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/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt b/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt index d815226..00d5f43 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 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 60b6aff..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 @@ -1,151 +1,146 @@ -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 controllers. - * 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) { - - 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 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 manufacturerData = nintendoData(result) ?: 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 - if (isKnownAddress(result.device.address)) return - - val name = result.device.name - ?: result.scanRecord?.deviceName - ?: "Joy-Con 2" - 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 nintendoData(result: ScanResult): ByteArray? = - result.scanRecord?.getManufacturerSpecificData(NINTENDO_MANUFACTURER_ID) - - 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): Side { - sideFromName(name)?.let { return it } - 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 - 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. - */ - 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 + + // 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}") + 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/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/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/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 cf400c5..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 @@ -3,9 +3,7 @@ package com.joegec.joycon2android.connection /** * 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 …`. + * advertises its address; holding SYNC (pairing mode) zeroes the field. */ object JoyconAdvertisement { 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..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 @@ -23,11 +23,8 @@ import kotlinx.coroutines.flow.asStateFlow import java.util.UUID /** - * 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( @@ -45,6 +42,11 @@ 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 NYXI_INPUT_NOTIFY_CHAR = UUID.fromString("d5a9e01e-2ffc-4cca-b20c-8b67142bf442") + private val INIT_CMD_1 = byteArrayOf( 0x0C, 0x91.toByte(), 0x01, 0x02, 0x00, 0x04, 0x00, 0x00, 0xFF.toByte(), 0x00, 0x00, 0x00 @@ -54,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, @@ -78,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 @@ -106,7 +95,7 @@ class JoyconConnection( 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 fun connect(device: BluetoothDevice) { @@ -129,7 +118,7 @@ class JoyconConnection( BluetoothProfile.STATE_CONNECTED -> { Log.i(TAG, "[$side] Connected. Requesting MTU $DESIRED_MTU") _connectionState.value = JoyconConnectionState( - connected = true, deviceName = deviceName + connected = true, deviceName = deviceName, bondState = g.device.bondState ) g.requestMtu(DESIRED_MTU) } @@ -166,36 +155,64 @@ class JoyconConnection( return } - val svc = g.getService(INPUT_SERVICE) + // 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) + } + } + } + } + } + } + + var svc = g.getService(INPUT_SERVICE) + if (svc == null) { + svc = g.getService(KEYLINKER_SERVICE) + } + if (svc == null) { + val uuids = g.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) { + // 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) } - } else { - Log.w(TAG, "[$side] CMD_RESPONSE char has no CCCD descriptor") } } @@ -204,38 +221,54 @@ class JoyconConnection( 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!") } + + // 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 { + writeDescriptor(g, nyxiCccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) + } + } + } + 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 { 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") + 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) } @@ -259,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) { @@ -288,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) } @@ -297,22 +327,25 @@ class JoyconConnection( } 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) - } + 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 + mainHandler.post { opQueue.enqueue { sendLedCommand(g) } } } } + + if (uuid == CMD_RESPONSE_CHAR || uuid == KEYLINKER_NOTIFY) { + handleCmdResponse(data) + } + } + + private fun handleCmdResponse(data: ByteArray) { + SpiColorParser.parseAccentColor(data)?.let { color -> + _connectionState.value = _connectionState.value.copy(accentColor = color) + } } private fun writeCharacteristic( @@ -345,5 +378,4 @@ class JoyconConnection( g.writeDescriptor(descriptor) } } - } 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..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,5 +1,6 @@ package com.joegec.joycon2android.connection + import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.JoyconInput import com.joegec.joycon2android.model.Side @@ -25,7 +26,14 @@ object PacketParser { 0x0100000000L to JoyconButton.GR, 0x0200000000L to JoyconButton.GL, ) - fun parse(data: ByteArray, side: Side): JoyconInput? { + fun parse(data: ByteArray, side: Side, isNyxiChar: Boolean = false): JoyconInput? { + if (data.size < 12) return null + + 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) @@ -52,6 +60,120 @@ object PacketParser { ) } + private fun isNyxiFormat(data: ByteArray): Boolean { + if (data.size < 8) return false + val status = data[1].toInt() and 0xFF + + // 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 + } + + private fun parseNyxiFormat(data: ByteArray, side: Side): JoyconInput? { + val status = data[1].toInt() and 0xFF + + // 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 + + if (!isValidStatus || (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 + + 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: 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), + pressed = pressed, + 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, + ) + } + 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) @@ -71,7 +193,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 af47f7a..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 @@ -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 @@ -64,4 +65,47 @@ 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 or mismatched status are accepted if valid status range`() { + val data = ByteArray(12).apply { + this[1] = 0x0C // Any valid status like 0x0C or 0x10 + putStick(5, 0x999 to 0x999) + } + val input = PacketParser.parse(data, Side.LEFT, isNyxiChar = true) + Assert.assertNotNull(input) + } + + @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 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) + assertNull(input) + } } 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/") } } }