From bb27928f92b3edac275ce00c8624ee15a1153080 Mon Sep 17 00:00:00 2001 From: Dave Kneeland Date: Tue, 25 Aug 2026 16:44:35 -0700 Subject: [PATCH 1/2] fix(android): retry GATT subscribes dropped by CCCD-busy races Android's GATT client allows one operation in flight; a CCCD write that races another subscribe is silently refused, leaving CAN notifications dead until reconnect. Replace the fire-and-forget descriptor write in DashKitDataSource with DashKitBleManager .subscribeWithRetry(), which verifies the write was queued and retries (up to 3x, 250ms apart). The helper is shared and will also back the Tesla status subscription. --- .../dashpilot/datasource/DashKitBleManager.kt | 41 +++++++++++++++++++ .../dashpilot/datasource/DashKitDataSource.kt | 16 +------- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/datasource/DashKitBleManager.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/datasource/DashKitBleManager.kt index 968a4ce9..fc5f744b 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/datasource/DashKitBleManager.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/datasource/DashKitBleManager.kt @@ -20,6 +20,7 @@ import android.os.Handler import android.os.Looper import android.util.Log import com.google.firebase.crashlytics.FirebaseCrashlytics +import java.util.UUID import com.softwiredtech.dashpilot.ble.VehicleControl import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -67,6 +68,12 @@ class DashKitBleManager(private val context: Context) { // Keepalive interval; the firmware drops the link after 60 s without a // ping (KEEPALIVE_TIMEOUT_S), so this allows a few missed writes. private const val PING_INTERVAL_MS = 15_000L + + // Android's GATT client allows only one operation in flight, so a CCCD + // write racing another subscribe can be refused; retry a few times. + private val CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + private const val MAX_SUBSCRIBE_ATTEMPTS = 3 + private const val SUBSCRIBE_RETRY_DELAY_MS = 250L } private val crashlytics = FirebaseCrashlytics.getInstance() @@ -624,4 +631,38 @@ class DashKitBleManager(private val context: Context) { } catch (_: Exception) {} scanning = false } + + /** Subscribes to a characteristic's notifications, retrying if the CCCD + * write races another GATT operation. Shared by the CAN and Tesla status + * sources. */ + fun subscribeWithRetry( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + logTag: String, + label: String + ) { + val descriptor = characteristic.getDescriptor(CCCD_UUID) + if (descriptor == null) { + Log.e(logTag, "$label CCCD (0x2902) not discovered") + return + } + var attempt = 0 + fun writeCccd() { + 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) { + attempt++ + Log.d(logTag, "$label CCCD write not queued (attempt=$attempt); retrying") + handler.postDelayed({ writeCccd() }, SUBSCRIBE_RETRY_DELAY_MS) + } + } + writeCccd() + } } 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..29e34b51 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 @@ -3,8 +3,6 @@ package com.softwiredtech.dashpilot.datasource import android.annotation.SuppressLint import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCharacteristic -import android.bluetooth.BluetoothGattDescriptor -import android.os.Build import android.util.Log import com.softwiredtech.dashpilot.datamodel.dash.CarState import kotlinx.coroutines.flow.Flow @@ -26,7 +24,6 @@ class DashKitDataSource( private const val TAG = "DashKitDataSource" private val SERVICE_UUID = UUID.fromString("CADA0000-CA00-B1E0-B0D6-C000AA0100A1") private val CHAR_UUID = UUID.fromString("CADA0001-CA00-B1E0-B0D6-C000AA0100A1") - 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. } @@ -58,18 +55,7 @@ class DashKitDataSource( return } gatt.setCharacteristicNotification(characteristic, true) - 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) - } - } - Log.d(TAG, "Subscribed to CAN notifications") + manager.subscribeWithRetry(gatt, characteristic, TAG, "CAN") } override fun onCharacteristicChanged( From dd2da90e12b639b06a2e73e2631835586a3fe3f4 Mon Sep 17 00:00:00 2001 From: Dave Kneeland Date: Tue, 25 Aug 2026 16:44:55 -0700 Subject: [PATCH 2/2] feat(android): assemble vehicle VIN from DashKit CAN stream Add VehicleVinAssembler, a pure-Kotlin assembler for the muxed VIN frames (bus 1, CAN id 0x405) carried by the existing DashKit BLE CAN notification stream. Extract CanPacket parsing so raw (bus, address, data) tuples feed both dashboard decoding and the assembler, with the firmware build_ble_packet wire format documented alongside the parser; subscription moves to subscribeWithRetry to survive GATT-busy CCCD races. Expose ConnectionViewModel.vehicleVin as a dedicated replaying state flow; keep VIN out of CarState and reset it during connection teardown. Includes parser and assembler unit tests (ordering, duplicates, batching, malformed packets). --- .../dashpilot/datasource/CanPacket.kt | 40 ++++++++++++ .../dashpilot/datasource/DashKitDataSource.kt | 62 ++++++++----------- .../dashpilot/vehicle/VehicleVinAssembler.kt | 57 +++++++++++++++++ .../viewmodel/ConnectionViewModel.kt | 13 +++- .../dashpilot/datasource/CanPacketVinTest.kt | 45 ++++++++++++++ .../vehicle/VehicleVinAssemblerTest.kt | 54 ++++++++++++++++ 6 files changed, 234 insertions(+), 37 deletions(-) create mode 100644 dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/datasource/CanPacket.kt create mode 100644 dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/vehicle/VehicleVinAssembler.kt create mode 100644 dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/datasource/CanPacketVinTest.kt create mode 100644 dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/vehicle/VehicleVinAssemblerTest.kt diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/datasource/CanPacket.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/datasource/CanPacket.kt new file mode 100644 index 00000000..f671ee38 --- /dev/null +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/datasource/CanPacket.kt @@ -0,0 +1,40 @@ +package com.softwiredtech.dashpilot.datasource + +class RawCanFrame( + val bus: Int, + val address: Int, + val data: ByteArray, +) + +// Wire format from firmware (build_ble_packet): +// [count : 1] +// per frame: +// [timestamp_us : LE32] +// [bus : 1] +// [addr : LE32] +// [len : 1] +// [data : len bytes] +internal fun parseCanPacket(payload: ByteArray): List { + if (payload.isEmpty()) return emptyList() + val frames = ArrayList(payload[0].toInt() and 0xFF) + var offset = 1 + val count = payload[0].toInt() and 0xFF + for (i in 0 until count) { + if (offset + 4 > payload.size) break + offset += 4 + if (offset >= payload.size) break + val bus = payload[offset].toInt() and 0xFF + offset += 1 + if (offset + 4 > payload.size) break + val addr = java.nio.ByteBuffer.wrap(payload, offset, 4) + .order(java.nio.ByteOrder.LITTLE_ENDIAN).int + offset += 4 + if (offset >= payload.size) break + val len = payload[offset].toInt() and 0xFF + offset += 1 + if (offset + len > payload.size) break + frames.add(RawCanFrame(bus, addr, payload.copyOfRange(offset, offset + len))) + offset += len + } + return frames +} 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 29e34b51..084550f8 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,33 +5,38 @@ import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCharacteristic import android.util.Log import com.softwiredtech.dashpilot.datamodel.dash.CarState -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.util.UUID import com.softwiredtech.dashpilot.vehicle.CanFrameDecoder +import com.softwiredtech.dashpilot.vehicle.VehicleVinAssembler +import com.softwiredtech.dashpilot.vehicle.VehicleVinState import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.sample +import java.util.UUID @SuppressLint("MissingPermission") class DashKitDataSource( private val manager: DashKitBleManager, - private val decoder: CanFrameDecoder + private val decoder: CanFrameDecoder, ) : IDataSource, GattListener { companion object { private const val TAG = "DashKitDataSource" private val SERVICE_UUID = UUID.fromString("CADA0000-CA00-B1E0-B0D6-C000AA0100A1") private val CHAR_UUID = UUID.fromString("CADA0001-CA00-B1E0-B0D6-C000AA0100A1") - // The firmware now applies CAN acceptance filtering in hardware (per-bus - // MCP251xFD filters), so the app no longer pushes a BLE filter list. } private val _incoming = MutableSharedFlow(replay = 1) @OptIn(FlowPreview::class) override val incomingMessages: Flow = _incoming.sample(40) + private val _vinState = MutableStateFlow(VehicleVinState.Waiting) + val vinState: StateFlow = _vinState.asStateFlow() + private val vinAssembler = VehicleVinAssembler() + private var currentState = CarState() override fun connect(address: String) { @@ -40,10 +45,13 @@ class DashKitDataSource( } override fun disconnect() { + resetVin() manager.removeGattListener(this) } override fun onServicesReady(gatt: BluetoothGatt) { + resetVin() + val service = gatt.getService(SERVICE_UUID) if (service == null) { Log.e(TAG, "CAN BLE service not found") @@ -67,37 +75,19 @@ class DashKitDataSource( parseAndEmit(value) } + override fun onDisconnected() = resetVin() + private fun parseAndEmit(payload: ByteArray) { - // Wire format from firmware (build_ble_packet): - // [count : 1] - // per frame: - // [timestamp_us : LE32] - // [bus : 1] - // [addr : LE32] - // [len : 1] - // [data : len bytes] if (payload.isEmpty()) return - val count = payload[0].toInt() and 0xFF - var offset = 1 - for (i in 0 until count) { - if (offset + 4 > payload.size) break - // Timestamp is currently unused by the decoder; skip it. - offset += 4 - if (offset >= payload.size) break - val bus = payload[offset].toInt() and 0xFF - offset += 1 - if (offset + 4 > payload.size) break - val addr = ByteBuffer.wrap(payload, offset, 4).order(ByteOrder.LITTLE_ENDIAN).int - offset += 4 - if (offset >= payload.size) break - val len = payload[offset].toInt() and 0xFF - offset += 1 - if (offset + len > payload.size) break - val data = payload.copyOfRange(offset, offset + len) - offset += len - - currentState = decoder.decodeFrame(bus, addr, data) + for (frame in parseCanPacket(payload)) { + _vinState.value = vinAssembler.onFrame(frame.bus, frame.address, frame.data) + currentState = decoder.decodeFrame(frame.bus, frame.address, frame.data) } _incoming.tryEmit(currentState) } + + private fun resetVin() { + vinAssembler.reset() + _vinState.value = VehicleVinState.Waiting + } } diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/vehicle/VehicleVinAssembler.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/vehicle/VehicleVinAssembler.kt new file mode 100644 index 00000000..b35c6277 --- /dev/null +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/vehicle/VehicleVinAssembler.kt @@ -0,0 +1,57 @@ +package com.softwiredtech.dashpilot.vehicle + +sealed interface VehicleVinState { + data object Waiting : VehicleVinState + data class Available(val vin: String) : VehicleVinState + data object Invalid : VehicleVinState +} + +class VehicleVinAssembler { + companion object { + const val VIN_BUS = 1 + const val VIN_CAN_ID = 0x405 + const val VIN_FRAME_LEN = 8 + const val VIN_LEN = 17 + const val MUX_A = 0x10 + const val MUX_B = 0x11 + const val MUX_C = 0x12 + } + + private val segments = arrayOfNulls(3) + private var completed: String? = null + + fun onFrame(bus: Int, address: Int, data: ByteArray): VehicleVinState = synchronized(this) { + completed?.let { return@synchronized VehicleVinState.Available(it) } + if (bus != VIN_BUS || address != VIN_CAN_ID || data.size != VIN_FRAME_LEN) { + return@synchronized VehicleVinState.Waiting + } + + val (index, offset) = when (data[0].toInt() and 0xFF) { + MUX_A -> 0 to 5 + MUX_B -> 1 to 1 + MUX_C -> 2 to 1 + else -> return@synchronized VehicleVinState.Waiting + } + if (index == 0 && (1..4).any { data[it] != 0.toByte() }) { + return@synchronized VehicleVinState.Invalid + } + val value = String(data, offset, VIN_FRAME_LEN - offset, Charsets.US_ASCII) + if (!value.all(::isVinChar)) { + return@synchronized VehicleVinState.Invalid + } + + segments[index] = value + if (segments.all { it != null }) { + completed = segments.joinToString(separator = "") { it!! } + } + completed?.let { VehicleVinState.Available(it) } ?: VehicleVinState.Waiting + } + + fun reset() = synchronized(this) { + segments.fill(null) + completed = null + } +} + +internal fun isVinChar(c: Char): Boolean = + c in '0'..'9' || (c in 'A'..'Z' && c !in "IOQ") diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/viewmodel/ConnectionViewModel.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/viewmodel/ConnectionViewModel.kt index 787e2920..70c2210f 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/viewmodel/ConnectionViewModel.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/viewmodel/ConnectionViewModel.kt @@ -60,6 +60,7 @@ import com.softwiredtech.dashpilot.jni.VehicleBridge import com.softwiredtech.dashpilot.util.NetworkUtil import com.softwiredtech.dashpilot.vehicle.CanFrameDecoder import com.softwiredtech.dashpilot.vehicle.VehicleProfileLoader +import com.softwiredtech.dashpilot.vehicle.VehicleVinState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -278,6 +279,9 @@ class ConnectionViewModel(private var networkUtil: NetworkUtil) : ViewModel() { private val _dashState = MutableStateFlow?>(null) val dashState = _dashState.asStateFlow() + private val _vehicleVin = MutableStateFlow(VehicleVinState.Waiting) + val vehicleVin: StateFlow = _vehicleVin.asStateFlow() + private fun phoneBatteryFlow(context: Context): Flow = flow { val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager while (true) { @@ -344,7 +348,13 @@ class ConnectionViewModel(private var networkUtil: NetworkUtil) : ViewModel() { } } val decoder = CanFrameDecoder(bridge, profile) - DashKitDataSource(manager, decoder) + val ds = DashKitDataSource(manager, decoder) + launch { + ds.vinState.collect { + if (_dataSource.value === ds) _vehicleVin.value = it + } + } + ds } DataSourceType.WEBSOCKET -> WebsocketDataSource() else -> CommaDataSource(bridge, profile) @@ -442,6 +452,7 @@ class ConnectionViewModel(private var networkUtil: NetworkUtil) : ViewModel() { _bleManager.value?.disconnect() _bleManager.value = null _dashState.value = null + _vehicleVin.value = VehicleVinState.Waiting _hasAutoNavigatedToDashboard.value = false } diff --git a/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/datasource/CanPacketVinTest.kt b/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/datasource/CanPacketVinTest.kt new file mode 100644 index 00000000..155ff524 --- /dev/null +++ b/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/datasource/CanPacketVinTest.kt @@ -0,0 +1,45 @@ +package com.softwiredtech.dashpilot.datasource + +import com.softwiredtech.dashpilot.vehicle.VehicleVinAssembler +import com.softwiredtech.dashpilot.vehicle.VehicleVinState +import org.junit.Assert.assertEquals +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class CanPacketVinTest { + @Test + fun assembles_vin_from_ble_notification() { + val vin = "5YJ3E7EB1MF123456" + val frames = listOf( + vinFrame(0x12, vin.substring(10), 1), + vinFrame(0x10, vin.substring(0, 3), 5), + vinFrame(0x11, vin.substring(3, 10), 1), + ) + val packet = ByteBuffer.allocate(1 + frames.size * 18) + .order(ByteOrder.LITTLE_ENDIAN) + .put(frames.size.toByte()) + for (frame in frames) { + packet.putInt(0) + packet.put(1.toByte()) + packet.putInt(0x405) + packet.put(8.toByte()) + packet.put(frame) + } + + val assembler = VehicleVinAssembler() + var state: VehicleVinState = VehicleVinState.Waiting + for (frame in parseCanPacket(packet.array())) { + state = assembler.onFrame(frame.bus, frame.address, frame.data) + } + + assertEquals(vin, (state as VehicleVinState.Available).vin) + } + + private fun vinFrame(mux: Int, text: String, offset: Int): ByteArray { + val data = ByteArray(8) + data[0] = mux.toByte() + text.forEachIndexed { index, char -> data[offset + index] = char.code.toByte() } + return data + } +} diff --git a/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/vehicle/VehicleVinAssemblerTest.kt b/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/vehicle/VehicleVinAssemblerTest.kt new file mode 100644 index 00000000..0a0bd45f --- /dev/null +++ b/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/vehicle/VehicleVinAssemblerTest.kt @@ -0,0 +1,54 @@ +package com.softwiredtech.dashpilot.vehicle + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class VehicleVinAssemblerTest { + private val vin = "5YJ3E7EB1MF123456" + + private fun frame(mux: Int, text: String): ByteArray { + val data = ByteArray(8) + data[0] = mux.toByte() + val offset = if (mux == VehicleVinAssembler.MUX_A) 5 else 1 + text.forEachIndexed { index, char -> data[offset + index] = char.code.toByte() } + return data + } + + private fun frameA() = frame(VehicleVinAssembler.MUX_A, vin.substring(0, 3)) + private fun frameB() = frame(VehicleVinAssembler.MUX_B, vin.substring(3, 10)) + private fun frameC() = frame(VehicleVinAssembler.MUX_C, vin.substring(10, 17)) + + @Test + fun assembles_out_of_order_frames() { + val assembler = VehicleVinAssembler() + assembler.onFrame(1, 0x405, frameC()) + assembler.onFrame(1, 0x405, frameA()) + assembler.onFrame(1, 0x405, frameA()) + val state = assembler.onFrame(1, 0x405, frameB()) + + assertEquals(vin, (state as VehicleVinState.Available).vin) + } + + @Test + fun rejects_illegal_vin_characters() { + val assembler = VehicleVinAssembler() + val invalid = frameC().also { it[1] = 'I'.code.toByte() } + assembler.onFrame(1, 0x405, frameA()) + assembler.onFrame(1, 0x405, frameB()) + val state = assembler.onFrame(1, 0x405, invalid) + + assertTrue(state is VehicleVinState.Invalid) + } + + @Test + fun reset_prevents_cross_session_assembly() { + val assembler = VehicleVinAssembler() + assembler.onFrame(1, 0x405, frameA()) + assembler.onFrame(1, 0x405, frameB()) + assembler.reset() + val state = assembler.onFrame(1, 0x405, frameC()) + + assertEquals(VehicleVinState.Waiting, state) + } +}