Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<RawCanFrame> {
if (payload.isEmpty()) return emptyList()
val frames = ArrayList<RawCanFrame>(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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<CarState>(replay = 1)
@OptIn(FlowPreview::class)
override val incomingMessages: Flow<CarState> = _incoming.sample(40)

private val _vinState = MutableStateFlow<VehicleVinState>(VehicleVinState.Waiting)
val vinState: StateFlow<VehicleVinState> = _vinState.asStateFlow()
private val vinAssembler = VehicleVinAssembler()

private var currentState = CarState()

override fun connect(address: String) {
Expand All @@ -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")
Expand All @@ -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(
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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<String>(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")
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -278,6 +279,9 @@ class ConnectionViewModel(private var networkUtil: NetworkUtil) : ViewModel() {
private val _dashState = MutableStateFlow<Flow<DashState>?>(null)
val dashState = _dashState.asStateFlow()

private val _vehicleVin = MutableStateFlow<VehicleVinState>(VehicleVinState.Waiting)
val vehicleVin: StateFlow<VehicleVinState> = _vehicleVin.asStateFlow()

private fun phoneBatteryFlow(context: Context): Flow<Int> = flow {
val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
while (true) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading