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/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..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 @@ -3,38 +3,40 @@ 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 -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") - 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. } 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) { @@ -43,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") @@ -58,18 +63,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( @@ -81,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) + } +}