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,138 @@
package com.softwiredtech.dashpilot.ble

import android.annotation.SuppressLint
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.os.Build
import android.util.Log
import com.softwiredtech.dashpilot.datasource.DashKitBleManager
import java.util.UUID

/**
* Sends DashKit *app-channel* commands over the Tesla service (CADA0200) — the
* phone-side half of the Phase 4 pairing/reset UX.
*
* This is intentionally separate from [VehicleControl] (which is hardcoded to
* the CAN control characteristic CADA0004): the app-channel writes go to
* CADA0201 with the same 3-byte [opcode][value_lo][value_hi] framing, and carry
* only pairing lifecycle opcodes:
* CMD_START (0x01) - begin enrollment (the ONLY trigger, app-triggered-only)
* CMD_RESET (0x02) - factory-reset the Tesla key (erase -> re-stage)
* CMD_CANCEL(0x03) - cancel an open pairing window
*
* Enrollment is app-triggered only: the firmware never starts pairing on its
* own, because the DashKit sits in the car trim and its LEDs aren't visible.
*/
@SuppressLint("MissingPermission")
object TeslaClient {

private const val TAG = "TeslaClient"

// App-channel service + characteristics (CADA02xx)
val SERVICE_UUID = UUID.fromString("CADA0200-CA00-B1E0-B0D6-C000AA0100A1")
val COMMAND_CHAR_UUID = UUID.fromString("CADA0201-CA00-B1E0-B0D6-C000AA0100A1")
val STATUS_CHAR_UUID = UUID.fromString("CADA0202-CA00-B1E0-B0D6-C000AA0100A1")
val CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")

// Command opcodes (match TESLA_CMD_* in main/ble/ble_appchan.h)
const val CMD_START: Int = 0x01
const val CMD_RESET: Int = 0x02
const val CMD_CANCEL: Int = 0x03
const val CMD_PROVISION: Int = 0x04

const val VIN_LEN: Int = 17

/** Ask the firmware to begin enrollment for the staged car. */
fun sendStart(manager: DashKitBleManager): Boolean = sendTesla(manager, CMD_START)

/** Factory-reset the Tesla key (erase -> the next sighting re-stages). */
fun sendReset(manager: DashKitBleManager): Boolean = sendTesla(manager, CMD_RESET)

/** Cancel an open pairing window. */
fun sendCancel(manager: DashKitBleManager): Boolean = sendTesla(manager, CMD_CANCEL)

// A successful write only dispatches provisioning; Staged acknowledges it.
fun sendProvision(manager: DashKitBleManager, vin: String, mac: String): Boolean {
val payload = buildProvisionPayload(vin, mac) ?: run {
Log.w(TAG, "provision: rejecting invalid vehicle identity or address")
return false
}
return writePayload(manager, payload)
}

internal fun isValidVin(vin: String): Boolean =
vin.length == VIN_LEN && vin.all { it in '0'..'9' || (it in 'A'..'Z' && it !in "IOQ") }

internal fun isValidMac(mac: String): Boolean {
if (mac.length != 17) return false
for (i in mac.indices) {
val c = mac[i]
if (i % 3 == 2) {
if (c != ':') return false
} else if (!(c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F')) {
return false
}
}
return true
}

internal fun buildProvisionPayload(vin: String, mac: String): ByteArray? {
val v = vin.trim().uppercase()
if (!isValidVin(v)) return null
val m = mac.trim()
if (!isValidMac(m)) return null
val payload = ByteArray(1 + VIN_LEN + 7)
payload[0] = CMD_PROVISION.toByte()
v.toByteArray(Charsets.US_ASCII).copyInto(payload, destinationOffset = 1)
payload[18] = 0x00 // BLE_ADDR_PUBLIC
// NimBLE stores the MAC octets in reverse display order.
for (i in 0..5) {
val pairIndex = 5 - i
payload[19 + i] = m.substring(pairIndex * 3, pairIndex * 3 + 2).toInt(16).toByte()
}
return payload
}

/** Write an app-channel command to the DashKit (same [opcode][value_lo][value_hi]
* framing and TIRAMISU branch as [VehicleControl.send], but on the new UUIDs).
* Returns true if dispatched (not acknowledged).
*/
fun sendTesla(manager: DashKitBleManager, opcode: Int): Boolean {
val payload = byteArrayOf(
(opcode and 0xFF).toByte(),
0,
0
)
Log.d(TAG, "Sending app-channel 0x%02X".format(opcode))
return writePayload(manager, payload)
}

private fun writePayload(manager: DashKitBleManager, payload: ByteArray): Boolean {
val gatt = manager.gatt ?: run {
Log.w(TAG, "No GATT connection; cannot send app-channel write")
return false
}
val service = gatt.getService(SERVICE_UUID) ?: run {
Log.w(TAG, "App-channel service not found; firmware may be older")
return false
}
val commandChar = service.getCharacteristic(COMMAND_CHAR_UUID) ?: run {
Log.w(TAG, "App-channel command characteristic not found")
return false
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeCharacteristic(
commandChar,
payload,
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
) == BluetoothGatt.GATT_SUCCESS
} else {
@Suppress("DEPRECATION")
commandChar.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
@Suppress("DEPRECATION")
commandChar.value = payload
@Suppress("DEPRECATION")
gatt.writeCharacteristic(commandChar)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.softwiredtech.dashpilot.ble

/**
* Parsed Tesla status indication from the DashKit app-channel service (CADA0202).
*
* The DashKit is installed in the car trim (LEDs not visible), so this frame is
* the phone's only view of the car / enrollment state. See
* docs/tesla-ble-app-ux-handoff.md §5 (status frame).
*/
enum class TeslaLinkState(val raw: Int) {
NeverEnrolled(0x00),
EnrolledNotConnected(0x01),
EnrolledConnected(0x02),
PairingWindow(0x03),
EnrollmentFault(0x04),
Staged(0x05), // car found, awaiting app start
Connecting(0x06), // app start accepted; contacting the car (pre-tap window)
Unknown(0xFF);

val hasKey: Boolean
get() = this == EnrolledNotConnected || this == EnrolledConnected

val connected: Boolean
get() = this == EnrolledConnected

companion object {
fun from(raw: Int): TeslaLinkState = entries.firstOrNull { it.raw == raw } ?: Unknown
}
}

enum class TeslaFaultDetail(val raw: Int) {
TapTimeout(0x00),
Rejected(0x01),
Protocol(0x02),
Persist(0x03),
None(0xFF);

companion object {
fun from(raw: Int): TeslaFaultDetail = entries.firstOrNull { it.raw == raw } ?: None
}
}

data class TeslaStatus(
val version: Int,
val linkState: TeslaLinkState,
val presence: Int,
val lock: Int,
val sleep: Int,
val flags: Int,
val faultDetail: TeslaFaultDetail,
) {
companion object {
/** Stable value before any frame arrives. */
val Idle = TeslaStatus(0x01, TeslaLinkState.Unknown, 0xFF, 0xFF, 0xFF, 0, TeslaFaultDetail.None)

/**
* Parse the 7-byte status frame:
* [0] frame version = 0x01
* [1] link_state
* [2] presence 0 absent, 1 present, 0xFF unknown
* [3] lock 0 unlocked, 1 locked, 0xFF unknown
* [4] sleep 0 awake, 1 asleep, 0xFF unknown
* [5] flags bit0 charge-connected, bit1 charging, bit2 climate-on
* [6] fault detail (0xFF unless link_state == enrollment fault)
*/
fun parse(payload: ByteArray): TeslaStatus? {
if (payload.size < 7) return null
val b = { i: Int -> payload[i].toInt() and 0xFF }
if (b(0) != 0x01) return null
return TeslaStatus(
version = b(0),
linkState = TeslaLinkState.from(b(1)),
presence = b(2),
lock = b(3),
sleep = b(4),
flags = b(5),
faultDetail = TeslaFaultDetail.from(b(6)),
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package com.softwiredtech.dashpilot.ble

import android.annotation.SuppressLint
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.util.Log
import com.softwiredtech.dashpilot.datasource.DashKitBleManager
import com.softwiredtech.dashpilot.datasource.GattListener
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

/**
* Subscribes to the DashKit Tesla app-channel status indication (CADA0202) and
* exposes the last-parsed [TeslaStatus] on a [StateFlow]. Mirrors
* [DashKitOtaUpdate]'s listener pattern.
*
* The connection is shared with the CAN data source via [DashKitBleManager]; this
* source only adds a second characteristic subscription and is removed when the
* manager is torn down.
*/
@SuppressLint("MissingPermission")
class TeslaStatusSource(private val manager: DashKitBleManager) : GattListener {

companion object {
private const val TAG = "TeslaStatusSource"
}

private val _status = MutableStateFlow(TeslaStatus.Idle)
val status: StateFlow<TeslaStatus> = _status.asStateFlow()

private val _resetPending = MutableStateFlow(false)
val resetPending: StateFlow<Boolean> = _resetPending.asStateFlow()

private var statusChar: BluetoothGattCharacteristic? = null

/** Register with the manager so we receive onServicesReady + notifications. */
fun start() {
manager.addGattListener(this)
val g = manager.gatt
if (g != null) setupStatusSubscription(g)
}

/** Unregister from the manager. */
fun stop() {
manager.removeGattListener(this)
}

fun requestReset(): Boolean {
_resetPending.value = true
if (TeslaClient.sendReset(manager)) return true
_resetPending.value = false
return false
}

override fun onServicesReady(gatt: BluetoothGatt) {
setupStatusSubscription(gatt)
}

private fun setupStatusSubscription(gatt: BluetoothGatt) {
val service = gatt.getService(TeslaClient.SERVICE_UUID)
val characteristic = service?.getCharacteristic(TeslaClient.STATUS_CHAR_UUID)
if (characteristic == null) {
Log.e(TAG, "App-channel/status characteristic not found; firmware may be older")
return
}
statusChar = characteristic
gatt.setCharacteristicNotification(characteristic, true)
manager.subscribeWithRetry(gatt, characteristic, TAG, "Status")
}

override fun onDescriptorWrite(
gatt: BluetoothGatt,
descriptor: BluetoothGattDescriptor,
status: Int
) {
// After subscribing, read the current value so we don't wait for the
// next change notification (e.g. during a long reconnect backoff).
if (descriptor.characteristic?.uuid == TeslaClient.STATUS_CHAR_UUID &&
status == BluetoothGatt.GATT_SUCCESS
) {
val c = statusChar ?: return
@Suppress("DEPRECATION")
gatt.readCharacteristic(c)
}
}

override fun onCharacteristicRead(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
status: Int
) {
if (status != BluetoothGatt.GATT_SUCCESS ||
characteristic.uuid != TeslaClient.STATUS_CHAR_UUID
) return
consume(value)
}

override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray
) {
if (characteristic.uuid != TeslaClient.STATUS_CHAR_UUID) return
consume(value)
}

private fun consume(value: ByteArray) {
val parsed = TeslaStatus.parse(value) ?: return
if (parsed.linkState == TeslaLinkState.NeverEnrolled ||
parsed.linkState == TeslaLinkState.Staged
) {
_resetPending.value = false
}
if (parsed != _status.value) {
Log.d(TAG, "Tesla status: $parsed")
_status.value = parsed
}
}

override fun onDisconnected() {
// Keep the last value so the UI can still render "enrolled, not
// connected" while the manager auto-reconnects; a re-subscribe happens
// via onServicesReady after the next connect.
}
}
Loading
Loading