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..f5c1ddc0 --- /dev/null +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaClient.kt @@ -0,0 +1,127 @@ +package com.softwiredtech.dashpilot.ble + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import android.os.Build +import android.util.Log +import com.softwiredtech.dashpilot.datasource.DashKitBleManager +import java.util.UUID + +/** + * Sends DashKit *app-channel* commands over the Tesla service (CADA0200) — the + * phone-side half of the Phase 4 pairing/reset UX. + * + * This is intentionally separate from [VehicleControl] (which is hardcoded to + * the CAN control characteristic CADA0004): the app-channel writes go to + * CADA0201 with the same 3-byte [opcode][value_lo][value_hi] framing, and carry + * only pairing lifecycle opcodes: + * CMD_START (0x01) - begin enrollment (the ONLY trigger, app-triggered-only) + * CMD_RESET (0x02) - factory-reset the Tesla key (erase -> re-stage) + * CMD_CANCEL(0x03) - cancel an open pairing window + * + * Enrollment is app-triggered only: the firmware never starts pairing on its + * own, because the DashKit sits in the car trim and its LEDs aren't visible. + */ +@SuppressLint("MissingPermission") +object TeslaClient { + + private const val TAG = "TeslaClient" + + // App-channel service + characteristics (CADA02xx) + val SERVICE_UUID = UUID.fromString("CADA0200-CA00-B1E0-B0D6-C000AA0100A1") + val COMMAND_CHAR_UUID = UUID.fromString("CADA0201-CA00-B1E0-B0D6-C000AA0100A1") + val STATUS_CHAR_UUID = UUID.fromString("CADA0202-CA00-B1E0-B0D6-C000AA0100A1") + val CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + + // Command opcodes (match TESLA_CMD_* in main/ble/ble_appchan.h) + const val CMD_START: Int = 0x01 + const val CMD_RESET: Int = 0x02 + const val CMD_CANCEL: Int = 0x03 + const val CMD_PROVISION: Int = 0x04 + + /** VIN length per ISO 3779 (also enforced by the firmware). */ + const val VIN_LEN: Int = 17 + + /** Ask the firmware to begin enrollment for the staged car. */ + fun sendStart(manager: DashKitBleManager): Boolean = sendTesla(manager, CMD_START) + + /** Factory-reset the Tesla key (erase -> the next sighting re-stages). */ + fun sendReset(manager: DashKitBleManager): Boolean = sendTesla(manager, CMD_RESET) + + /** Cancel an open pairing window. */ + fun sendCancel(manager: DashKitBleManager): Boolean = sendTesla(manager, CMD_CANCEL) + + /** + * Stage the car the phone discovered (firmware TESLA_CMD_PROVISION). + * + * Payload: [opcode][17-byte VIN][address type][6 MAC bytes]. [mac] is the + * advertised address as a human-readable string ("AA:BB:CC:DD:EE:FF"); it + * is sent in NimBLE's raw ble_addr_t.val byte order — val[0] is the LAST + * pair of the string, val[5] the first — which is what the firmware stores + * and connects with. The address type is public (0x00), matching how + * Tesla vehicles advertise. + */ + fun sendProvision(manager: DashKitBleManager, vin: String, mac: String): Boolean { + val v = vin.trim().uppercase() + if (v.length != VIN_LEN) { + Log.w(TAG, "provision: bad VIN length ${v.length}") + return false + } + val octets = mac.split(":").mapNotNull { it.trim().toIntOrNull(16)?.toByte() } + if (octets.size != 6) { + Log.w(TAG, "provision: bad MAC \"$mac\"") + return false + } + val payload = ByteArray(1 + VIN_LEN + 7) + payload[0] = CMD_PROVISION.toByte() + v.toByteArray(Charsets.US_ASCII).copyInto(payload, destinationOffset = 1) + payload[18] = 0x00 // BLE_ADDR_PUBLIC + for (i in 0..5) payload[19 + i] = octets[5 - i] // reversed -> NimBLE order + Log.d(TAG, "Sending provision for VIN $v") + return writePayload(manager, payload) + } + + /** Write an app-channel command to the DashKit (same [opcode][value_lo][value_hi] + * framing and TIRAMISU branch as [VehicleControl.send], but on the new UUIDs). + * Returns true if dispatched (not acknowledged). + */ + fun sendTesla(manager: DashKitBleManager, opcode: Int): Boolean { + val payload = byteArrayOf( + (opcode and 0xFF).toByte(), + 0, + 0 + ) + Log.d(TAG, "Sending app-channel 0x%02X".format(opcode)) + return writePayload(manager, payload) + } + + private fun writePayload(manager: DashKitBleManager, payload: ByteArray): Boolean { + val gatt = manager.gatt ?: run { + Log.w(TAG, "No GATT connection; cannot send app-channel write") + return false + } + val service = gatt.getService(SERVICE_UUID) ?: run { + Log.w(TAG, "App-channel service not found; firmware may be older") + return false + } + val commandChar = service.getCharacteristic(COMMAND_CHAR_UUID) ?: run { + Log.w(TAG, "App-channel command characteristic not found") + return false + } + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeCharacteristic( + commandChar, + payload, + BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + ) == BluetoothGatt.GATT_SUCCESS + } else { + @Suppress("DEPRECATION") + commandChar.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + @Suppress("DEPRECATION") + commandChar.value = payload + @Suppress("DEPRECATION") + gatt.writeCharacteristic(commandChar) + } + } +} 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..bd89bc40 --- /dev/null +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaStatus.kt @@ -0,0 +1,81 @@ +package com.softwiredtech.dashpilot.ble + +/** + * Parsed Tesla status indication from the DashKit app-channel service (CADA0202). + * + * The DashKit is installed in the car trim (LEDs not visible), so this frame is + * the phone's only view of the car / enrollment state. See + * docs/tesla-ble-app-ux-handoff.md §5 (status frame). + */ +enum class TeslaLinkState(val raw: Int) { + NeverEnrolled(0x00), + EnrolledNotConnected(0x01), + EnrolledConnected(0x02), + PairingWindow(0x03), + EnrollmentFault(0x04), + Staged(0x05), // car found, awaiting app start + Connecting(0x06), // app start accepted; contacting the car (pre-tap window) + Unknown(0xFF); + + val hasKey: Boolean + get() = this == EnrolledNotConnected || this == EnrolledConnected + + val connected: Boolean + get() = this == EnrolledConnected + + companion object { + fun from(raw: Int): TeslaLinkState = entries.firstOrNull { it.raw == raw } ?: Unknown + } +} + +enum class TeslaFaultDetail(val raw: Int) { + TapTimeout(0x00), + Rejected(0x01), + Protocol(0x02), + Persist(0x03), + None(0xFF); + + companion object { + fun from(raw: Int): TeslaFaultDetail = entries.firstOrNull { it.raw == raw } ?: None + } +} + +data class TeslaStatus( + val version: Int, + val linkState: TeslaLinkState, + val presence: Int, + val lock: Int, + val sleep: Int, + val flags: Int, + val faultDetail: TeslaFaultDetail, +) { + companion object { + /** Stable value before any frame arrives. */ + val Idle = TeslaStatus(0x01, TeslaLinkState.Unknown, 0xFF, 0xFF, 0xFF, 0, TeslaFaultDetail.None) + + /** + * Parse the 7-byte status frame: + * [0] frame version = 0x01 + * [1] link_state + * [2] presence 0 absent, 1 present, 0xFF unknown + * [3] lock 0 unlocked, 1 locked, 0xFF unknown + * [4] sleep 0 awake, 1 asleep, 0xFF unknown + * [5] flags bit0 charge-connected, bit1 charging, bit2 climate-on + * [6] fault detail (0xFF unless link_state == enrollment fault) + */ + fun parse(payload: ByteArray): TeslaStatus? { + if (payload.size < 7) return null + val b = { i: Int -> payload[i].toInt() and 0xFF } + if (b(0) != 0x01) return null + return TeslaStatus( + version = b(0), + linkState = TeslaLinkState.from(b(1)), + presence = b(2), + lock = b(3), + sleep = b(4), + flags = b(5), + faultDetail = TeslaFaultDetail.from(b(6)), + ) + } + } +} 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..c362b1bf --- /dev/null +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaStatusSource.kt @@ -0,0 +1,113 @@ +package com.softwiredtech.dashpilot.ble + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.util.Log +import com.softwiredtech.dashpilot.datasource.DashKitBleManager +import com.softwiredtech.dashpilot.datasource.GattListener +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Subscribes to the DashKit Tesla app-channel status indication (CADA0202) and + * exposes the last-parsed [TeslaStatus] on a [StateFlow]. Mirrors + * [DashKitOtaUpdate]'s listener pattern. + * + * The connection is shared with the CAN data source via [DashKitBleManager]; this + * source only adds a second characteristic subscription and is removed when the + * manager is torn down. + */ +@SuppressLint("MissingPermission") +class TeslaStatusSource(private val manager: DashKitBleManager) : GattListener { + + companion object { + private const val TAG = "TeslaStatusSource" + } + + private val _status = MutableStateFlow(TeslaStatus.Idle) + val status: StateFlow = _status.asStateFlow() + + private var statusChar: BluetoothGattCharacteristic? = null + + /** Register with the manager so we receive onServicesReady + notifications. */ + fun start() { + manager.addGattListener(this) + val g = manager.gatt + if (g != null) setupStatusSubscription(g) + } + + /** Unregister from the manager. */ + fun stop() { + manager.removeGattListener(this) + } + + override fun onServicesReady(gatt: BluetoothGatt) { + setupStatusSubscription(gatt) + } + + private fun setupStatusSubscription(gatt: BluetoothGatt) { + val service = gatt.getService(TeslaClient.SERVICE_UUID) + val characteristic = service?.getCharacteristic(TeslaClient.STATUS_CHAR_UUID) + if (characteristic == null) { + Log.e(TAG, "App-channel/status characteristic not found; firmware may be older") + return + } + statusChar = characteristic + gatt.setCharacteristicNotification(characteristic, true) + manager.subscribeWithRetry(gatt, characteristic, TAG, "Status") + } + + override fun onDescriptorWrite( + gatt: BluetoothGatt, + descriptor: BluetoothGattDescriptor, + status: Int + ) { + // After subscribing, read the current value so we don't wait for the + // next change notification (e.g. during a long reconnect backoff). + if (descriptor.characteristic?.uuid == TeslaClient.STATUS_CHAR_UUID && + status == BluetoothGatt.GATT_SUCCESS + ) { + val c = statusChar ?: return + @Suppress("DEPRECATION") + gatt.readCharacteristic(c) + } + } + + override fun onCharacteristicRead( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + status: Int + ) { + if (status != BluetoothGatt.GATT_SUCCESS || + characteristic.uuid != TeslaClient.STATUS_CHAR_UUID + ) return + consume(value) + } + + override fun onCharacteristicChanged( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray + ) { + if (characteristic.uuid != TeslaClient.STATUS_CHAR_UUID) return + consume(value) + } + + private fun consume(value: ByteArray) { + val parsed = TeslaStatus.parse(value) ?: return + if (parsed != _status.value) { + Log.d(TAG, "Tesla status: $parsed") + _status.value = parsed + } + } + + override fun onDisconnected() { + // Keep the last value so the UI can still render "enrolled, not + // connected" while the manager auto-reconnects; a re-subscribe happens + // via onServicesReady after the next connect. + } +} 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/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( 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..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 @@ -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,13 @@ 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.tesla.rememberTeslaCarsDetected 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 +72,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 +95,8 @@ fun HomeScreen( bleManager = bleManager, connectionStatus = connectionStatus, pinnedControlId = pinnedControlId, + teslaStatus = teslaStatus, + onEnrollTesla = onEnrollTesla, onConnect = onConnect, onDisconnect = onDisconnect, onSelectDataSource = { selectedDataSource = it }, @@ -115,6 +125,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 +139,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 +213,9 @@ private fun ConnectedHomeContent( Spacer(modifier = Modifier.height(32.dp)) + val carsDetected = rememberTeslaCarsDetected(teslaStatus) + TeslaTile(status = tesla, onEnroll = onEnrollTesla, carsDetected = carsDetected) + 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..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 @@ -66,7 +66,14 @@ 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.rememberTeslaCarsDetected +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 +126,8 @@ fun SettingsScreen( onDisplaySettingsChanged: (DisplaySettings) -> Unit = {}, bleManager: DashKitBleManager? = null, dashkitUpdateManager: FirmwareUpdateManager? = null, + teslaStatus: StateFlow? = null, + onEnrollTesla: () -> Unit = {}, onReplayOnboarding: () -> Unit = {}, onThemeClick: () -> Unit = {} ) { @@ -454,7 +463,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 +546,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 +594,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 +618,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 +706,96 @@ 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 carsDetected = rememberTeslaCarsDetected(teslaStatus) + 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)) + 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 + // once connected (removal erases the link, so it must not persist after). + val hasKey = status.linkState.hasKey + Button( + onClick = if (hasKey) ({ showResetDialog.value = true }) else onEnrollTesla, + enabled = connected, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = if (hasKey) DarkColors.SurfaceSelected else AccentColor, + 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 + ) + } + + 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.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) } + TeslaLinkState.PairingWindow -> stringResource(R.string.tesla_status_pairing) + TeslaLinkState.EnrollmentFault -> stringResource(R.string.tesla_status_fault) + 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..be874c95 --- /dev/null +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaEnrollFlow.kt @@ -0,0 +1,723 @@ +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 +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.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 +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 +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 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( + manager: DashKitBleManager?, + statusFlow: StateFlow?, + onClose: () -> Unit, +) { + val idleFlow = remember { MutableStateFlow(TeslaStatus.Idle) } + 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 -> 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( + 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 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_vin_help), + hero = { DevicePuck(state = PairingState.Searching, accent = TeslaCyan) }, + extra = { + 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 = { + 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(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 = { + Column(Modifier.fillMaxWidth()) { + CarIdentityLine(identityVin) + 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 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) { + 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 = { + 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()) { + Text(stringResource(R.string.tesla_enroll_cancel), color = OnboardingColors.TextSecondary) + } + }, + ) +} + +@Composable +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 = { + 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, 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.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) + TextButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.tesla_enroll_cancel), color = OnboardingColors.TextSecondary) + } + } + }, + ) +} + +/** + * 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, + 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 · 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 && + status.linkState != TeslaLinkState.EnrolledNotConnected + ) { + return "" + } + val parts = buildList { + 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 new file mode 100644 index 00000000..f10fa23c --- /dev/null +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaTile.kt @@ -0,0 +1,178 @@ +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 +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.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 +import androidx.compose.ui.text.style.TextOverflow +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.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 + * "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, carsDetected: Boolean = false) { + val tapEnabled = status.linkState == TeslaLinkState.Staged || + status.linkState == TeslaLinkState.NeverEnrolled || + status.linkState == TeslaLinkState.EnrollmentFault + + 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) } + 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 + } + + 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.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, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.End, + modifier = Modifier.weight(1f), + ) + if (tapEnabled) { + Spacer(Modifier.width(16.dp)) + Text(text = "›", color = DarkColors.TextMuted, fontSize = 20.sp) + } + } + 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/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..a752f1c5 100644 --- a/dashpilot-android/app/src/main/res/values/strings.xml +++ b/dashpilot-android/app/src/main/res/values/strings.xml @@ -112,4 +112,77 @@ Check for firmware updates Replay onboarding + + + Tesla + + Not connected + Car seen nearby — tap to connect + 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 + + + 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. + 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 + Done + Connection failed + Try again + Cancel + 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. + Something went wrong. Try again. + + + Tesla Connection + Read status and control charging via BLE. + Status + 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 + Tesla connection removed. Reconnect from this screen when ready. + Couldn\'t remove the connection. Try again. + Pairs another phone — separate from your Tesla connection. 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..a934559d --- /dev/null +++ b/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/TeslaStatusTest.kt @@ -0,0 +1,75 @@ +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 `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()) + 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) + } +} 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)) + } +}