From 536f06ed231742602b4a5d613b910fd04b6139ec Mon Sep 17 00:00:00 2001 From: Dave Kneeland Date: Thu, 20 Aug 2026 15:25:38 -0700 Subject: [PATCH] fix(android): retry CAN subscribe dropped by GATT-busy race Why: onboarding/Connected stalled at Pairing because the app never actually subscribed to the CAN characteristic. CAN and the Tesla status char enable back-to-back on service discovery, but Android's GATT client allows one op in flight, so the second write was refused. The old code ignored writeDescriptor()'s return value, so the dropped CAN subscribe looked like a success and no CAN frames arrived. Since Connected is gated on the first CAN frame, the app hung whenever there was no bus data (bench or an off car). Fix: check writeDescriptor()'s return value and retry the CAN subscribe (3 attempts, 250ms apart) when the write isn't queued, so it lands once the other subscribe finishes. Log only the failure path and a missing-CCCD error so a regression is visible without per-connect noise. Verified: first attempt is refused, retry queues successfully, firmware reports the CAN subscription, and the dashboard renders live data. --- .../dashpilot/datasource/DashKitDataSource.kt | 48 +++++++++++++++---- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/datasource/DashKitDataSource.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/datasource/DashKitDataSource.kt index 85898d85..f3e6621b 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/datasource/DashKitDataSource.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/datasource/DashKitDataSource.kt @@ -5,6 +5,8 @@ import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattDescriptor import android.os.Build +import android.os.Handler +import android.os.Looper import android.util.Log import com.softwiredtech.dashpilot.datamodel.dash.CarState import kotlinx.coroutines.flow.Flow @@ -29,8 +31,17 @@ class DashKitDataSource( private val CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") // The firmware now applies CAN acceptance filtering in hardware (per-bus // MCP251xFD filters), so the app no longer pushes a BLE filter list. + + // The app subscribes to two characteristics back-to-back when services + // become ready (this one and the Tesla status char). Android's GATT + // client allows only one operation in flight, so the second write can be + // refused while the first is still pending. Retry a few times. + private const val MAX_SUBSCRIBE_ATTEMPTS = 3 + private const val SUBSCRIBE_RETRY_DELAY_MS = 250L } + private val handler = Handler(Looper.getMainLooper()) + private val _incoming = MutableSharedFlow(replay = 1) @OptIn(FlowPreview::class) override val incomingMessages: Flow = _incoming.sample(40) @@ -58,18 +69,35 @@ class DashKitDataSource( return } gatt.setCharacteristicNotification(characteristic, true) + subscribeCan(gatt, characteristic, 0) + } + + private fun subscribeCan(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, attempt: Int) { val descriptor = characteristic.getDescriptor(CCCD_UUID) - if (descriptor != null) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - gatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) - } else { - @Suppress("DEPRECATION") - descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE - @Suppress("DEPRECATION") - gatt.writeDescriptor(descriptor) - } + if (descriptor == null) { + Log.e(TAG, "CAN CCCD (0x2902) not discovered") + return + } + // API 33+ writeDescriptor() returns Int (0 = queued); older returns Boolean. + val queued = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) == + BluetoothGatt.GATT_SUCCESS + } else { + @Suppress("DEPRECATION") + descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + @Suppress("DEPRECATION") + gatt.writeDescriptor(descriptor) + } + if (!queued && attempt < MAX_SUBSCRIBE_ATTEMPTS) { + // Android allows one GATT op in flight, so this write can be refused + // while the app-channel status subscribe is pending; retry after it + // completes. Only fires when the subscription would otherwise drop. + Log.d(TAG, "CAN CCCD write not queued (attempt=$attempt); retrying") + handler.postDelayed( + { subscribeCan(gatt, characteristic, attempt + 1) }, + SUBSCRIBE_RETRY_DELAY_MS + ) } - Log.d(TAG, "Subscribed to CAN notifications") } override fun onCharacteristicChanged(