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 0000000..766c5e1 --- /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.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. + */ +object TeslaClient { + + private const val TAG = "TeslaClient" + + // App-channel service + characteristics (CADA02xx) + val SERVICE_UUID = UUID.fromString("CADA0200-CA00-B1E0-B0D6-C000AA0100A1") + val COMMAND_CHAR_UUID = UUID.fromString("CADA0201-CA00-B1E0-B0D6-C000AA0100A1") + val STATUS_CHAR_UUID = UUID.fromString("CADA0202-CA00-B1E0-B0D6-C000AA0100A1") + val CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + + // Command opcodes (match TESLA_CMD_* in main/ble/ble_appchan.h) + const val CMD_START: Int = 0x01 + const val CMD_RESET: Int = 0x02 + const val CMD_CANCEL: Int = 0x03 + const val CMD_PROVISION: Int = 0x04 + + const val VIN_LEN: Int = 17 + + private val MAC_REGEX = Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}") + + /** Ask the firmware to begin enrollment for the staged car. */ + fun sendStart(manager: DashKitBleManager): Boolean = sendTesla(manager, CMD_START) + + /** Factory-reset the Tesla key (erase -> the next sighting re-stages). */ + fun sendReset(manager: DashKitBleManager): Boolean = sendTesla(manager, CMD_RESET) + + /** Cancel an open pairing window. */ + fun sendCancel(manager: DashKitBleManager): Boolean = sendTesla(manager, CMD_CANCEL) + + // A successful write only dispatches provisioning; Staged acknowledges it. + fun sendProvision(manager: DashKitBleManager, vin: String, mac: String): Boolean { + val payload = buildProvisionPayload(vin, mac) ?: run { + Log.w(TAG, "provision: rejecting invalid vehicle identity or address") + return false + } + return manager.writeCommand(SERVICE_UUID, COMMAND_CHAR_UUID, payload, TAG) + } + + internal fun isValidVin(vin: String): Boolean = + vin.length == VIN_LEN && vin.all { it in '0'..'9' || (it in 'A'..'Z' && it !in "IOQ") } + + internal fun isValidMac(mac: String): Boolean = MAC_REGEX.matches(mac) + + internal fun buildProvisionPayload(vin: String, mac: String): ByteArray? { + val v = vin.trim().uppercase() + if (!isValidVin(v)) return null + val m = mac.trim() + if (!isValidMac(m)) return null + val payload = ByteArray(1 + VIN_LEN + 7) + payload[0] = CMD_PROVISION.toByte() + v.toByteArray(Charsets.US_ASCII).copyInto(payload, destinationOffset = 1) + payload[18] = 0x00 // BLE_ADDR_PUBLIC + // NimBLE stores the MAC octets in reverse display order. + m.split(":") + .map { it.toInt(16).toByte() } + .reversed() + .toByteArray() + .copyInto(payload, destinationOffset = 19) + return payload + } + + /** Write an app-channel command to the DashKit (same [opcode][value_lo][value_hi] + * framing as [VehicleControl.send], but on the app-channel 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 manager.writeCommand(SERVICE_UUID, COMMAND_CHAR_UUID, payload, TAG) + } +} 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 0000000..bd89bc4 --- /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 0000000..fe04bb2 --- /dev/null +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaStatusSource.kt @@ -0,0 +1,124 @@ +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 val _resetPending = MutableStateFlow(false) + val resetPending: StateFlow = _resetPending.asStateFlow() + + /** Register with the manager so we receive onServicesReady + notifications. */ + fun start() { + manager.addGattListener(this) + val g = manager.gatt + if (g != null) setupStatusSubscription(g) + } + + /** Unregister from the manager. */ + fun stop() { + manager.removeGattListener(this) + } + + fun requestReset(): Boolean { + _resetPending.value = true + if (TeslaClient.sendReset(manager)) return true + _resetPending.value = false + return false + } + + override fun onServicesReady(gatt: BluetoothGatt) { + setupStatusSubscription(gatt) + } + + private fun setupStatusSubscription(gatt: BluetoothGatt) { + val service = gatt.getService(TeslaClient.SERVICE_UUID) + val characteristic = service?.getCharacteristic(TeslaClient.STATUS_CHAR_UUID) + if (characteristic == null) { + Log.e(TAG, "App-channel/status characteristic not found; firmware may be older") + return + } + 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). + val characteristic = descriptor.characteristic ?: return + if (characteristic.uuid == TeslaClient.STATUS_CHAR_UUID && + status == BluetoothGatt.GATT_SUCCESS + ) { + gatt.readCharacteristic(characteristic) + } + } + + override fun onCharacteristicRead( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + status: Int + ) { + if (status != BluetoothGatt.GATT_SUCCESS || + characteristic.uuid != TeslaClient.STATUS_CHAR_UUID + ) return + consume(value) + } + + override fun onCharacteristicChanged( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray + ) { + if (characteristic.uuid != TeslaClient.STATUS_CHAR_UUID) return + consume(value) + } + + private fun consume(value: ByteArray) { + val parsed = TeslaStatus.parse(value) ?: return + if (parsed.linkState == TeslaLinkState.NeverEnrolled || + parsed.linkState == TeslaLinkState.Staged + ) { + _resetPending.value = false + } + if (parsed != _status.value) { + Log.d(TAG, "Tesla status: $parsed") + _status.value = parsed + } + } + + override fun onDisconnected() { + // Keep the last value so the UI can still render "enrolled, not + // connected" while the manager auto-reconnects; a re-subscribe happens + // via onServicesReady after the next connect. + } +} 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 0000000..f08094e --- /dev/null +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/TeslaVehicleScanner.kt @@ -0,0 +1,99 @@ +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.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 + +@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" + + private const val ROLE_SUFFIXES = "CRDP" + private const val MIN_TAIL_LEN = 4 + private const val MAX_TAIL_LEN = 6 + + 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' + + 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)) { + return advertisedName.dropLast(1).equals(legacyNamePrefix(upper), ignoreCase = true) + } + return false + } + + private fun legacyNamePrefix(vin: String): String { + val hash = MessageDigest.getInstance("SHA-1") + .digest(vin.toByteArray(Charsets.US_ASCII)) + .joinToString("") { "%02x".format(it.toInt() and 0xFF) } + return "S${hash.take(16)}" + } + + private 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 ROLE_SUFFIXES + } + + private fun isModernTeslaName(name: String): Boolean { + val tail = if (name.startsWith("Tesla ")) name.removePrefix("Tesla ") else return false + if (tail.length !in MIN_TAIL_LEN..MAX_TAIL_LEN) return false + return tail.all(::isVinChar) + } + + } + + fun scan(vin: String): 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 (matchesVin(advertised, vin)) trySend(result.device) + } + + 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) } } + } +} diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/VehicleControl.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/VehicleControl.kt index 7296ccd..56a58e7 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/VehicleControl.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/VehicleControl.kt @@ -1,9 +1,5 @@ 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 @@ -15,7 +11,6 @@ import java.util.UUID * [opcode][value_lo][value_hi] packet and bit-packs the matching Tesla DBC * signal into a CAN frame. Opcodes mirror `vehicle_control.h` in the firmware. */ -@SuppressLint("MissingPermission") object VehicleControl { private const val TAG = "VehicleControl" @@ -130,43 +125,13 @@ object VehicleControl { * link is down or the control characteristic is unavailable. */ fun send(manager: DashKitBleManager, opcode: Int, value: Int): Boolean { - val gatt = manager.gatt - if (gatt == null) { - Log.w(TAG, "No GATT connection; cannot send command 0x%02X".format(opcode)) - return false - } - val service = gatt.getService(SERVICE_UUID) - if (service == null) { - Log.w(TAG, "Control service not found") - return false - } - val controlChar = service.getCharacteristic(CONTROL_CHAR_UUID) - if (controlChar == null) { - Log.w(TAG, "Control characteristic not found; firmware may be older") - 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 control 0x%02X value=%d".format(opcode, v)) - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - gatt.writeCharacteristic( - controlChar, - payload, - BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT - ) == BluetoothGatt.GATT_SUCCESS - } else { - @Suppress("DEPRECATION") - controlChar.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT - @Suppress("DEPRECATION") - controlChar.value = payload - @Suppress("DEPRECATION") - gatt.writeCharacteristic(controlChar) - } + return manager.writeCommand(SERVICE_UUID, CONTROL_CHAR_UUID, payload, TAG) } } 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 fc5f744..54f780f 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 @@ -665,4 +665,37 @@ class DashKitBleManager(private val context: Context) { } writeCccd() } + + /** Write-with-response to a characteristic on the current connection. + * Returns true if the write was dispatched (not acknowledged). Shared by + * the CAN control and Tesla app-channel commands. */ + fun writeCommand( + serviceUuid: UUID, + charUuid: UUID, + payload: ByteArray, + logTag: String + ): Boolean { + val gatt = gatt ?: run { + Log.w(logTag, "No GATT connection; cannot write command") + return false + } + val characteristic = gatt.getService(serviceUuid)?.getCharacteristic(charUuid) ?: run { + Log.w(logTag, "Characteristic $charUuid not found; firmware may be older") + return false + } + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeCharacteristic( + characteristic, + payload, + BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + ) == BluetoothGatt.GATT_SUCCESS + } else { + @Suppress("DEPRECATION") + characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + @Suppress("DEPRECATION") + characteristic.value = payload + @Suppress("DEPRECATION") + gatt.writeCharacteristic(characteristic) + } + } } 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 0000000..a934559 --- /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/ble/TeslaClientProvisionTest.kt b/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/ble/TeslaClientProvisionTest.kt new file mode 100644 index 0000000..d535057 --- /dev/null +++ b/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/ble/TeslaClientProvisionTest.kt @@ -0,0 +1,28 @@ +package com.softwiredtech.dashpilot.ble + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class TeslaClientProvisionTest { + @Test + fun builds_provision_payload() { + val payload = TeslaClient.buildProvisionPayload( + "5YJ3E7EB1MF123456", + "AA:BB:CC:DD:EE:FF", + )!! + + assertArrayEquals( + byteArrayOf(TeslaClient.CMD_PROVISION.toByte()) + + "5YJ3E7EB1MF123456".toByteArray() + + byteArrayOf(0, 0xFF.toByte(), 0xEE.toByte(), 0xDD.toByte(), 0xCC.toByte(), 0xBB.toByte(), 0xAA.toByte()), + payload, + ) + } + + @Test + fun rejects_invalid_provision_values() { + assertNull(TeslaClient.buildProvisionPayload("5YJ3", "AA:BB:CC:DD:EE:FF")) + assertNull(TeslaClient.buildProvisionPayload("5YJ3E7EB1MF123456", "AA:BB:CC")) + } +} diff --git a/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/ble/TeslaVehicleScannerTest.kt b/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/ble/TeslaVehicleScannerTest.kt new file mode 100644 index 0000000..54a00a2 --- /dev/null +++ b/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/ble/TeslaVehicleScannerTest.kt @@ -0,0 +1,25 @@ +package com.softwiredtech.dashpilot.ble + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TeslaVehicleScannerTest { + private val vin = "5YJ3E7EB1MF123456" + + @Test + fun matches_legacy_advert_roles() { + for (role in "CRDP") { + assertTrue(TeslaVehicleScanner.matchesVin("Sb753e5f4c0736ab4$role", vin)) + } + assertFalse(TeslaVehicleScanner.matchesVin("Sb753e5f4c0736ab4X", vin)) + } + + @Test + fun matches_modern_vin_tails() { + assertTrue(TeslaVehicleScanner.matchesVin("Tesla 3456", vin)) + assertTrue(TeslaVehicleScanner.matchesVin("Tesla 23456", vin)) + assertTrue(TeslaVehicleScanner.matchesVin("Tesla 123456", vin)) + assertFalse(TeslaVehicleScanner.matchesVin("Tesla 654321", vin)) + } +}