From 849f859fad333f814b002ace01b5b50c60121c05 Mon Sep 17 00:00:00 2001 From: Dave Kneeland Date: Thu, 20 Aug 2026 19:18:11 -0700 Subject: [PATCH 1/4] feat(android): Phase 4 Tesla BLE app-channel UX - Add TeslaStatus model + parser and TeslaClient with app-channel opcodes (0x01 start / 0x02 reset / 0x03 cancel) on the new CADA0200 service. - Add TeslaStatusSource (GattListener) subscribing to CADA0202 -> StateFlow. - Add one-line TeslaTile on Home (key-not-set-up / staged / connected / not-connected; first two open the enroll flow). - Add standalone TeslaEnrollFlow (Start -> tap-window countdown + Cancel -> success/fault) driven by the status flow; DevicePuck gains an accent param. - Add Settings > DashKit > Tesla key section: status, enroll, two-phase reset. - Wire nav route TeslaEnrollRoute + status source lifecycle in MainActivity; cyan #4FD1FF in Color.kt; strings in strings.xml. Review findings folded in: read current status on subscribe (fresh subscribers don't wait for a reconnect backoff) + handle onCharacteristicRead; proper HomeTile text for PairingWindow/EnrollmentFault; ErrorStep maps TeslaFaultDetail.None to a neutral message; hoisted recreate-on-recompose fallback StateFlows to stable remember values; added TeslaStatusTest covering the 7-byte frame parser + link-state mapping. Also drops dead tesla_enroll_confirm/_card_detected/_already_enrolled strings - the firmware exposes no such discrete states (ponytail-audit). --- .../softwiredtech/dashpilot/MainActivity.kt | 21 ++ .../dashpilot/ble/TeslaClient.kt | 96 +++++++ .../dashpilot/ble/TeslaStatus.kt | 80 ++++++ .../dashpilot/ble/TeslaStatusSource.kt | 129 ++++++++++ .../dashpilot/navigation/Routes.kt | 3 + .../softwiredtech/dashpilot/ui/HomeScreen.kt | 15 ++ .../dashpilot/ui/SettingsScreen.kt | 110 +++++++- .../dashpilot/ui/onboarding/DevicePuck.kt | 5 +- .../dashpilot/ui/tesla/TeslaEnrollFlow.kt | 234 ++++++++++++++++++ .../dashpilot/ui/tesla/TeslaTile.kt | 97 ++++++++ .../softwiredtech/dashpilot/ui/theme/Color.kt | 4 + .../app/src/main/res/values/strings.xml | 47 ++++ .../dashpilot/TeslaStatusTest.kt | 70 ++++++ 13 files changed, 907 insertions(+), 4 deletions(-) create mode 100644 dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaClient.kt create mode 100644 dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaStatus.kt create mode 100644 dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaStatusSource.kt create mode 100644 dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaEnrollFlow.kt create mode 100644 dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaTile.kt create mode 100644 dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/TeslaStatusTest.kt diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/MainActivity.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/MainActivity.kt index 003a84bc..c75cfbc5 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/MainActivity.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/MainActivity.kt @@ -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 @@ -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 @@ -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 @@ -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 -> { @@ -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, @@ -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) @@ -324,6 +337,14 @@ class MainActivity : ComponentActivity() { } ) } + composable { + val manager by connectionVM.bleManager.collectAsState() + TeslaEnrollFlow( + manager = manager, + statusFlow = teslaStatus, + onClose = { navController.popBackStack() } + ) + } composable { ThemePickerScreen( onBack = { navController.popBackStack(ThemePickerRoute, inclusive = true) } diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaClient.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaClient.kt new file mode 100644 index 00000000..9ecc721e --- /dev/null +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaClient.kt @@ -0,0 +1,96 @@ +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 + + /** Ask the firmware to begin enrollment for the staged car. */ + fun sendStart(manager: DashKitBleManager): Boolean = sendTesla(manager, CMD_START, 0) + + /** Factory-reset the Tesla key (erase -> the next sighting re-stages). */ + fun sendReset(manager: DashKitBleManager): Boolean = sendTesla(manager, CMD_RESET, 0) + + /** Cancel an open pairing window. */ + fun sendCancel(manager: DashKitBleManager): Boolean = sendTesla(manager, CMD_CANCEL, 0) + + /** + * 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, value: Int): Boolean { + val gatt = manager.gatt + if (gatt == null) { + Log.w(TAG, "No GATT connection; cannot send Tesla command 0x%02X".format(opcode)) + return false + } + val service = gatt.getService(SERVICE_UUID) + if (service == null) { + Log.w(TAG, "App-channel service not found; firmware may be older") + return false + } + val commandChar = service.getCharacteristic(COMMAND_CHAR_UUID) + if (commandChar == null) { + Log.w(TAG, "App-channel command characteristic not found") + return false + } + + val v = value and 0xFFFF + val payload = byteArrayOf( + (opcode and 0xFF).toByte(), + (v and 0xFF).toByte(), + ((v shr 8) and 0xFF).toByte() + ) + + Log.d(TAG, "Sending app-channel 0x%02X value=%d".format(opcode, v)) + 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) + } + } +} diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaStatus.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaStatus.kt new file mode 100644 index 00000000..4685db19 --- /dev/null +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaStatus.kt @@ -0,0 +1,80 @@ +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 + 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), + Link(0x04), + 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 } + 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)), + ) + } + } +} diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaStatusSource.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaStatusSource.kt new file mode 100644 index 00000000..b97cdd91 --- /dev/null +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaStatusSource.kt @@ -0,0 +1,129 @@ +package com.softwiredtech.dashpilot.ble + +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.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 = _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) + val descriptor = characteristic.getDescriptor(TeslaClient.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 Tesla status notifications") + } + + 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 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.readCharacteristic(c) + } else { + @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. + } +} diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/navigation/Routes.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/navigation/Routes.kt index 551e215d..81b0e1a7 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/navigation/Routes.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/navigation/Routes.kt @@ -11,6 +11,9 @@ object SetupRoute @Serializable object SettingsRoute +@Serializable +object TeslaEnrollRoute + @Serializable object ThemePickerRoute diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/HomeScreen.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/HomeScreen.kt index dbf788bd..1d317091 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/HomeScreen.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/HomeScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -42,6 +43,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.softwiredtech.dashpilot.ble.TeslaStatus import com.softwiredtech.dashpilot.datamodel.dash.CarState import com.softwiredtech.dashpilot.datamodel.dash.DashState import com.softwiredtech.dashpilot.datasource.ConnectionStatus @@ -49,9 +51,12 @@ import com.softwiredtech.dashpilot.datasource.DashKitBleManager import com.softwiredtech.dashpilot.datasource.DataSourceType import com.softwiredtech.dashpilot.ui.controls.ControlActionButton import com.softwiredtech.dashpilot.ui.controls.controlById +import com.softwiredtech.dashpilot.ui.tesla.TeslaTile import com.softwiredtech.dashpilot.ui.theme.AccentColor import com.softwiredtech.dashpilot.ui.theme.DarkColors import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOf import kotlin.math.roundToInt @@ -66,6 +71,8 @@ fun HomeScreen( bleManager: DashKitBleManager?, dashState: Flow?, pinnedControlId: String?, + teslaStatus: StateFlow?, + onEnrollTesla: () -> Unit, onConnect: (serverAddress: String, dataSourceType: String) -> Unit, onDisconnect: () -> Unit, onNext: () -> Unit, @@ -87,6 +94,8 @@ fun HomeScreen( bleManager = bleManager, connectionStatus = connectionStatus, pinnedControlId = pinnedControlId, + teslaStatus = teslaStatus, + onEnrollTesla = onEnrollTesla, onConnect = onConnect, onDisconnect = onDisconnect, onSelectDataSource = { selectedDataSource = it }, @@ -115,6 +124,8 @@ private fun ConnectedHomeContent( bleManager: DashKitBleManager?, connectionStatus: ConnectionStatus, pinnedControlId: String?, + teslaStatus: StateFlow?, + onEnrollTesla: () -> Unit, onConnect: (serverAddress: String, dataSourceType: String) -> Unit, onDisconnect: () -> Unit, onSelectDataSource: (String) -> Unit, @@ -127,6 +138,8 @@ private fun ConnectedHomeContent( val state by (dashState ?: flowOf(fallback)).collectAsState(initial = fallback) val car = state.carState val useImperial = state.displaySettings.useImperial + val idleTesla = remember { MutableStateFlow(TeslaStatus.Idle) } + val tesla by (teslaStatus ?: idleTesla).collectAsState() Column( modifier = Modifier @@ -199,6 +212,8 @@ private fun ConnectedHomeContent( Spacer(modifier = Modifier.height(32.dp)) + TeslaTile(status = tesla, onEnroll = onEnrollTesla) + controlById(pinnedControlId)?.let { action -> Text( text = "Pinned", diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/SettingsScreen.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/SettingsScreen.kt index 66ea5398..39e10a2b 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/SettingsScreen.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/SettingsScreen.kt @@ -66,7 +66,13 @@ import com.softwiredtech.dashpilot.datasource.ConnectionStatus import com.softwiredtech.dashpilot.datasource.DashKitBleManager import com.softwiredtech.dashpilot.ble.FirmwareUpdateManager import com.softwiredtech.dashpilot.ble.OtaState +import com.softwiredtech.dashpilot.ble.TeslaClient +import com.softwiredtech.dashpilot.ble.TeslaLinkState +import com.softwiredtech.dashpilot.ble.TeslaStatus import com.softwiredtech.dashpilot.ble.VehicleControl +import com.softwiredtech.dashpilot.ui.tesla.teslaTileSummary +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import com.softwiredtech.dashpilot.R @@ -119,6 +125,8 @@ fun SettingsScreen( onDisplaySettingsChanged: (DisplaySettings) -> Unit = {}, bleManager: DashKitBleManager? = null, dashkitUpdateManager: FirmwareUpdateManager? = null, + teslaStatus: StateFlow? = null, + onEnrollTesla: () -> Unit = {}, onReplayOnboarding: () -> Unit = {}, onThemeClick: () -> Unit = {} ) { @@ -454,7 +462,7 @@ fun SettingsScreen( if (selectedTab.intValue == 1) { if (bleManager != null && dashkitUpdateManager != null) { - DashKitSettingsContent(bleManager, dashkitUpdateManager) + DashKitSettingsContent(bleManager, dashkitUpdateManager, teslaStatus, onEnrollTesla) } else { Text( text = stringResource(R.string.settings_dashkit_not_connected), @@ -537,6 +545,13 @@ private fun PairNewDeviceSection(bleManager: DashKitBleManager) { Text(text = stringResource(R.string.settings_pair_new_device), fontSize = 16.sp) } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.settings_pair_caption), + color = DarkColors.TextMuted, + fontSize = 12.sp + ) + if (!connected) { Spacer(modifier = Modifier.height(8.dp)) Text( @@ -578,7 +593,9 @@ private fun PairNewDeviceSection(bleManager: DashKitBleManager) { @Composable private fun DashKitSettingsContent( bleManager: DashKitBleManager, - updateManager: FirmwareUpdateManager + updateManager: FirmwareUpdateManager, + teslaStatus: StateFlow?, + onEnrollTesla: () -> Unit, ) { val connectionState by bleManager.connectionState.collectAsState() val connected = connectionState == ConnectionStatus.Connected @@ -600,6 +617,9 @@ private fun DashKitSettingsContent( PairNewDeviceSection(bleManager) Spacer(modifier = Modifier.height(24.dp)) + TeslaKeySection(bleManager, teslaStatus, onEnrollTesla) + Spacer(modifier = Modifier.height(24.dp)) + DashKitMaintenanceSection(bleManager, connected) } @@ -685,6 +705,92 @@ private fun DashKitMaintenanceSection(bleManager: DashKitBleManager, connected: } } +@Composable +private fun TeslaKeySection( + bleManager: DashKitBleManager, + teslaStatus: StateFlow?, + onEnrollTesla: () -> Unit, +) { + val context = LocalContext.current + val connectionState by bleManager.connectionState.collectAsState() + val connected = connectionState == ConnectionStatus.Connected + val idleTesla = remember { MutableStateFlow(TeslaStatus.Idle) } + val status by (teslaStatus ?: idleTesla).collectAsState() + val showResetDialog = remember { mutableStateOf(false) } + + SectionHeader(stringResource(R.string.settings_section_tesla_key)) + Spacer(modifier = Modifier.height(12.dp)) + InfoRow(label = stringResource(R.string.settings_tesla_key_status), value = teslaStatusText(status)) + Spacer(modifier = Modifier.height(12.dp)) + + Button( + onClick = onEnrollTesla, + enabled = connected, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = AccentColor, + contentColor = Color.White, + disabledContainerColor = DarkColors.Border, + disabledContentColor = DarkColors.TextMuted + ) + ) { + Text(text = stringResource(R.string.settings_tesla_enroll), fontSize = 16.sp) + } + Spacer(modifier = Modifier.height(12.dp)) + Button( + onClick = { showResetDialog.value = true }, + enabled = connected, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = DarkColors.SurfaceSelected, + contentColor = Color.White, + disabledContainerColor = DarkColors.Border, + disabledContentColor = DarkColors.TextMuted + ) + ) { + Text(text = stringResource(R.string.settings_tesla_reset), fontSize = 16.sp) + } + + if (showResetDialog.value) { + AlertDialog( + onDismissRequest = { showResetDialog.value = false }, + title = { Text(stringResource(R.string.settings_tesla_reset_dialog_title)) }, + text = { Text(stringResource(R.string.settings_tesla_reset_dialog_body)) }, + confirmButton = { + TextButton(onClick = { + showResetDialog.value = false + val ok = TeslaClient.sendReset(bleManager) + val msg = if (ok) { + context.getString(R.string.settings_tesla_reset_sent) + } else { + context.getString(R.string.settings_tesla_reset_failed) + } + android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_LONG).show() + }) { + Text(stringResource(R.string.settings_tesla_reset_dialog_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { showResetDialog.value = false }) { + Text(stringResource(R.string.settings_tesla_reset_dialog_cancel)) + } + } + ) + } +} + +@Composable +private fun teslaStatusText(status: TeslaStatus): String = when (status.linkState) { + TeslaLinkState.NeverEnrolled -> stringResource(R.string.tesla_tile_key_not_set_up) + TeslaLinkState.Staged -> stringResource(R.string.tesla_tile_staged) + TeslaLinkState.EnrolledNotConnected -> stringResource(R.string.tesla_tile_not_connected) + TeslaLinkState.EnrolledConnected -> teslaTileSummary(status) + .ifBlank { stringResource(R.string.tesla_tile_not_connected) } + TeslaLinkState.PairingWindow -> stringResource(R.string.tesla_enroll_tap_title) + TeslaLinkState.EnrollmentFault -> stringResource(R.string.tesla_enroll_error_title) + TeslaLinkState.Unknown -> "—" +} + @Composable private fun FirmwareUpdateSection(updateManager: FirmwareUpdateManager) { val scope = rememberCoroutineScope() diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/onboarding/DevicePuck.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/onboarding/DevicePuck.kt index e36f27f1..1626417f 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/onboarding/DevicePuck.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/onboarding/DevicePuck.kt @@ -39,7 +39,8 @@ enum class PairingState { Idle, Searching, Paired } @Composable fun DevicePuck( state: PairingState, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + accent: Color = OnboardingColors.Accent ) { val infinite = rememberInfiniteTransition(label = "puck") @@ -94,7 +95,7 @@ fun DevicePuck( if (state == PairingState.Searching) { ringAnims.forEach { (scaleAnim, alphaAnim) -> drawCircle( - color = OnboardingColors.Accent.copy(alpha = alphaAnim.value), + color = accent.copy(alpha = alphaAnim.value), radius = puckRadius * scaleAnim.value, center = center, style = Stroke(width = 1.dp.toPx()) diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaEnrollFlow.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaEnrollFlow.kt new file mode 100644 index 00000000..c371084a --- /dev/null +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaEnrollFlow.kt @@ -0,0 +1,234 @@ +package com.softwiredtech.dashpilot.ui.tesla + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import com.softwiredtech.dashpilot.R +import com.softwiredtech.dashpilot.ble.TeslaClient +import com.softwiredtech.dashpilot.ble.TeslaFaultDetail +import com.softwiredtech.dashpilot.ble.TeslaLinkState +import com.softwiredtech.dashpilot.ble.TeslaStatus +import com.softwiredtech.dashpilot.datasource.DashKitBleManager +import com.softwiredtech.dashpilot.ui.onboarding.DevicePuck +import com.softwiredtech.dashpilot.ui.onboarding.OnboardingPageScaffold +import com.softwiredtech.dashpilot.ui.onboarding.PairingState +import com.softwiredtech.dashpilot.ui.onboarding.PrimaryCta +import com.softwiredtech.dashpilot.ui.theme.OnboardingColors +import com.softwiredtech.dashpilot.ui.theme.TeslaCyan +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +private const val TAP_WINDOW_S = 60 + +/** + * Standalone Phase 4 enroll flow. App-triggered only: the firmware never starts + * pairing by itself (the DashKit's LEDs are not user-visible). The app watches + * [statusFlow] and: starts enrollment (Start -> send 0x01), shows a live + * tap-window countdown with a Cancel (0x03), renders success when a key is + * enrolled, or a fault (with its detail) when enrollment fails. + */ +@Composable +fun TeslaEnrollFlow( + manager: DashKitBleManager?, + statusFlow: StateFlow?, + onClose: () -> Unit, +) { + val idleFlow = remember { MutableStateFlow(TeslaStatus.Idle) } + val status by (statusFlow ?: idleFlow).collectAsState() + BackHandler(onBack = onClose) + + Box(modifier = Modifier.fillMaxSize().background(OnboardingColors.BgBase).systemBarsPadding()) { + when (status.linkState) { + TeslaLinkState.NeverEnrolled, TeslaLinkState.Unknown -> LookingStep() + TeslaLinkState.Staged -> StartStep(onStart = { manager?.let { TeslaClient.sendStart(it) } }) + TeslaLinkState.PairingWindow -> TapCardStep(onCancel = { manager?.let { TeslaClient.sendCancel(it) } }) + TeslaLinkState.EnrollmentFault -> ErrorStep( + fault = status.faultDetail, + onRetry = { manager?.let { TeslaClient.sendStart(it) } }, + onCancel = onClose, + ) + TeslaLinkState.EnrolledNotConnected, TeslaLinkState.EnrolledConnected -> SuccessStep(onDone = onClose) + } + } +} + +@Composable +private fun LookingStep() { + InlineScaffold( + title = stringResource(R.string.tesla_enroll_title), + subtitle = stringResource(R.string.tesla_enroll_looking), + hero = { DevicePuck(state = PairingState.Idle, accent = TeslaCyan) }, + cta = { Spacer(Modifier.height(8.dp)) }, + ) +} + +@Composable +private fun StartStep(onStart: () -> Unit) { + InlineScaffold( + title = stringResource(R.string.tesla_enroll_title), + subtitle = stringResource(R.string.tesla_enroll_explain_body), + hero = { DevicePuck(state = PairingState.Searching, accent = TeslaCyan) }, + extra = { + RoleChip(stringResource(R.string.tesla_enroll_role_chip)) + }, + cta = { + Column(Modifier.fillMaxWidth()) { + PrimaryCta(label = stringResource(R.string.tesla_enroll_start), onClick = onStart) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.tesla_enroll_need_card), + color = OnboardingColors.TextMuted, + fontSize = 13.sp, + ) + } + }, + ) +} + +@Composable +private fun TapCardStep(onCancel: () -> Unit) { + var remaining by remember { mutableIntStateOf(TAP_WINDOW_S) } + LaunchedEffect(Unit) { + while (remaining > 0) { + delay(1000) + remaining-- + } + } + InlineScaffold( + title = stringResource(R.string.tesla_enroll_tap_title), + subtitle = stringResource(R.string.tesla_enroll_tap_body), + hero = { DevicePuck(state = PairingState.Searching, accent = TeslaCyan) }, + extra = { + Text( + text = stringResource(R.string.tesla_enroll_tap_hint, remaining), + color = TeslaCyan, + fontSize = 13.sp, + ) + }, + cta = { + TextButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.tesla_enroll_cancel), color = OnboardingColors.TextSecondary) + } + }, + ) +} + +@Composable +private fun SuccessStep(onDone: () -> Unit) { + InlineScaffold( + title = stringResource(R.string.tesla_enroll_success_title), + subtitle = stringResource(R.string.tesla_enroll_success_body), + hero = { DevicePuck(state = PairingState.Paired) }, + extra = { RoleChip(stringResource(R.string.tesla_enroll_success_role)) }, + cta = { PrimaryCta(label = stringResource(R.string.tesla_enroll_done), onClick = onDone) }, + ) +} + +@Composable +private fun ErrorStep(fault: TeslaFaultDetail, onRetry: () -> Unit, onCancel: () -> Unit) { + val message = when (fault) { + TeslaFaultDetail.TapTimeout -> stringResource(R.string.tesla_fault_tap_timeout) + TeslaFaultDetail.Rejected -> stringResource(R.string.tesla_fault_rejected) + TeslaFaultDetail.Protocol -> stringResource(R.string.tesla_fault_protocol) + TeslaFaultDetail.Persist -> stringResource(R.string.tesla_fault_persist) + TeslaFaultDetail.Link -> stringResource(R.string.tesla_fault_link) + TeslaFaultDetail.None -> stringResource(R.string.tesla_fault_generic) + } + InlineScaffold( + title = stringResource(R.string.tesla_enroll_error_title), + subtitle = message, + hero = { DevicePuck(state = PairingState.Idle, accent = OnboardingColors.LedDim) }, + cta = { + Column(Modifier.fillMaxWidth()) { + PrimaryCta(label = stringResource(R.string.tesla_enroll_retry), onClick = onRetry) + TextButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.tesla_enroll_cancel), color = OnboardingColors.TextSecondary) + } + } + }, + ) +} + +/** Thin wrapper over the onboarding scaffold with consistent hero sizing + a spinner-capable CTA. */ +@Composable +private fun InlineScaffold( + title: String, + subtitle: String, + cta: @Composable () -> Unit, + hero: @Composable () -> Unit = {}, + extra: @Composable () -> Unit = {}, +) { + OnboardingPageScaffold( + title = title, + subtitle = subtitle, + cta = cta, + hero = { + Box(contentAlignment = Alignment.Center) { + hero() + } + }, + extra = extra, + ) +} + +@Composable +private fun RoleChip(text: String) { + Box( + modifier = Modifier + .background(OnboardingColors.Surface, MaterialTheme.shapes.small) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Text( + text = text, + color = OnboardingColors.TextSecondary, + fontSize = 13.sp, + ) + } +} + +/** Builds the one-line Home-tile summary, e.g. "Present · Locked · Asleep". */ +@Composable +fun teslaTileSummary(status: TeslaStatus): String { + if (status.linkState != TeslaLinkState.EnrolledConnected && + status.linkState != TeslaLinkState.EnrolledNotConnected + ) { + return "" + } + val parts = buildList { + if (status.presence == 1) add(stringResource(R.string.tesla_status_present)) + if (status.lock == 1) add(stringResource(R.string.tesla_status_locked)) + if (status.sleep == 1) add(stringResource(R.string.tesla_status_asleep)) + } + return parts.joinToString(" · ") +} diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaTile.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaTile.kt new file mode 100644 index 00000000..dfa72c25 --- /dev/null +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaTile.kt @@ -0,0 +1,97 @@ +package com.softwiredtech.dashpilot.ui.tesla + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.softwiredtech.dashpilot.R +import com.softwiredtech.dashpilot.ble.TeslaLinkState +import com.softwiredtech.dashpilot.ble.TeslaStatus +import com.softwiredtech.dashpilot.ui.theme.DarkColors +import com.softwiredtech.dashpilot.ui.theme.OnboardingColors +import com.softwiredtech.dashpilot.ui.theme.TeslaCyan + +/** + * One-line Tesla tile on Home. App-triggered only: "Key not set up" (0x00) and + * "Car found — Start enrollment?" (0x05 / staged) are tappable and open the + * enroll flow; connected (0x02) and not-connected (0x01) are informational. + * The LED dot is an in-app motif — the physical DashKit LED is buried in the + * car trim and not user-visible. + */ +@Composable +fun TeslaTile(status: TeslaStatus, onEnroll: () -> Unit) { + val tapEnabled = status.linkState == TeslaLinkState.Staged || + status.linkState == TeslaLinkState.NeverEnrolled + + val text = when (status.linkState) { + TeslaLinkState.NeverEnrolled -> stringResource(R.string.tesla_tile_key_not_set_up) + TeslaLinkState.Staged -> stringResource(R.string.tesla_tile_staged) + TeslaLinkState.EnrolledNotConnected -> stringResource(R.string.tesla_tile_not_connected) + TeslaLinkState.EnrolledConnected -> teslaTileSummary(status) + .ifBlank { stringResource(R.string.tesla_tile_not_connected) } + TeslaLinkState.PairingWindow -> stringResource(R.string.tesla_enroll_tap_title) + TeslaLinkState.EnrollmentFault -> stringResource(R.string.tesla_enroll_error_title) + else -> stringResource(R.string.tesla_tile_not_connected) + } + + val ledColor: Color = when { + status.linkState == TeslaLinkState.EnrolledConnected -> OnboardingColors.Accent + status.linkState == TeslaLinkState.Staged -> TeslaCyan + status.linkState == TeslaLinkState.PairingWindow -> TeslaCyan + else -> DarkColors.Disabled + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(DarkColors.Surface) + .then(if (tapEnabled) Modifier.clickable(onClick = onEnroll) else Modifier) + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(ledColor), + ) + Spacer(Modifier.width(12.dp)) + Text( + text = stringResource(R.string.tesla_label), + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.weight(1f)) + Text( + text = text, + color = DarkColors.TextMuted, + fontSize = 14.sp, + modifier = Modifier.weight(2f), + ) + if (tapEnabled) { + Spacer(Modifier.width(8.dp)) + Text(text = "›", color = DarkColors.TextMuted, fontSize = 20.sp) + } + } + Spacer(Modifier.height(12.dp)) +} diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/theme/Color.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/theme/Color.kt index 875964e8..2d3948aa 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/theme/Color.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/theme/Color.kt @@ -12,6 +12,10 @@ val Pink40 = Color(0xFF7D5260) val AccentColor = Color(0xFF5CBD68) +// Phase 4 Tesla pair/status cyan (pairing window + staged tile). The in-app motif +// for a state the physical DashKit LED (buried in the car trim) can't show. +val TeslaCyan = Color(0xFF4FD1FF) + object DarkColors { val Background = Color(0xFF0D0D0D) val Surface = Color(0xFF1A1A1A) diff --git a/dashpilot-android/app/src/main/res/values/strings.xml b/dashpilot-android/app/src/main/res/values/strings.xml index 71fd1f50..70cb3597 100644 --- a/dashpilot-android/app/src/main/res/values/strings.xml +++ b/dashpilot-android/app/src/main/res/values/strings.xml @@ -112,4 +112,51 @@ Check for firmware updates Replay onboarding + + + Tesla + + Key not set up + Car found — Start enrollment? + Not connected + Present + Locked + Asleep + + + Enroll Tesla key + DashKit adds itself to your Tesla as a Charging Manager — it can read status and control charging, but can\'t unlock or drive the car. + Charging Manager · Read + charge + Start enrollment + You\'ll need your Tesla key card. + Looking for DashKit / car… + Tap your key card + Place your key card on the center console. + Tap window: %1$d s · The approval prompt appears only after you tap. + Enrolled + DashKit is now a Charging Manager on your Tesla. + Read + charge enabled + Done + Enrollment didn\'t finish + Try again + Cancel + The tap window expired. Try again. + The car rejected the enrollment. Make sure it\'s the right car. + A protocol error occurred. Try again. + DashKit couldn\'t save the key. Try again. + The link to DashKit was lost. Reconnect and try again. + Enrollment didn\'t finish. Try again. + + + Tesla key + Status + Enroll Tesla key + Reset Tesla key + Reset Tesla key? + Erase DashKit\'s Tesla key. You\'ll need your key card to re-enroll. + Reset + Cancel + DashKit\'s Tesla key was erased. It will re-enroll when you start from the app. + Could not send reset command. + Pairs another phone to DashKit — unrelated to your Tesla key. diff --git a/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/TeslaStatusTest.kt b/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/TeslaStatusTest.kt new file mode 100644 index 00000000..5c8be8a0 --- /dev/null +++ b/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/TeslaStatusTest.kt @@ -0,0 +1,70 @@ +package com.softwiredtech.dashpilot + +import com.softwiredtech.dashpilot.ble.TeslaFaultDetail +import com.softwiredtech.dashpilot.ble.TeslaLinkState +import com.softwiredtech.dashpilot.ble.TeslaStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TeslaStatusTest { + + @Test + fun `parse full connected frame`() { + // [version=1][link=0x02 connected][presence=1][lock=1][sleep=1][flags=0][fault=0xFF] + val frame = byteArrayOf(0x01, 0x02, 0x01, 0x01, 0x01, 0x00, 0xFF.toByte()) + val st = TeslaStatus.parse(frame)!! + assertEquals(TeslaLinkState.EnrolledConnected, st.linkState) + assertTrue(st.linkState.hasKey) + assertTrue(st.linkState.connected) + assertEquals(1, st.presence) + assertEquals(1, st.lock) + assertEquals(1, st.sleep) + assertEquals(0, st.flags) + assertEquals(TeslaFaultDetail.None, st.faultDetail) + } + + @Test + fun `parse staged frame maps to staged`() { + val frame = byteArrayOf(0x01, 0x05, 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0x00, 0xFF.toByte()) + val st = TeslaStatus.parse(frame)!! + assertEquals(TeslaLinkState.Staged, st.linkState) + assertFalse(st.linkState.hasKey) + assertFalse(st.linkState.connected) + } + + @Test + fun `parse fault frame surfaces fault detail`() { + // link_state 0x04 (fault), fault_detail 0x00 (tap window expired) + val frame = byteArrayOf(0x01, 0x04, 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0x00, 0x00) + val st = TeslaStatus.parse(frame)!! + assertEquals(TeslaLinkState.EnrollmentFault, st.linkState) + assertEquals(TeslaFaultDetail.TapTimeout, st.faultDetail) + } + + @Test + fun `parse rejects short frames`() { + assertNull(TeslaStatus.parse(byteArrayOf(0x01, 0x02, 0x01))) + assertNull(TeslaStatus.parse(byteArrayOf())) + } + + @Test + fun `link state unknown for out of range byte`() { + val frame = byteArrayOf(0x01, 0x7F, 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0x00, 0xFF.toByte()) + val st = TeslaStatus.parse(frame)!! + assertEquals(TeslaLinkState.Unknown, st.linkState) + assertFalse(st.linkState.hasKey) + assertFalse(st.linkState.connected) + } + + @Test + fun `enrolled not connected has key but not connected`() { + val frame = byteArrayOf(0x01, 0x01, 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0x00, 0xFF.toByte()) + val st = TeslaStatus.parse(frame)!! + assertEquals(TeslaLinkState.EnrolledNotConnected, st.linkState) + assertTrue(st.linkState.hasKey) + assertFalse(st.linkState.connected) + } +} From b06792dfe2382e9334b0620c6c48af91a6154315 Mon Sep 17 00:00:00 2001 From: Dave Kneeland Date: Thu, 20 Aug 2026 19:18:12 -0700 Subject: [PATCH 2/4] fix(android): retry GATT subscribes dropped by the GATT-busy race Android's GATT client allows only one operation in flight, and the app subscribes to two characteristics back-to-back when services become ready (CAN + Tesla status). The second CCCD write could be refused while the first was pending, and writeDescriptor()'s return value was ignored, so the dropped subscribe looked like success: no CAN frames arrived (Connected is gated on the first CAN frame, so onboarding stalled at Pairing), or the status tile went stale with no error. Check writeDescriptor()'s return value and retry (3 attempts, 250 ms apart) via a shared DashKitBleManager.subscribeWithRetry(), now used by both the CAN datasource and the Tesla status source. Supersedes #200. --- .../dashpilot/ble/TeslaStatusSource.kt | 22 ++-------- .../dashpilot/datasource/DashKitBleManager.kt | 41 +++++++++++++++++++ .../dashpilot/datasource/DashKitDataSource.kt | 16 +------- 3 files changed, 45 insertions(+), 34 deletions(-) diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaStatusSource.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaStatusSource.kt index b97cdd91..c362b1bf 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaStatusSource.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaStatusSource.kt @@ -4,7 +4,6 @@ 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.datasource.DashKitBleManager import com.softwiredtech.dashpilot.datasource.GattListener @@ -58,18 +57,7 @@ class TeslaStatusSource(private val manager: DashKitBleManager) : GattListener { } statusChar = characteristic gatt.setCharacteristicNotification(characteristic, true) - val descriptor = characteristic.getDescriptor(TeslaClient.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 Tesla status notifications") + manager.subscribeWithRetry(gatt, characteristic, TAG, "Status") } override fun onDescriptorWrite( @@ -83,12 +71,8 @@ class TeslaStatusSource(private val manager: DashKitBleManager) : GattListener { status == BluetoothGatt.GATT_SUCCESS ) { val c = statusChar ?: return - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - gatt.readCharacteristic(c) - } else { - @Suppress("DEPRECATION") - gatt.readCharacteristic(c) - } + @Suppress("DEPRECATION") + gatt.readCharacteristic(c) } } 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 0793a0513b5450481bc3ff798f3a5dae445df267 Mon Sep 17 00:00:00 2001 From: Dave Kneeland Date: Thu, 20 Aug 2026 18:40:29 -0700 Subject: [PATCH 3/4] feat(android): Phase 4 Tesla Connection UX rebrand + tile alignment Rebrand on-screen Tesla copy from key/enroll/set up key to Connection/connect/remove: Settings section 'Tesla key' -> 'Tesla Connection' with caption; actions 'Connect Tesla' / 'Remove Tesla connection' with remove/reconnect dialog and toasts; enroll flow 'Connect to your Tesla' / 'Connect' / 'Connected' / 'Connection failed'; pair caption reflects the separate connection. Tile: right-justify status text (TextAlign.End), single-line ellipsis, 16 dp spacer before the chevron; 'Tesla' label stays left. Show negative statuses (not present/unlocked/awake) and a 'Connection failed' fault state on the tile. Internal identifiers (tesla_enroll_*, TeslaKeySection, NeverEnrolled) and 'key card' wording left as-is; user-facing copy only. --- .../dashpilot/ui/SettingsScreen.kt | 35 +++++------ .../dashpilot/ui/tesla/TeslaEnrollFlow.kt | 49 ++++++++++++--- .../dashpilot/ui/tesla/TeslaTile.kt | 20 ++++-- .../app/src/main/res/values/strings.xml | 63 +++++++++++-------- 4 files changed, 106 insertions(+), 61 deletions(-) diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/SettingsScreen.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/SettingsScreen.kt index 39e10a2b..23a600b3 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/SettingsScreen.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/SettingsScreen.kt @@ -719,36 +719,33 @@ private fun TeslaKeySection( val showResetDialog = remember { mutableStateOf(false) } SectionHeader(stringResource(R.string.settings_section_tesla_key)) + Text( + text = stringResource(R.string.settings_tesla_key_caption), + color = DarkColors.TextMuted, + fontSize = 12.sp, + ) Spacer(modifier = Modifier.height(12.dp)) InfoRow(label = stringResource(R.string.settings_tesla_key_status), value = teslaStatusText(status)) Spacer(modifier = Modifier.height(12.dp)) + // Contextual action: show "Connect" when no connection exists, "Remove" only + // once connected (removal erases the link, so it must not persist after). + val hasKey = status.linkState.hasKey Button( - onClick = onEnrollTesla, + onClick = if (hasKey) ({ showResetDialog.value = true }) else onEnrollTesla, enabled = connected, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors( - containerColor = AccentColor, + containerColor = if (hasKey) DarkColors.SurfaceSelected else AccentColor, contentColor = Color.White, disabledContainerColor = DarkColors.Border, disabledContentColor = DarkColors.TextMuted ) ) { - Text(text = stringResource(R.string.settings_tesla_enroll), fontSize = 16.sp) - } - Spacer(modifier = Modifier.height(12.dp)) - Button( - onClick = { showResetDialog.value = true }, - enabled = connected, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( - containerColor = DarkColors.SurfaceSelected, - contentColor = Color.White, - disabledContainerColor = DarkColors.Border, - disabledContentColor = DarkColors.TextMuted + Text( + text = stringResource(if (hasKey) R.string.settings_tesla_reset else R.string.settings_tesla_enroll), + fontSize = 16.sp ) - ) { - Text(text = stringResource(R.string.settings_tesla_reset), fontSize = 16.sp) } if (showResetDialog.value) { @@ -785,9 +782,9 @@ private fun teslaStatusText(status: TeslaStatus): String = when (status.linkStat TeslaLinkState.Staged -> stringResource(R.string.tesla_tile_staged) TeslaLinkState.EnrolledNotConnected -> stringResource(R.string.tesla_tile_not_connected) TeslaLinkState.EnrolledConnected -> teslaTileSummary(status) - .ifBlank { stringResource(R.string.tesla_tile_not_connected) } - TeslaLinkState.PairingWindow -> stringResource(R.string.tesla_enroll_tap_title) - TeslaLinkState.EnrollmentFault -> stringResource(R.string.tesla_enroll_error_title) + .ifBlank { stringResource(R.string.tesla_tile_connected) } + TeslaLinkState.PairingWindow -> stringResource(R.string.tesla_status_pairing) + TeslaLinkState.EnrollmentFault -> stringResource(R.string.tesla_status_fault) TeslaLinkState.Unknown -> "—" } diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaEnrollFlow.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaEnrollFlow.kt index c371084a..c7458c20 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaEnrollFlow.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaEnrollFlow.kt @@ -70,7 +70,7 @@ fun TeslaEnrollFlow( Box(modifier = Modifier.fillMaxSize().background(OnboardingColors.BgBase).systemBarsPadding()) { when (status.linkState) { - TeslaLinkState.NeverEnrolled, TeslaLinkState.Unknown -> LookingStep() + TeslaLinkState.NeverEnrolled, TeslaLinkState.Unknown -> LookingStep(onClose = onClose) TeslaLinkState.Staged -> StartStep(onStart = { manager?.let { TeslaClient.sendStart(it) } }) TeslaLinkState.PairingWindow -> TapCardStep(onCancel = { manager?.let { TeslaClient.sendCancel(it) } }) TeslaLinkState.EnrollmentFault -> ErrorStep( @@ -84,12 +84,23 @@ fun TeslaEnrollFlow( } @Composable -private fun LookingStep() { +private fun LookingStep(onClose: () -> Unit) { InlineScaffold( title = stringResource(R.string.tesla_enroll_title), subtitle = stringResource(R.string.tesla_enroll_looking), - hero = { DevicePuck(state = PairingState.Idle, accent = TeslaCyan) }, - cta = { Spacer(Modifier.height(8.dp)) }, + hero = { DevicePuck(state = PairingState.Searching, accent = TeslaCyan) }, + extra = { + Text( + text = stringResource(R.string.tesla_enroll_looking_hint), + color = OnboardingColors.TextMuted, + fontSize = 13.sp, + ) + }, + cta = { + TextButton(onClick = onClose, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.tesla_enroll_cancel), color = OnboardingColors.TextSecondary) + } + }, ) } @@ -217,7 +228,9 @@ private fun RoleChip(text: String) { } } -/** Builds the one-line Home-tile summary, e.g. "Present · Locked · Asleep". */ +/** Builds the one-line Home-tile summary, e.g. "Present · Unlocked · Awake". Always + * shows the presence/lock/sleep state so the driver can see live values — including + * the "negative" states (not present / unlocked / awake); unknown (0xFF) is omitted. */ @Composable fun teslaTileSummary(status: TeslaStatus): String { if (status.linkState != TeslaLinkState.EnrolledConnected && @@ -226,9 +239,27 @@ fun teslaTileSummary(status: TeslaStatus): String { return "" } val parts = buildList { - if (status.presence == 1) add(stringResource(R.string.tesla_status_present)) - if (status.lock == 1) add(stringResource(R.string.tesla_status_locked)) - if (status.sleep == 1) add(stringResource(R.string.tesla_status_asleep)) - } + add( + when (status.presence) { + 1 -> stringResource(R.string.tesla_status_present) + 0 -> stringResource(R.string.tesla_status_not_present) + else -> null + } + ) + add( + when (status.lock) { + 1 -> stringResource(R.string.tesla_status_locked) + 0 -> stringResource(R.string.tesla_status_unlocked) + else -> null + } + ) + add( + when (status.sleep) { + 1 -> stringResource(R.string.tesla_status_asleep) + 0 -> stringResource(R.string.tesla_status_awake) + else -> null + } + ) + }.filterNotNull() return parts.joinToString(" · ") } diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaTile.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaTile.kt index dfa72c25..8742269f 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaTile.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaTile.kt @@ -20,6 +20,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.softwiredtech.dashpilot.R @@ -39,16 +41,17 @@ import com.softwiredtech.dashpilot.ui.theme.TeslaCyan @Composable fun TeslaTile(status: TeslaStatus, onEnroll: () -> Unit) { val tapEnabled = status.linkState == TeslaLinkState.Staged || - status.linkState == TeslaLinkState.NeverEnrolled + status.linkState == TeslaLinkState.NeverEnrolled || + status.linkState == TeslaLinkState.EnrollmentFault val text = when (status.linkState) { TeslaLinkState.NeverEnrolled -> stringResource(R.string.tesla_tile_key_not_set_up) TeslaLinkState.Staged -> stringResource(R.string.tesla_tile_staged) TeslaLinkState.EnrolledNotConnected -> stringResource(R.string.tesla_tile_not_connected) TeslaLinkState.EnrolledConnected -> teslaTileSummary(status) - .ifBlank { stringResource(R.string.tesla_tile_not_connected) } + .ifBlank { stringResource(R.string.tesla_tile_connected) } TeslaLinkState.PairingWindow -> stringResource(R.string.tesla_enroll_tap_title) - TeslaLinkState.EnrollmentFault -> stringResource(R.string.tesla_enroll_error_title) + TeslaLinkState.EnrollmentFault -> stringResource(R.string.tesla_tile_fault) else -> stringResource(R.string.tesla_tile_not_connected) } @@ -81,15 +84,20 @@ fun TeslaTile(status: TeslaStatus, onEnroll: () -> Unit) { fontSize = 16.sp, fontWeight = FontWeight.SemiBold, ) - Spacer(Modifier.weight(1f)) + Spacer(Modifier.width(12.dp)) + // Status is right-justified toward the chevron (with breathing room) and + // single-line so the three statuses never word-wrap; ellipsizes if long. Text( text = text, color = DarkColors.TextMuted, fontSize = 14.sp, - modifier = Modifier.weight(2f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.End, + modifier = Modifier.weight(1f), ) if (tapEnabled) { - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.width(16.dp)) Text(text = "›", color = DarkColors.TextMuted, fontSize = 20.sp) } } diff --git a/dashpilot-android/app/src/main/res/values/strings.xml b/dashpilot-android/app/src/main/res/values/strings.xml index 70cb3597..e78b9ec7 100644 --- a/dashpilot-android/app/src/main/res/values/strings.xml +++ b/dashpilot-android/app/src/main/res/values/strings.xml @@ -113,50 +113,59 @@ Replay onboarding - + Tesla - Key not set up - Car found — Start enrollment? - Not connected + Not connected + Car found — Connect + Car not connected + Connection failed — tap to retry + Connected Present + Not present Locked + Unlocked Asleep + Awake + Ready to tap card + Connection failed - Enroll Tesla key + Connect to your Tesla DashKit adds itself to your Tesla as a Charging Manager — it can read status and control charging, but can\'t unlock or drive the car. Charging Manager · Read + charge - Start enrollment - You\'ll need your Tesla key card. - Looking for DashKit / car… + Connect + Have your key card ready. + Looking for your Tesla… + Keep your phone near the car. Tap your key card - Place your key card on the center console. - Tap window: %1$d s · The approval prompt appears only after you tap. - Enrolled + Place your key card on the center console, then approve on the car\'s touchscreen. + %1$d s left + Connected DashKit is now a Charging Manager on your Tesla. Read + charge enabled Done - Enrollment didn\'t finish + Connection failed Try again Cancel - The tap window expired. Try again. - The car rejected the enrollment. Make sure it\'s the right car. - A protocol error occurred. Try again. - DashKit couldn\'t save the key. Try again. + You didn\'t tap the card in time. Try again. + The car rejected the connection. Check you\'re near the right car and using its key card. + DashKit couldn\'t talk to the car. Try again. + DashKit couldn\'t save the connection. Try again. The link to DashKit was lost. Reconnect and try again. - Enrollment didn\'t finish. Try again. + Something went wrong. Try again. - - Tesla key + + Tesla Connection + Read status and control charging via BLE. Status - Enroll Tesla key - Reset Tesla key - Reset Tesla key? - Erase DashKit\'s Tesla key. You\'ll need your key card to re-enroll. - Reset + Connect Tesla + Remove Tesla connection + Remove Tesla connection? + Remove DashKit from your car\'s Locks screen, then remove the connection here. You\'ll need your key card to reconnect. + Remove Cancel - DashKit\'s Tesla key was erased. It will re-enroll when you start from the app. - Could not send reset command. - Pairs another phone to DashKit — unrelated to your Tesla key. + Tesla connection removed. Reconnect from this screen when ready. + Couldn\'t remove the connection. Try again. + Pairs another phone — separate from your Tesla connection. From f3a5ca467371ddfe940499b448ad6f6ce3f57840 Mon Sep 17 00:00:00 2001 From: Dave Kneeland Date: Sun, 23 Aug 2026 15:56:22 -0700 Subject: [PATCH 4/4] feat(android): VIN discovery, provisioning confirm beat, audit fixes App-driven onboarding for the Tesla flow: - live scan of nearby Tesla adverts, deduped and expired after 10 s without an advert; both the rendered list and the auto-match derive from that live window - VIN entry validated by charset/WMI plus the ISO 3779 check digit; a failing digit requires a per-VIN "Stage this car anyway" override - explicit staging beat: decoded identity card (model/year/plant/drive) with a "Stage this car" CTA; TESLA_CMD_PROVISION fires only on tap - confirmed car identity carried through start/connecting/tap-window/ success/fault screens as a muted one-liner - Home/Settings tile reworded to recent-past ("Car seen nearby") to match its latch-on-first-sighting design - TeslaStatusTest covers frame-version rejection - adds ble/TeslaVinDecoder.kt + its unit tests (previously untracked; clean checkouts could not build without them) --- .../dashpilot/ble/TeslaClient.kt | 73 ++- .../dashpilot/ble/TeslaStatus.kt | 3 +- .../dashpilot/ble/TeslaVehicleScanner.kt | 192 +++++++ .../dashpilot/ble/TeslaVinDecoder.kt | 193 +++++++ .../softwiredtech/dashpilot/ui/HomeScreen.kt | 4 +- .../dashpilot/ui/SettingsScreen.kt | 10 +- .../dashpilot/ui/tesla/TeslaEnrollFlow.kt | 520 ++++++++++++++++-- .../dashpilot/ui/tesla/TeslaTile.kt | 89 ++- .../app/src/main/res/values/strings.xml | 25 +- .../dashpilot/TeslaStatusTest.kt | 5 + .../dashpilot/TeslaVinDecoderTest.kt | 127 +++++ 11 files changed, 1174 insertions(+), 67 deletions(-) create mode 100644 dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaVehicleScanner.kt create mode 100644 dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaVinDecoder.kt create mode 100644 dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/TeslaVinDecoderTest.kt diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaClient.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaClient.kt index 9ecc721e..f5c1ddc0 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaClient.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaClient.kt @@ -38,46 +38,77 @@ object TeslaClient { 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, 0) + 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, 0) + fun sendReset(manager: DashKitBleManager): Boolean = sendTesla(manager, CMD_RESET) /** Cancel an open pairing window. */ - fun sendCancel(manager: DashKitBleManager): Boolean = sendTesla(manager, CMD_CANCEL, 0) + fun sendCancel(manager: DashKitBleManager): Boolean = sendTesla(manager, CMD_CANCEL) /** - * Write an app-channel command to the DashKit (same [opcode][value_lo][value_hi] + * 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, value: Int): Boolean { - val gatt = manager.gatt - if (gatt == null) { - Log.w(TAG, "No GATT connection; cannot send Tesla command 0x%02X".format(opcode)) + 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) - if (service == null) { + 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) - if (commandChar == null) { + val commandChar = service.getCharacteristic(COMMAND_CHAR_UUID) ?: run { Log.w(TAG, "App-channel command characteristic not found") return false } - - val v = value and 0xFFFF - val payload = byteArrayOf( - (opcode and 0xFF).toByte(), - (v and 0xFF).toByte(), - ((v shr 8) and 0xFF).toByte() - ) - - Log.d(TAG, "Sending app-channel 0x%02X value=%d".format(opcode, v)) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { gatt.writeCharacteristic( commandChar, diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaStatus.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaStatus.kt index 4685db19..bd89bc40 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaStatus.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaStatus.kt @@ -14,6 +14,7 @@ enum class TeslaLinkState(val raw: Int) { 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 @@ -32,7 +33,6 @@ enum class TeslaFaultDetail(val raw: Int) { Rejected(0x01), Protocol(0x02), Persist(0x03), - Link(0x04), None(0xFF); companion object { @@ -66,6 +66,7 @@ data class TeslaStatus( 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)), diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaVehicleScanner.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaVehicleScanner.kt new file mode 100644 index 00000000..c2aac9a8 --- /dev/null +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaVehicleScanner.kt @@ -0,0 +1,192 @@ +package com.softwiredtech.dashpilot.ble + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.util.Log +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import java.security.MessageDigest + +/** + * A detected Tesla advertisement. [advertisedName] is the raw local name + * ("Tesla " or legacy "S<16 hex>"); [rssi] is the measured signal + * strength used to tell cars apart at a glance (the car you're standing next + * to reads strongest). The phone cannot reconstruct a full VIN from either + * name form — legacy is a one-way SHA-1 hash of the VIN, modern only carries + * the last 6 characters — so the VIN is still collected from the user before + * provisioning, then confirmed against the advert. + */ +data class TeslaVehicle( + val advertisedName: String, + val device: BluetoothDevice, + val rssi: Int, +) + +/** + * Scans for Tesla vehicles advertising. Two modes: + * + * - [scan] (VIN-derived): scans for a *specific* VIN using the two + * VIN-derived advert names and emits the first match. + * - [scanNearby]: scans for *any* Tesla-format advertisement (legacy or + * modern name forms) and emits each detected vehicle, so the user can pick + * their car from what is actually on air instead of typing a full VIN + * blind. The DashKit has no BLE observer, so the phone does discovery. + * + * Advert name formats: + * - legacy: "S" + first 16 hex chars of SHA1(VIN) + role letter ('C' here) + * - modern: "Tesla " + last 4..6 VIN characters (the firmware matched a 4..6 + * tail; the scan filter uses the full-adverted scan record so we match the + * same tolerance here). + */ +@SuppressLint("MissingPermission") // BLUETOOTH_SCAN is in the manifest and requested with the other BLE permissions +class TeslaVehicleScanner(private val adapter: BluetoothAdapter?) { + + companion object { + private const val TAG = "TeslaScanner" + + /** VIN charset (ISO 3779): A-Z minus I/O/Q plus digits. */ + private fun isVinChar(c: Char): Boolean = + c in '0'..'9' || (c in 'A'..'Z' && c != 'I' && c != 'O' && c != 'Q') + + private fun isHex(c: Char): Boolean = + c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F' + + /** VIN-derived advert names used by Android scan filters. */ + internal fun derivedNames(vin: String): Set { + val upper = vin.uppercase() + val sha1Hex = MessageDigest.getInstance("SHA-1") + .digest(upper.toByteArray(Charsets.US_ASCII)) + .joinToString("") { "%02x".format(it) } + return setOf("S" + sha1Hex.take(16) + "C", "Tesla " + upper.takeLast(6)) + } + + /** + * True when [advertisedName] identifies [vin]. Modern cars may expose + * only the last 4..6 VIN characters; legacy names compare the VIN hash + * case-insensitively and ignore the advertised role suffix. + */ + internal fun matchesVin(advertisedName: String, vin: String): Boolean { + val upper = vin.uppercase() + if (upper.length != TeslaClient.VIN_LEN || !upper.all(::isVinChar)) return false + + if (isModernTeslaName(advertisedName)) { + val tail = advertisedName.removePrefix("Tesla ").uppercase() + return upper.endsWith(tail) + } + if (isLegacyTeslaName(advertisedName)) { + val expectedHashPrefix = derivedNames(upper).first { it.startsWith("S") }.dropLast(1) + return advertisedName.dropLast(1).equals(expectedHashPrefix, ignoreCase = true) + } + return false + } + + /** Legacy name: 'S' + 16 hex + role letter C/R/D/P (18 chars). */ + fun isLegacyTeslaName(name: String): Boolean { + if (name.length != 18 || name[0] != 'S') return false + for (i in 1..16) if (!isHex(name[i])) return false + return name[17] in "CRDP" + } + + /** Modern name: "Tesla " + 4..6 VIN-alphabet chars. */ + fun isModernTeslaName(name: String): Boolean { + val tail = if (name.startsWith("Tesla ")) name.removePrefix("Tesla ") else return false + if (tail.length !in 4..6) return false + return tail.all(::isVinChar) + } + + fun isTeslaName(name: String): Boolean = + isLegacyTeslaName(name) || isModernTeslaName(name) + } + + /** Emits each advertisement whose advertised local name matches the derived names for [vin]. */ + fun scan(vin: String): Flow = scanTeslaFilter(derivedNames(vin)) + + /** + * Emits every Tesla-format advertisement seen while scanning. Callers + * dedupe by device address / collapse to a Map. Returns an empty flow (no + * scan started) when Bluetooth LE scanning is unavailable. + */ + fun scanNearby(): Flow = callbackFlow { + val scanner = adapter?.bluetoothLeScanner + if (scanner == null) { + Log.w(TAG, "BLE scanning unavailable") + close(IllegalStateException("Bluetooth LE scanning unavailable")) + return@callbackFlow + } + + val callback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + val advertised = result.scanRecord?.deviceName ?: return + if (isTeslaName(advertised)) { + trySend( + TeslaVehicle( + advertisedName = advertised, + device = result.device, + rssi = result.rssi, + ) + ) + } + } + + override fun onScanFailed(errorCode: Int) { + Log.w(TAG, "BLE scan failed: $errorCode") + close(IllegalStateException("BLE scan failed: $errorCode")) + } + } + + val settings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + + try { + scanner.startScan(null, settings, callback) + } catch (e: Exception) { + Log.w(TAG, "BLE scan start failed: ${e.message}") + close(e) + return@callbackFlow + } + awaitClose { runCatching { scanner.stopScan(callback) } } + } + + /** Emits each advertisement whose advertised local name matches [names] exactly. */ + private fun scanTeslaFilter(names: Set): Flow = callbackFlow { + val scanner = adapter?.bluetoothLeScanner + if (scanner == null) { + close(IllegalStateException("Bluetooth LE scanning unavailable")) + return@callbackFlow + } + + val callback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + // Belt and braces: the ScanFilter already matches by name, but + // some OEM stacks filter inconsistently, so verify here too. + val advertised = result.scanRecord?.deviceName ?: return + if (advertised in names) trySend(result.device) + } + + override fun onScanFailed(errorCode: Int) { + Log.w(TAG, "BLE scan failed: $errorCode") + close(IllegalStateException("BLE scan failed: $errorCode")) + } + } + + val filters = names.map { ScanFilter.Builder().setDeviceName(it).build() } + val settings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + + try { + scanner.startScan(filters, settings, callback) + } catch (e: Exception) { + close(e) + return@callbackFlow + } + awaitClose { runCatching { scanner.stopScan(callback) } } + } +} diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaVinDecoder.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaVinDecoder.kt new file mode 100644 index 00000000..4eb5255d --- /dev/null +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaVinDecoder.kt @@ -0,0 +1,193 @@ +package com.softwiredtech.dashpilot.ble + +/** + * Local VIN decoder for the provisioning flow, aligned with Tesla's published + * VIN tables (Tesla service manual VIN decoding + the community references). + * + * What the BLE advertisement actually leaks: + * - legacy advert: "S" + first 8 bytes of SHA1(VIN) + role letter. SHA1 is a + * one-way hash — the full VIN (and thus make/model) is NOT recoverable. + * - modern advert: "Tesla " + the LAST 6 VIN characters. That IS a partial + * VIN (the production sequence), but make/model/body/year/plant live in VIN + * positions 1–8 and 10–11, which are never broadcast. + * + * So the app decodes the VIN the user types for provisioning (all 17 chars are + * required by the firmware's TESLA_CMD_PROVISION anyway) into the pieces an + * owner cares about: model, drive unit, model year, and assembly plant. + * + * Best-effort local tables for common Tesla WMIs / VDS patterns — not a + * licensed decoder database. + */ +object TeslaVinDecoder { + + private const val VALID_CHARS = "ABCDEFGHJKLMNPRSTVWXYZ0123456789" + + /** Known Tesla World Manufacturer Identifiers (Tesla service manual + references). */ + private val WMI_PLANT = mapOf( + "5YJ" to "Fremont, CA", + "7SA" to "Austin, TX", + "7G2" to "Austin, TX", // Cybertruck / Semi class + "LRW" to "Shanghai, China", + "XP7" to "Berlin, Germany", + "SFZ" to "Hethel, UK", // original Roadster + ) + + /** VIN char 4 -> model / line series (Tesla service manual). */ + private val MODEL_BY_CHAR = mapOf( + 'S' to "Model S", + 'X' to "Model X", + '3' to "Model 3", + 'Y' to "Model Y", + 'C' to "Cybertruck", + 'R' to "Roadster", + ) + + /** VIN char 5 -> body style (Tesla service manual / decoder tables). */ + private val BODY_BY_CHAR = mapOf( + 'A' to "Hatchback LHD", + 'B' to "Hatchback RHD", + 'C' to "MPV LHD", + 'D' to "MPV RHD", + 'E' to "Sedan LHD", + 'F' to "Sedan RHD", + 'G' to "MPV LHD", + 'H' to "MPV RHD", + ) + + /** VIN char 7 -> battery chemistry (E = ternary Li-ion, F = LFP). */ + private val BATTERY_BY_CHAR = mapOf( + 'E' to "Li-Ion", + 'F' to "LiFePO4", + 'H' to "Li-Ion", + 'S' to "Li-Ion", + 'V' to "Li-Ion", + ) + + /** VIN char 8 -> motor / drive unit, per Tesla's per-model table. */ + private val DRIVE_BY_MODEL = mapOf( + 'S' to mapOf('5' to "Dual Motor", '6' to "Tri Motor (Plaid)", '4' to "Dual Motor", '2' to "Single Motor"), + 'X' to mapOf('5' to "Dual Motor", '6' to "Tri Motor (Plaid)", '4' to "Dual Motor", '2' to "Single Motor"), + '3' to mapOf( + 'A' to "Single Motor", 'B' to "Dual Motor", 'C' to "Dual Motor Performance", + 'R' to "Single Motor", 'S' to "Single Motor", 'T' to "Single Motor", + ), + 'Y' to mapOf( + 'D' to "Single Motor", 'E' to "Dual Motor", 'F' to "Dual Motor Performance", + 'J' to "Single Motor", 'K' to "Dual Motor", 'L' to "Dual Motor Performance", + ), + 'C' to mapOf('A' to "Dual Motor", 'B' to "Tri Motor", 'C' to "Dual Motor"), + ) + + /** VIN char 10 -> model year (2010–2030 cycling). */ + private val YEAR_BY_CHAR = mapOf( + 'A' to 2010, 'B' to 2011, 'C' to 2012, 'D' to 2013, 'E' to 2014, + 'F' to 2015, 'G' to 2016, 'H' to 2017, 'J' to 2018, 'K' to 2019, + 'L' to 2020, 'M' to 2021, 'N' to 2022, 'P' to 2023, 'R' to 2024, + 'S' to 2025, 'T' to 2026, 'V' to 2027, 'W' to 2028, 'X' to 2029, + 'Y' to 2030, + ) + + /** VIN char 11 -> assembly plant (Tesla service manual). */ + private val PLANT_BY_CHAR = mapOf( + 'F' to "Fremont, CA", + 'A' to "Austin, TX", + 'B' to "Berlin, Germany", + 'G' to "Shanghai, China", + 'R' to "Shanghai, China", + 'K' to "Lathrop, CA", // Semi + ) + + data class DecodedVin( + val manufacturer: String?, + val model: String?, + val body: String?, + val drive: String?, + val battery: String?, + val modelYear: Int?, + val plant: String?, + val valid: Boolean, + ) + + fun isValidVinLength(vin: String): Boolean = + vin.length == 17 && vin.all { it in VALID_CHARS } + + /** Complete, legal VIN belonging to a Tesla WMI known by this decoder. */ + fun isTeslaVin(vin: String): Boolean { + val upper = vin.uppercase() + return isValidVinLength(upper) && upper.take(3) in WMI_PLANT + } + + /** ISO 3779 transliteration values (I, O, Q never appear in a VIN). */ + private val CHAR_VALUES: Map = buildMap { + ('0'..'9').forEach { put(it, it - '0') } + putAll( + mapOf( + 'A' to 1, 'B' to 2, 'C' to 3, 'D' to 4, 'E' to 5, 'F' to 6, 'G' to 7, 'H' to 8, + 'J' to 1, 'K' to 2, 'L' to 3, 'M' to 4, 'N' to 5, + 'P' to 7, 'R' to 9, + 'S' to 2, 'T' to 3, 'U' to 4, 'V' to 5, 'W' to 6, 'X' to 7, 'Y' to 8, 'Z' to 9, + ), + ) + } + + /** ISO 3779 position weights. */ + private val POSITION_WEIGHTS = intArrayOf(8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2) + + /** + * True when the position-9 check digit satisfies ISO 3779. Every Tesla + * plant (US, CN, EU) computes it, so a failure almost always means a + * mistyped character — precisely what an advert tail match cannot catch. + * + * Deliberately NOT part of [isTeslaVin]: some regions do not enforce the + * digit, and blocking here could lock out a legitimate car. Callers treat + * a failure as "require explicit confirmation", not "impossible". + */ + fun checkDigitValid(vin: String): Boolean { + val upper = vin.uppercase() + if (upper.length != 17) return false + var sum = 0 + for (i in upper.indices) { + val value = CHAR_VALUES[upper[i]] ?: return false + sum += value * POSITION_WEIGHTS[i] + } + val expected = sum % 11 + return upper[8] == if (expected == 10) 'X' else ('0' + expected) + } + + fun decode(vin: String): DecodedVin { + val upper = vin.uppercase() + if (!isTeslaVin(upper)) { + return DecodedVin(null, null, null, null, null, null, null, false) + } + + val wmi = upper.substring(0, 3) + val modelChar = upper[3] + val model = MODEL_BY_CHAR[modelChar] + val body = BODY_BY_CHAR[upper[4]] + val battery = BATTERY_BY_CHAR[upper[6]] + // Drive table is model-specific; only decode when both are known. + val drive = DRIVE_BY_MODEL[modelChar]?.get(upper[7]) + val modelYear = YEAR_BY_CHAR[upper[9]] + val plant = PLANT_BY_CHAR[upper[10]] ?: WMI_PLANT[wmi] + val manufacturer = "Tesla" + return DecodedVin(manufacturer, model, body, drive, battery, modelYear, plant, true) + } + + /** Human one-liner, e.g. "Tesla Model 3 · Dual Motor · 2021 · Fremont, CA". */ + fun descriptive(decoded: DecodedVin): String { + val parts = buildList { + add(decoded.manufacturer ?: "Vehicle") + decoded.model?.let { add(it) } + decoded.drive?.let { add(it) } + decoded.modelYear?.let { add(it.toString()) } + decoded.plant?.let { add(it) } + } + return parts.joinToString(" · ") + } + + /** Secondary detail line, e.g. "Sedan LHD · LiFePO4". Empty when unknown. */ + fun detailLine(decoded: DecodedVin): String { + val bits = listOfNotNull(decoded.body, decoded.battery, decoded.drive) + return bits.distinct().joinToString(" · ") + } +} diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/HomeScreen.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/HomeScreen.kt index 1d317091..fb8662e5 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/HomeScreen.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/HomeScreen.kt @@ -52,6 +52,7 @@ import com.softwiredtech.dashpilot.datasource.DataSourceType import com.softwiredtech.dashpilot.ui.controls.ControlActionButton import com.softwiredtech.dashpilot.ui.controls.controlById import com.softwiredtech.dashpilot.ui.tesla.TeslaTile +import com.softwiredtech.dashpilot.ui.tesla.rememberTeslaCarsDetected import com.softwiredtech.dashpilot.ui.theme.AccentColor import com.softwiredtech.dashpilot.ui.theme.DarkColors import kotlinx.coroutines.flow.Flow @@ -212,7 +213,8 @@ private fun ConnectedHomeContent( Spacer(modifier = Modifier.height(32.dp)) - TeslaTile(status = tesla, onEnroll = onEnrollTesla) + val carsDetected = rememberTeslaCarsDetected(teslaStatus) + TeslaTile(status = tesla, onEnroll = onEnrollTesla, carsDetected = carsDetected) controlById(pinnedControlId)?.let { action -> Text( diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/SettingsScreen.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/SettingsScreen.kt index 23a600b3..2c3bb5f7 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/SettingsScreen.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/SettingsScreen.kt @@ -70,6 +70,7 @@ import com.softwiredtech.dashpilot.ble.TeslaClient import com.softwiredtech.dashpilot.ble.TeslaLinkState import com.softwiredtech.dashpilot.ble.TeslaStatus import com.softwiredtech.dashpilot.ble.VehicleControl +import com.softwiredtech.dashpilot.ui.tesla.rememberTeslaCarsDetected import com.softwiredtech.dashpilot.ui.tesla.teslaTileSummary import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -716,6 +717,7 @@ private fun TeslaKeySection( val connected = connectionState == ConnectionStatus.Connected val idleTesla = remember { MutableStateFlow(TeslaStatus.Idle) } val status by (teslaStatus ?: idleTesla).collectAsState() + val carsDetected = rememberTeslaCarsDetected(teslaStatus) val showResetDialog = remember { mutableStateOf(false) } SectionHeader(stringResource(R.string.settings_section_tesla_key)) @@ -725,7 +727,12 @@ private fun TeslaKeySection( fontSize = 12.sp, ) Spacer(modifier = Modifier.height(12.dp)) - InfoRow(label = stringResource(R.string.settings_tesla_key_status), value = teslaStatusText(status)) + val statusLabel = if (status.linkState == TeslaLinkState.NeverEnrolled && carsDetected) { + stringResource(R.string.tesla_tile_car_found) + } else { + teslaStatusText(status) + } + InfoRow(label = stringResource(R.string.settings_tesla_key_status), value = statusLabel) Spacer(modifier = Modifier.height(12.dp)) // Contextual action: show "Connect" when no connection exists, "Remove" only @@ -780,6 +787,7 @@ private fun TeslaKeySection( private fun teslaStatusText(status: TeslaStatus): String = when (status.linkState) { TeslaLinkState.NeverEnrolled -> stringResource(R.string.tesla_tile_key_not_set_up) TeslaLinkState.Staged -> stringResource(R.string.tesla_tile_staged) + TeslaLinkState.Connecting -> stringResource(R.string.tesla_enroll_connecting_body) TeslaLinkState.EnrolledNotConnected -> stringResource(R.string.tesla_tile_not_connected) TeslaLinkState.EnrolledConnected -> teslaTileSummary(status) .ifBlank { stringResource(R.string.tesla_tile_connected) } diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaEnrollFlow.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaEnrollFlow.kt index c7458c20..be874c95 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaEnrollFlow.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaEnrollFlow.kt @@ -1,7 +1,11 @@ package com.softwiredtech.dashpilot.ui.tesla +import android.bluetooth.BluetoothManager +import android.content.Context +import android.os.SystemClock import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,25 +16,38 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import com.softwiredtech.dashpilot.R @@ -38,11 +55,15 @@ import com.softwiredtech.dashpilot.ble.TeslaClient import com.softwiredtech.dashpilot.ble.TeslaFaultDetail import com.softwiredtech.dashpilot.ble.TeslaLinkState import com.softwiredtech.dashpilot.ble.TeslaStatus +import com.softwiredtech.dashpilot.ble.TeslaVehicle +import com.softwiredtech.dashpilot.ble.TeslaVehicleScanner +import com.softwiredtech.dashpilot.ble.TeslaVinDecoder import com.softwiredtech.dashpilot.datasource.DashKitBleManager import com.softwiredtech.dashpilot.ui.onboarding.DevicePuck import com.softwiredtech.dashpilot.ui.onboarding.OnboardingPageScaffold import com.softwiredtech.dashpilot.ui.onboarding.PairingState import com.softwiredtech.dashpilot.ui.onboarding.PrimaryCta +import com.softwiredtech.dashpilot.ui.theme.DarkColors import com.softwiredtech.dashpilot.ui.theme.OnboardingColors import com.softwiredtech.dashpilot.ui.theme.TeslaCyan import kotlinx.coroutines.delay @@ -50,13 +71,22 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow private const val TAP_WINDOW_S = 60 +private const val PROVISION_ACK_TIMEOUT_MS = 8_000L +private const val PROVISION_DISPATCH_RETRIES = 3 + +// A car counts as present while its advertisement was heard within this +// window; advert cycles are sub-second, so silence past it means the car +// left range. Both the scan list and the automatic VIN match expire on it. +private const val CAR_VISIBLE_WINDOW_MS = 10_000L +private const val CAR_PRUNE_TICK_MS = 500L /** - * Standalone Phase 4 enroll flow. App-triggered only: the firmware never starts - * pairing by itself (the DashKit's LEDs are not user-visible). The app watches - * [statusFlow] and: starts enrollment (Start -> send 0x01), shows a live - * tap-window countdown with a Cancel (0x03), renders success when a key is - * enrolled, or a fault (with its detail) when enrollment fails. + * Standalone enroll flow. App-driven end to end: the phone scans for the car + * and stages it on the DashKit (ProvisionStep -> send 0x04 with VIN + MAC), + * then watches [statusFlow]: starts enrollment (Start -> send 0x01), shows a + * live tap-window countdown with a Cancel (0x03), renders success when a key + * is enrolled, or a fault (with its detail) when enrollment fails. The + * firmware never starts pairing by itself (its LEDs are not user-visible). */ @Composable fun TeslaEnrollFlow( @@ -68,50 +98,417 @@ fun TeslaEnrollFlow( val status by (statusFlow ?: idleFlow).collectAsState() BackHandler(onBack = onClose) + // VIN whose staging was dispatched this session; feeds the decoded car + // identity line ("Model 3 · 2021 · Fremont") shown on every later step so + // the payoff of the VIN step stays visible through connect/pair/success. + var stagedVin by rememberSaveable { mutableStateOf(null) } + Box(modifier = Modifier.fillMaxSize().background(OnboardingColors.BgBase).systemBarsPadding()) { when (status.linkState) { - TeslaLinkState.NeverEnrolled, TeslaLinkState.Unknown -> LookingStep(onClose = onClose) - TeslaLinkState.Staged -> StartStep(onStart = { manager?.let { TeslaClient.sendStart(it) } }) - TeslaLinkState.PairingWindow -> TapCardStep(onCancel = { manager?.let { TeslaClient.sendCancel(it) } }) + TeslaLinkState.NeverEnrolled, TeslaLinkState.Unknown -> ProvisionStep( + manager = manager, + onStaged = { stagedVin = it }, + onClose = onClose, + ) + TeslaLinkState.Staged -> StartStep( + identityVin = stagedVin, + onStart = { manager?.let { TeslaClient.sendStart(it) } }, + ) + // Interim state: the app tapped Connect and the firmware accepted + // it (DashKit connecting to the car / before the tap window + // opens). Same spinner-onwards UX as staged, but with distinct + // copy so the user knows the flow is moving. + TeslaLinkState.Connecting -> ConnectingStep( + identityVin = stagedVin, + onCancel = { manager?.let { TeslaClient.sendCancel(it) } }, + ) + TeslaLinkState.PairingWindow -> TapCardStep( + carReady = status.flags and 0x01 != 0, + identityVin = stagedVin, + onCancel = { manager?.let { TeslaClient.sendCancel(it) } }, + ) TeslaLinkState.EnrollmentFault -> ErrorStep( fault = status.faultDetail, + identityVin = stagedVin, onRetry = { manager?.let { TeslaClient.sendStart(it) } }, onCancel = onClose, ) - TeslaLinkState.EnrolledNotConnected, TeslaLinkState.EnrolledConnected -> SuccessStep(onDone = onClose) + TeslaLinkState.EnrolledNotConnected, TeslaLinkState.EnrolledConnected -> SuccessStep( + identityVin = stagedVin, + onDone = onClose, + ) } } } +/** VIN character set: A–Z except I, O, Q, plus digits. */ +private fun sanitizeVin(input: String): String = input.uppercase() + .filter { it.isDigit() || (it in 'A'..'Z' && it != 'I' && it != 'O' && it != 'Q') } + .take(TeslaClient.VIN_LEN) + +/** 17-char VIN grouped as WMI VDS VIS, e.g. "5YJ3E7EB 1MF123456". */private fun formatVin(vin: String): String = vin.uppercase() + .chunked(8) + .joinToString(" ") + +/** 17-char VIN grouped as WMI VDS VIS, e.g. "5YJ3E7EB 1MF123456". *//** + * Differentiating data for a car whose advert carries no VIN (legacy format + * "S" + SHA-1(VIN)[:16] + role). The full hash is one-way, so we surface a + * short tag unique to that car's broadcast plus signal strength — enough to + * tell two cars apart without showing the cryptic 18-char name. + */ +private fun identitySnippet(v: TeslaVehicle): String = + if (TeslaVehicleScanner.isLegacyTeslaName(v.advertisedName)) { + "ID ····" + v.advertisedName.drop(1).takeLast(4) + } else { + "ID ····" + v.advertisedName.takeLast(4) + } + +/** Signal-strength label: strongest = your car (closest). */ +private fun signalLabel(rssi: Int): String = when { + rssi >= -55 -> "▮▮▮ $rssi dBm" + rssi >= -70 -> "▮▮ $rssi dBm" + rssi >= -85 -> "▮ $rssi dBm" + else -> "— $rssi dBm" +} + +private fun signalColor(rssi: Int): Color = when { + rssi >= -70 -> OnboardingColors.Accent + rssi >= -85 -> Color(0xFFF5A623) + else -> DarkColors.Error +} + +/** + * VIN entry + vehicle discovery: scan nearby Tesla adverts, stage the chosen + * car via TESLA_CMD_PROVISION. Staging is an explicit beat — decoded card + + * "Stage this car" — gated on a live advert match and VIN trust (check digit, + * or override). + */ @Composable -private fun LookingStep(onClose: () -> Unit) { +private fun ProvisionStep( + manager: DashKitBleManager?, + onStaged: (String) -> Unit, + onClose: () -> Unit, +) { + var vin by rememberSaveable { mutableStateOf("") } + var provisioning by remember { mutableStateOf(false) } + var provisionError by remember { mutableStateOf(false) } + var provisionRetry by remember { mutableIntStateOf(0) } + var scanError by remember { mutableStateOf(false) } + val context = LocalContext.current + val complete = vin.length == TeslaClient.VIN_LEN + val valid = TeslaVinDecoder.isTeslaVin(vin) + + // Failing digit ⇒ likely typo; never blocks staging, just requires the + // explicit confirmation below (see TeslaVinDecoder.checkDigitValid). + val checkOk = remember(valid, vin) { valid && TeslaVinDecoder.checkDigitValid(vin) } + var confirmedBadVin by remember { mutableStateOf(null) } + val stageConfirmed = checkOk || confirmedBadVin == vin + + // Confirm beat: staging fires only after the user taps "Stage this car" + // for THIS vin (editing the vin re-arms the requirement). + var stageRequestedForVin by rememberSaveable { mutableStateOf(null) } + val stageRequested = stageRequestedForVin == vin + + // Live scan of every Tesla-format advertisement, deduped by address so the + // list shows each car once (newest RSSI/name wins for the row), stamped + // with when its advert was last heard. This single scan both drives the + // list and (for the confirm beat) finds the car matching the entered VIN, + // so we never run two BLE scans side by side. + val sightings = remember { mutableStateOf(mapOf>()) } + LaunchedEffect(manager) { + if (manager == null) return@LaunchedEffect + val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter + scanError = false + try { + TeslaVehicleScanner(adapter).scanNearby().collect { v -> + val now = SystemClock.elapsedRealtime() + sightings.value = + (sightings.value + (v.device.address to (v to now))) + .filterValues { (_, seenAt) -> now - seenAt <= CAR_VISIBLE_WINDOW_MS } + } + } catch (_: Exception) { + if (!provisioning) scanError = true + } + } + + // Tick so cars that stop advertising age out of the list even when nothing + // new arrives; without this a ghost row would linger until the next advert. + var nowMs by remember { mutableLongStateOf(SystemClock.elapsedRealtime()) } + LaunchedEffect(Unit) { + while (true) { + delay(CAR_PRUNE_TICK_MS) + nowMs = SystemClock.elapsedRealtime() + } + } + + // Cars heard within [CAR_VISIBLE_WINDOW_MS]. Both the list and the + // automatic VIN match use only these — a stale entry must never be shown + // as present or provisioned against. + val liveVehicles = sightings.value.values + .filter { (_, seenAt) -> nowMs - seenAt <= CAR_VISIBLE_WINDOW_MS } + .map { (v, _) -> v } + .sortedByDescending { it.rssi } + + val automaticMatch = if (valid) { + liveVehicles + .filter { TeslaVehicleScanner.matchesVin(it.advertisedName, vin) } + .maxByOrNull { it.rssi } + } else null + + // One controlled attempt per VIN/target pair, and only once the user has + // confirmed the decoded card (Stage this car) AND the VIN is trusted + // (check digit passed, or explicit override). Android accepting the GATT + // write is not firmware acknowledgement: the outer status state advances + // only when firmware reports Staged. If that never arrives, allow retry. + LaunchedEffect(valid, stageConfirmed, stageRequested, vin, automaticMatch?.device?.address, manager, provisionRetry) { + if (!valid || !stageConfirmed || !stageRequested || manager == null || automaticMatch == null) return@LaunchedEffect + provisioning = false + provisionError = false + repeat(PROVISION_DISPATCH_RETRIES) { attempt -> + if (TeslaClient.sendProvision(manager, vin, automaticMatch.device.address)) { + onStaged(vin) + provisioning = true + delay(PROVISION_ACK_TIMEOUT_MS) + provisioning = false + provisionError = true + return@LaunchedEffect + } + if (attempt < PROVISION_DISPATCH_RETRIES - 1) delay(750) + } + provisionError = true + } + + val decoded = remember(valid, vin) { if (valid) TeslaVinDecoder.decode(vin) else null } + InlineScaffold( title = stringResource(R.string.tesla_enroll_title), - subtitle = stringResource(R.string.tesla_enroll_looking), + subtitle = stringResource(R.string.tesla_enroll_vin_help), hero = { DevicePuck(state = PairingState.Searching, accent = TeslaCyan) }, extra = { - Text( - text = stringResource(R.string.tesla_enroll_looking_hint), - color = OnboardingColors.TextMuted, - fontSize = 13.sp, - ) + Column(Modifier.fillMaxWidth()) { + OutlinedTextField( + value = vin, + onValueChange = { vin = sanitizeVin(it) }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.tesla_enroll_vin_label)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + ) + Spacer(Modifier.height(8.dp)) + + if (complete && !valid) { + Text( + text = stringResource(R.string.tesla_enroll_vin_invalid), + color = MaterialTheme.colorScheme.error, + fontSize = 13.sp, + ) + Spacer(Modifier.height(8.dp)) + } + + // Once a full VIN is entered we can decode it to make/model/ + // year/plant (the advert itself only carries an unreversible + // hash or the last 6 VIN characters). + if (decoded?.valid == true) { + Text( + text = stringResource(R.string.tesla_enroll_vin_decoded, + TeslaVinDecoder.descriptive(decoded)), + color = TeslaCyan, + fontSize = 13.sp, + ) + val detail = TeslaVinDecoder.detailLine(decoded) + if (detail.isNotEmpty()) { + Text( + text = detail, + color = OnboardingColors.TextMuted, + fontSize = 13.sp, + ) + } + Spacer(Modifier.height(8.dp)) + } + + // Staging stays locked until the user accepts the failing + // check digit for this exact VIN. + if (complete && valid && !stageConfirmed) { + Text( + text = stringResource(R.string.tesla_enroll_check_digit_warning), + color = Color(0xFFF5A623), + fontSize = 13.sp, + ) + TextButton(onClick = { confirmedBadVin = vin }) { + Text(stringResource(R.string.tesla_enroll_stage_anyway), color = TeslaCyan) + } + Spacer(Modifier.height(8.dp)) + } + + if (liveVehicles.isEmpty()) { + val statusText = when { + scanError -> stringResource(R.string.tesla_enroll_scan_failed) + else -> stringResource(R.string.tesla_enroll_scanning) + } + Text( + text = statusText, + color = if (scanError) MaterialTheme.colorScheme.error else OnboardingColors.TextMuted, + fontSize = 13.sp, + ) + } else { + Text( + text = stringResource(R.string.tesla_enroll_cars_found), + color = OnboardingColors.TextMuted, + fontSize = 13.sp, + ) + Spacer(Modifier.height(6.dp)) + liveVehicles.forEach { v -> + // Row identity reflects evidence strength: a matched + // VIN decodes fully; a modern advert leaks only its + // last-4..6 tail; a legacy advert carries no VIN at + // all (one-way hash) — signal + broadcast tag instead. + val thisVinOk = valid && TeslaVehicleScanner.matchesVin(v.advertisedName, vin) + val modernTail = v.advertisedName.removePrefix("Tesla ") + val label = when { + thisVinOk -> TeslaVinDecoder.descriptive(decoded!!) + v.advertisedName.startsWith("Tesla ") -> + "Tesla · VIN ······" + modernTail.takeLast(6) + else -> stringResource(R.string.tesla_enroll_car_unknown) + } + // Secondary line: VIN evidence where the advert + // carries it, else signal + broadcast tag. + val vinText = when { + thisVinOk -> formatVin(vin) + modernTail.length in 4..6 -> + stringResource(R.string.tesla_enroll_vin_tail, modernTail.takeLast(6)) + else -> identitySnippet(v) + } + val isMatch = thisVinOk + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background( + if (isMatch) DarkColors.SurfaceSelected + else OnboardingColors.Surface + ) + .border( + width = if (isMatch) 1.dp else 0.dp, + color = if (isMatch) TeslaCyan else Color.Transparent, + shape = RoundedCornerShape(10.dp), + ) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + text = label, + color = if (thisVinOk) TeslaCyan else Color.White, + fontSize = 14.sp, + fontWeight = if (isMatch) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = vinText, + color = DarkColors.TextMuted, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // Make/model detail once the VIN is confirmed: + // body · battery · drive, straight from the VDS. + if (thisVinOk) { + val detail = TeslaVinDecoder.detailLine(decoded!!) + if (detail.isNotEmpty()) { + Text( + text = detail, + color = OnboardingColors.TextMuted, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + Spacer(Modifier.width(8.dp)) + Column(horizontalAlignment = Alignment.End) { + if (isMatch) { + // Evidence-strength matters here: a modern + // advert only proves the last 4–6 VIN + // characters, while a legacy advert matches + // a 64-bit SHA-1 prefix of the whole VIN. + Text( + text = + if (v.advertisedName.startsWith("Tesla ")) { + stringResource(R.string.tesla_enroll_tail_matches) + } else { + stringResource(R.string.tesla_enroll_match_check) + }, + color = TeslaCyan, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + ) + } + Text( + text = signalLabel(v.rssi), + color = signalColor(v.rssi), + fontSize = 11.sp, + ) + } + } + if (!isMatch) Spacer(Modifier.height(4.dp)) + } + } + + if (provisioning) { + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.tesla_enroll_found), + color = OnboardingColors.TextMuted, + fontSize = 13.sp, + ) + } else if (provisionError) { + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.tesla_enroll_provision_failed), + color = MaterialTheme.colorScheme.error, + fontSize = 13.sp, + ) + TextButton(onClick = { provisionRetry++ }) { + Text(stringResource(R.string.tesla_enroll_retry), color = TeslaCyan) + } + } + } }, cta = { - TextButton(onClick = onClose, modifier = Modifier.fillMaxWidth()) { - Text(stringResource(R.string.tesla_enroll_cancel), color = OnboardingColors.TextSecondary) + Column(Modifier.fillMaxWidth()) { + // The confirm beat: enabled only once a live advert match + // exists and the VIN is trusted, so the tap is always an + // informed commitment. + PrimaryCta( + label = stringResource(R.string.tesla_enroll_stage_cta), + onClick = { + stageRequestedForVin = vin + onStaged(vin) + }, + enabled = valid && stageConfirmed && !provisioning && automaticMatch != null, + ) + Spacer(Modifier.height(8.dp)) + TextButton(onClick = onClose, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.tesla_enroll_cancel), color = OnboardingColors.TextSecondary) + } } }, ) } @Composable -private fun StartStep(onStart: () -> Unit) { +private fun StartStep(identityVin: String?, onStart: () -> Unit) { InlineScaffold( title = stringResource(R.string.tesla_enroll_title), subtitle = stringResource(R.string.tesla_enroll_explain_body), hero = { DevicePuck(state = PairingState.Searching, accent = TeslaCyan) }, extra = { - RoleChip(stringResource(R.string.tesla_enroll_role_chip)) + Column(Modifier.fillMaxWidth()) { + CarIdentityLine(identityVin) + RoleChip(stringResource(R.string.tesla_enroll_role_chip)) + } }, cta = { Column(Modifier.fillMaxWidth()) { @@ -128,7 +525,27 @@ private fun StartStep(onStart: () -> Unit) { } @Composable -private fun TapCardStep(onCancel: () -> Unit) { +private fun ConnectingStep(identityVin: String?, onCancel: () -> Unit) { + InlineScaffold( + title = stringResource(R.string.tesla_enroll_title), + subtitle = stringResource(R.string.tesla_enroll_connecting_body), + hero = { DevicePuck(state = PairingState.Searching, accent = TeslaCyan) }, + extra = { + Column(Modifier.fillMaxWidth()) { + CarIdentityLine(identityVin) + RoleChip(stringResource(R.string.tesla_enroll_connecting_hint)) + } + }, + cta = { + TextButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.tesla_enroll_cancel), color = OnboardingColors.TextSecondary) + } + }, + ) +} + +@Composable +private fun TapCardStep(carReady: Boolean, identityVin: String?, onCancel: () -> Unit) { var remaining by remember { mutableIntStateOf(TAP_WINDOW_S) } LaunchedEffect(Unit) { while (remaining > 0) { @@ -141,11 +558,23 @@ private fun TapCardStep(onCancel: () -> Unit) { subtitle = stringResource(R.string.tesla_enroll_tap_body), hero = { DevicePuck(state = PairingState.Searching, accent = TeslaCyan) }, extra = { - Text( - text = stringResource(R.string.tesla_enroll_tap_hint, remaining), - color = TeslaCyan, - fontSize = 13.sp, - ) + Column(Modifier.fillMaxWidth()) { + CarIdentityLine(identityVin) + if (carReady) { + Text( + text = stringResource(R.string.tesla_enroll_tap_ready, remaining), + color = TeslaCyan, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + ) + } else { + Text( + text = stringResource(R.string.tesla_enroll_tap_hint, remaining), + color = OnboardingColors.TextMuted, + fontSize = 13.sp, + ) + } + } }, cta = { TextButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { @@ -156,30 +585,35 @@ private fun TapCardStep(onCancel: () -> Unit) { } @Composable -private fun SuccessStep(onDone: () -> Unit) { +private fun SuccessStep(identityVin: String?, onDone: () -> Unit) { InlineScaffold( title = stringResource(R.string.tesla_enroll_success_title), subtitle = stringResource(R.string.tesla_enroll_success_body), hero = { DevicePuck(state = PairingState.Paired) }, - extra = { RoleChip(stringResource(R.string.tesla_enroll_success_role)) }, + extra = { + Column(Modifier.fillMaxWidth()) { + CarIdentityLine(identityVin) + RoleChip(stringResource(R.string.tesla_enroll_success_role)) + } + }, cta = { PrimaryCta(label = stringResource(R.string.tesla_enroll_done), onClick = onDone) }, ) } @Composable -private fun ErrorStep(fault: TeslaFaultDetail, onRetry: () -> Unit, onCancel: () -> Unit) { +private fun ErrorStep(fault: TeslaFaultDetail, identityVin: String?, onRetry: () -> Unit, onCancel: () -> Unit) { val message = when (fault) { TeslaFaultDetail.TapTimeout -> stringResource(R.string.tesla_fault_tap_timeout) TeslaFaultDetail.Rejected -> stringResource(R.string.tesla_fault_rejected) TeslaFaultDetail.Protocol -> stringResource(R.string.tesla_fault_protocol) TeslaFaultDetail.Persist -> stringResource(R.string.tesla_fault_persist) - TeslaFaultDetail.Link -> stringResource(R.string.tesla_fault_link) TeslaFaultDetail.None -> stringResource(R.string.tesla_fault_generic) } InlineScaffold( title = stringResource(R.string.tesla_enroll_error_title), subtitle = message, hero = { DevicePuck(state = PairingState.Idle, accent = OnboardingColors.LedDim) }, + extra = { CarIdentityLine(identityVin) }, cta = { Column(Modifier.fillMaxWidth()) { PrimaryCta(label = stringResource(R.string.tesla_enroll_retry), onClick = onRetry) @@ -191,13 +625,37 @@ private fun ErrorStep(fault: TeslaFaultDetail, onRetry: () -> Unit, onCancel: () ) } +/** + * Muted one-line identity of the staged car ("Model 3 · 2021 · Fremont, CA"), + * carried through the post-provision steps so the payoff of the VIN step stays + * on screen for the whole flow instead of flashing once during hand-off. + * Renders nothing until a decodable VIN exists. + */ +@Composable +private fun CarIdentityLine(vin: String?) { + val decoded = remember(vin) { + vin?.takeIf { TeslaVinDecoder.isTeslaVin(it) }?.let { TeslaVinDecoder.decode(it) } + } ?: return + val line = listOfNotNull( + decoded.model, + decoded.modelYear?.toString(), + decoded.plant, + ).joinToString(" · ") + Text( + text = line, + color = OnboardingColors.TextMuted, + fontSize = 13.sp, + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + ) +} + /** Thin wrapper over the onboarding scaffold with consistent hero sizing + a spinner-capable CTA. */ @Composable private fun InlineScaffold( title: String, subtitle: String, cta: @Composable () -> Unit, - hero: @Composable () -> Unit = {}, + hero: @Composable () -> Unit, extra: @Composable () -> Unit = {}, ) { OnboardingPageScaffold( diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaTile.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaTile.kt index 8742269f..f10fa23c 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaTile.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaTile.kt @@ -1,5 +1,7 @@ package com.softwiredtech.dashpilot.ui.tesla +import android.bluetooth.BluetoothManager +import android.content.Context import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -14,10 +16,17 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -27,9 +36,14 @@ import androidx.compose.ui.unit.sp import com.softwiredtech.dashpilot.R import com.softwiredtech.dashpilot.ble.TeslaLinkState import com.softwiredtech.dashpilot.ble.TeslaStatus +import com.softwiredtech.dashpilot.ble.TeslaVehicleScanner import com.softwiredtech.dashpilot.ui.theme.DarkColors import com.softwiredtech.dashpilot.ui.theme.OnboardingColors import com.softwiredtech.dashpilot.ui.theme.TeslaCyan +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull /** * One-line Tesla tile on Home. App-triggered only: "Key not set up" (0x00) and @@ -39,25 +53,34 @@ import com.softwiredtech.dashpilot.ui.theme.TeslaCyan * car trim and not user-visible. */ @Composable -fun TeslaTile(status: TeslaStatus, onEnroll: () -> Unit) { +fun TeslaTile(status: TeslaStatus, onEnroll: () -> Unit, carsDetected: Boolean = false) { val tapEnabled = status.linkState == TeslaLinkState.Staged || status.linkState == TeslaLinkState.NeverEnrolled || status.linkState == TeslaLinkState.EnrollmentFault - val text = when (status.linkState) { - TeslaLinkState.NeverEnrolled -> stringResource(R.string.tesla_tile_key_not_set_up) - TeslaLinkState.Staged -> stringResource(R.string.tesla_tile_staged) - TeslaLinkState.EnrolledNotConnected -> stringResource(R.string.tesla_tile_not_connected) - TeslaLinkState.EnrolledConnected -> teslaTileSummary(status) + val text = when { + // No key yet but the phone has recently heard a Tesla broadcasting + // nearby: that is the "ready to pair" state, so surface it in cyan + // instead of a grey "Not connected". (Recent-past, not live presence — + // see rememberTeslaCarsDetected.) + status.linkState == TeslaLinkState.NeverEnrolled && carsDetected -> + stringResource(R.string.tesla_tile_car_found) + status.linkState == TeslaLinkState.NeverEnrolled -> + stringResource(R.string.tesla_tile_key_not_set_up) + status.linkState == TeslaLinkState.Staged -> stringResource(R.string.tesla_tile_staged) + status.linkState == TeslaLinkState.Connecting -> stringResource(R.string.tesla_enroll_connecting_body) + status.linkState == TeslaLinkState.EnrolledNotConnected -> stringResource(R.string.tesla_tile_not_connected) + status.linkState == TeslaLinkState.EnrolledConnected -> teslaTileSummary(status) .ifBlank { stringResource(R.string.tesla_tile_connected) } - TeslaLinkState.PairingWindow -> stringResource(R.string.tesla_enroll_tap_title) - TeslaLinkState.EnrollmentFault -> stringResource(R.string.tesla_tile_fault) + status.linkState == TeslaLinkState.PairingWindow -> stringResource(R.string.tesla_enroll_tap_title) + status.linkState == TeslaLinkState.EnrollmentFault -> stringResource(R.string.tesla_tile_fault) else -> stringResource(R.string.tesla_tile_not_connected) } val ledColor: Color = when { status.linkState == TeslaLinkState.EnrolledConnected -> OnboardingColors.Accent status.linkState == TeslaLinkState.Staged -> TeslaCyan + status.linkState == TeslaLinkState.NeverEnrolled && carsDetected -> TeslaCyan status.linkState == TeslaLinkState.PairingWindow -> TeslaCyan else -> DarkColors.Disabled } @@ -103,3 +126,53 @@ fun TeslaTile(status: TeslaStatus, onEnroll: () -> Unit) { } Spacer(Modifier.height(12.dp)) } + +/** + * True once a Tesla-format advertisement has been heard recently and the + * DashKit has no key yet. The firmware has no BLE observer, so the PHONE does + * discovery; this runs a short scan (respecting the Bluetooth-on + permission + * state) so the Home tile can light up cyan "Car seen nearby" without waiting + * for the user to open the enroll flow. Returns false for any connected/staged + * state — there the firmware already knows the car. + * + * The result is intentionally a recent-past signal, not live presence: it + * latches on the first sighting within a scan window and does not keep + * scanning afterwards (repeated LOW_LATENCY scans would burn battery and trip + * Android's scan throttling). Callers must word their UI accordingly. + */ +@android.annotation.SuppressLint("MissingPermission") // BLUETOOTH_SCAN requested at runtime with the other BLE permissions +@Composable +fun rememberTeslaCarsDetected( + teslaStatus: StateFlow?, + scanEnabled: Boolean = true, +): Boolean { + val fallback = remember { MutableStateFlow(TeslaStatus.Idle) } + val status by (teslaStatus ?: fallback).collectAsState() + var detected by remember { mutableStateOf(false) } + val context = LocalContext.current + + val needsDiscovery = scanEnabled && + (status.linkState == TeslaLinkState.NeverEnrolled || + status.linkState == TeslaLinkState.EnrollmentFault) + + LaunchedEffect(needsDiscovery) { + detected = false + if (!needsDiscovery) return@LaunchedEffect + val adapter = runCatching { + (context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter + }.getOrNull() ?: return@LaunchedEffect + // Very short window: enough to catch a parked car's ~100ms advert + // cycle, cheap enough to run while the Home screen is up. + withTimeoutOrNull(8_000) { + try { + TeslaVehicleScanner(adapter).scanNearby().first { + detected = true + true + } + } catch (_: Exception) { + detected = false + } + } + } + return detected +} diff --git a/dashpilot-android/app/src/main/res/values/strings.xml b/dashpilot-android/app/src/main/res/values/strings.xml index e78b9ec7..a752f1c5 100644 --- a/dashpilot-android/app/src/main/res/values/strings.xml +++ b/dashpilot-android/app/src/main/res/values/strings.xml @@ -117,6 +117,7 @@ Tesla Not connected + Car seen nearby — tap to connect Car found — Connect Car not connected Connection failed — tap to retry @@ -131,16 +132,33 @@ Connection failed - Connect to your Tesla + Connect your Tesla DashKit adds itself to your Tesla as a Charging Manager — it can read status and control charging, but can\'t unlock or drive the car. Charging Manager · Read + charge Connect Have your key card ready. - Looking for your Tesla… - Keep your phone near the car. + Contacting your car… DashKit is reaching out to pair. + Keep your phone and DashKit nearby. + Enter your VIN to find your car. + VIN + Enter a valid Tesla VIN. + Searching for your car… + Car found — connecting to DashKit… + DashKit didn\'t confirm the car. Check the connection and try again. + Nearby + Nearby Tesla + VIN decodes to: %1$s + VIN ····%1$s + MATCH ✓ + VIN tail matches ✓ + This VIN fails its ISO 3779 check digit — double-check every character. + Stage this car + Stage this car anyway + Couldn\'t start Bluetooth scanning. Check Bluetooth is on and permissions are granted. Tap your key card Place your key card on the center console, then approve on the car\'s touchscreen. %1$d s left + Car ready — tap your key card now (%1$d s) Connected DashKit is now a Charging Manager on your Tesla. Read + charge enabled @@ -152,7 +170,6 @@ The car rejected the connection. Check you\'re near the right car and using its key card. DashKit couldn\'t talk to the car. Try again. DashKit couldn\'t save the connection. Try again. - The link to DashKit was lost. Reconnect and try again. Something went wrong. Try again. diff --git a/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/TeslaStatusTest.kt b/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/TeslaStatusTest.kt index 5c8be8a0..a934559d 100644 --- a/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/TeslaStatusTest.kt +++ b/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/TeslaStatusTest.kt @@ -50,6 +50,11 @@ class TeslaStatusTest { assertNull(TeslaStatus.parse(byteArrayOf())) } + @Test + fun `parse rejects unsupported frame versions`() { + assertNull(TeslaStatus.parse(byteArrayOf(0x02, 0x02, 0x01, 0x01, 0x01, 0x00, 0xFF.toByte()))) + } + @Test fun `link state unknown for out of range byte`() { val frame = byteArrayOf(0x01, 0x7F, 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0x00, 0xFF.toByte()) diff --git a/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/TeslaVinDecoderTest.kt b/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/TeslaVinDecoderTest.kt new file mode 100644 index 00000000..bde1466c --- /dev/null +++ b/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/TeslaVinDecoderTest.kt @@ -0,0 +1,127 @@ +package com.softwiredtech.dashpilot + +import com.softwiredtech.dashpilot.ble.TeslaVinDecoder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TeslaVinDecoderTest { + + // Real-world Tesla VIN structures (fictional serials, valid positioned + // model/year/plant codes). VIN position 4 encodes the model letter, 10 the + // year, 11 the plant. + private val model3Fremont = "5YJ3E7EB1MF123456" // M -> 2021, F -> Fremont + private val modelYShanghai = "LRWYG7EKXNG123456" // N -> 2022, G -> Shanghai + private val modelSFremont = "5YJSA1EP1RF654321" // R -> 2024, F -> Fremont + + @Test + fun decodesValidTeslaVin() { + val d = TeslaVinDecoder.decode(model3Fremont) + assertEquals(true, d.valid) + assertEquals("Tesla", d.manufacturer) + assertEquals("Model 3", d.model) + assertEquals("Dual Motor", d.drive) + assertEquals("Sedan LHD", d.body) + assertEquals("Li-Ion", d.battery) + assertEquals(2021, d.modelYear) + assertEquals("Fremont, CA", d.plant) + } + + @Test + fun decodesMarketModels() { + assertEquals("Model Y", TeslaVinDecoder.decode(modelYShanghai).model) + assertEquals("Model S", TeslaVinDecoder.decode(modelSFremont).model) + assertEquals(2022, TeslaVinDecoder.decode(modelYShanghai).modelYear) + assertEquals("Dual Motor", TeslaVinDecoder.decode(modelYShanghai).drive) + } + + @Test + fun rejectsShortOrInvalidVin() { + assertFalse(TeslaVinDecoder.decode("5YJ3E7EB1M").valid) + assertFalse(TeslaVinDecoder.decode("5YJ3E7EB1MF12345").valid) + assertTrue(TeslaVinDecoder.decode("5YJ3E7EB1MF123456").valid) + } + + @Test + fun rejectsIllegalCharacters() { + val bad = "5YJ3E7EB1MFI23456" // I/O/Q not allowed in VIN charset + assertFalse(TeslaVinDecoder.decode(bad).valid) + } + + @Test + fun rejectsNonTeslaVin() { + assertFalse(TeslaVinDecoder.decode("1HGCM82633A004352").valid) + assertFalse(TeslaVinDecoder.isTeslaVin("1HGCM82633A004352")) + } + + @Test + fun descriptiveComposes() { + assertEquals( + "Tesla · Model 3 · Dual Motor · 2021 · Fremont, CA", + TeslaVinDecoder.descriptive(TeslaVinDecoder.decode(model3Fremont)), + ) + assertEquals( + "Tesla · Model Y · Dual Motor · 2022 · Shanghai, China", + TeslaVinDecoder.descriptive(TeslaVinDecoder.decode(modelYShanghai)), + ) + } + + @Test + fun detailLineComposes() { + val d = TeslaVinDecoder.decode(model3Fremont) + assertEquals("Sedan LHD · Li-Ion · Dual Motor", TeslaVinDecoder.detailLine(d)) + } + + @Test + fun supportsFauxAdvertNameMatching() { + // The modern advert is "Tesla " + last6 of the VIN; the derived names + // helper must accept that as the derived name for the same VIN. + val vin = model3Fremont + val derived = com.softwiredtech.dashpilot.ble.TeslaVehicleScanner.derivedNames(vin) + assertTrue("Tesla " + vin.takeLast(6) in derived) + assertTrue(derived.any { it.startsWith("S") && it.length == 18 }) + } + + @Test + fun matchesModernAdvertTailsFromFourToSixCharacters() { + val vin = model3Fremont + assertTrue(com.softwiredtech.dashpilot.ble.TeslaVehicleScanner.matchesVin("Tesla " + vin.takeLast(4), vin)) + assertTrue(com.softwiredtech.dashpilot.ble.TeslaVehicleScanner.matchesVin("Tesla " + vin.takeLast(5), vin)) + assertTrue(com.softwiredtech.dashpilot.ble.TeslaVehicleScanner.matchesVin("Tesla " + vin.takeLast(6), vin)) + assertFalse(com.softwiredtech.dashpilot.ble.TeslaVehicleScanner.matchesVin("Tesla 999999", vin)) + } + + @Test + fun matchesLegacyHashCaseInsensitivelyAndIgnoresRole() { + val vin = model3Fremont + val legacy = com.softwiredtech.dashpilot.ble.TeslaVehicleScanner.derivedNames(vin) + .first { it.startsWith("S") } + assertTrue(com.softwiredtech.dashpilot.ble.TeslaVehicleScanner.matchesVin(legacy, vin)) + assertTrue(com.softwiredtech.dashpilot.ble.TeslaVehicleScanner.matchesVin(legacy.dropLast(1).uppercase() + "R", vin)) + assertFalse(com.softwiredtech.dashpilot.ble.TeslaVehicleScanner.matchesVin(legacy, modelYShanghai)) + } + + @Test + fun checkDigitAcceptsSolvedAndXCaseVins() { + // Check digit solved from the ISO 3779 table for this prefix/serial. + assertTrue(TeslaVinDecoder.checkDigitValid("5YJ3E1EA8KF000001")) + // Sum mod 11 == 10 -> the check character is 'X'. + assertTrue(TeslaVinDecoder.checkDigitValid("5YJ3E1EAXKF000002")) + // Input is normalised before validation. + assertTrue(TeslaVinDecoder.checkDigitValid("5yj3e1ea8kf000001")) + // Classic sanity vector: all ones passes. + assertTrue(TeslaVinDecoder.checkDigitValid("11111111111111111")) + } + + @Test + fun checkDigitRejectsWrongDigitMalformedAndFictionalFixtures() { + // Same frame with one wrong position-9 character. + assertFalse(TeslaVinDecoder.checkDigitValid("5YJ3E1EA7KF000001")) + assertFalse(TeslaVinDecoder.checkDigitValid("")) + // The fictional test fixtures above carry arbitrary serials and do NOT + // satisfy the check digit — proof that passing decode != verified VIN. + assertFalse(TeslaVinDecoder.checkDigitValid(model3Fremont)) + assertFalse(TeslaVinDecoder.checkDigitValid(modelYShanghai)) + } +}