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
Expand Up @@ -34,6 +34,7 @@ import androidx.navigation.toRoute
import androidx.startup.AppInitializer
import app.rive.runtime.kotlin.RiveInitializer
import com.softwiredtech.dashpilot.ble.FirmwareUpdateManager
import com.softwiredtech.dashpilot.ble.TeslaStatusSource
import com.softwiredtech.dashpilot.datamodel.dash.DashboardType
import com.softwiredtech.dashpilot.datamodel.dash.ManifestLoader
import com.softwiredtech.dashpilot.datamodel.dash.availableDashboards
Expand All @@ -48,6 +49,7 @@ import com.softwiredtech.dashpilot.navigation.DashboardRoute
import com.softwiredtech.dashpilot.navigation.OnboardingRoute
import com.softwiredtech.dashpilot.navigation.SettingsRoute
import com.softwiredtech.dashpilot.navigation.SetupRoute
import com.softwiredtech.dashpilot.navigation.TeslaEnrollRoute
import com.softwiredtech.dashpilot.navigation.ThemePickerRoute
import com.softwiredtech.dashpilot.ui.AutomationsScreen
import com.softwiredtech.dashpilot.ui.ControlScreen
Expand All @@ -57,6 +59,7 @@ import com.softwiredtech.dashpilot.ui.LOCAL_ASSET_BASE_URL
import com.softwiredtech.dashpilot.ui.SettingsScreen
import com.softwiredtech.dashpilot.ui.ThemePickerScreen
import com.softwiredtech.dashpilot.ui.onboarding.OnboardingScreen
import com.softwiredtech.dashpilot.ui.tesla.TeslaEnrollFlow
import com.softwiredtech.dashpilot.ui.theme.DashPilotTheme
import com.softwiredtech.dashpilot.util.NetworkUtil
import com.softwiredtech.dashpilot.viewmodel.ConnectionViewModel
Expand Down Expand Up @@ -174,6 +177,12 @@ class MainActivity : ComponentActivity() {
onDispose { dashkitUpdateManager?.dispose() }
}

// Phase 4: Tesla status/command channel rides the same bond.
val teslaSource = remember(bleManager) { bleManager?.let { TeslaStatusSource(it) } }
val teslaStatus = teslaSource?.status
LaunchedEffect(teslaSource) { teslaSource?.start() }
DisposableEffect(teslaSource) { onDispose { teslaSource?.stop() } }

LaunchedEffect(startupTarget) {
when (startupTarget.route) {
ConnectionViewModel.StartupRoute.ONBOARDING_DASHKIT -> {
Expand Down Expand Up @@ -247,6 +256,8 @@ class MainActivity : ComponentActivity() {
bleManager = manager,
dashState = dashStateFlow,
pinnedControlId = pinnedControl,
teslaStatus = teslaStatus,
onEnrollTesla = { navController.navigate(TeslaEnrollRoute) },
onConnect = { serverAddress, dataSourceType ->
connectionVM.connect(
context, serverAddress, dataSourceType,
Expand Down Expand Up @@ -312,6 +323,8 @@ class MainActivity : ComponentActivity() {
onDisplaySettingsChanged = { connectionVM.updateDisplaySettings(it) },
bleManager = manager,
dashkitUpdateManager = dashkitUpdateManager,
teslaStatus = teslaStatus,
onEnrollTesla = { navController.navigate(TeslaEnrollRoute) },
onReplayOnboarding = {
connectionVM.disconnect()
setOnboardingCompleted(context, false)
Expand All @@ -324,6 +337,14 @@ class MainActivity : ComponentActivity() {
}
)
}
composable<TeslaEnrollRoute> {
val manager by connectionVM.bleManager.collectAsState()
TeslaEnrollFlow(
manager = manager,
statusFlow = teslaStatus,
onClose = { navController.popBackStack() }
)
}
composable<ThemePickerRoute> {
ThemePickerScreen(
onBack = { navController.popBackStack(ThemePickerRoute, inclusive = true) }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
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

/** VIN length per ISO 3779 (also enforced by the firmware). */
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)

/**
* Stage the car the phone discovered (firmware TESLA_CMD_PROVISION).
*
* Payload: [opcode][17-byte VIN][address type][6 MAC bytes]. [mac] is the
* advertised address as a human-readable string ("AA:BB:CC:DD:EE:FF"); it
* is sent in NimBLE's raw ble_addr_t.val byte order — val[0] is the LAST
* pair of the string, val[5] the first — which is what the firmware stores
* and connects with. The address type is public (0x00), matching how
* Tesla vehicles advertise.
*/
fun sendProvision(manager: DashKitBleManager, vin: String, mac: String): Boolean {
val v = vin.trim().uppercase()
if (v.length != VIN_LEN) {
Log.w(TAG, "provision: bad VIN length ${v.length}")
return false
}
val octets = mac.split(":").mapNotNull { it.trim().toIntOrNull(16)?.toByte() }
if (octets.size != 6) {
Log.w(TAG, "provision: bad MAC \"$mac\"")
return false
}
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
for (i in 0..5) payload[19 + i] = octets[5 - i] // reversed -> NimBLE order
Log.d(TAG, "Sending provision for VIN $v")
return writePayload(manager, 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,113 @@
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 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)
}

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 != _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