From cf65a54c7b28784abc3242f418bac3b4ede419fa Mon Sep 17 00:00:00 2001 From: Ahmed Harmouche Date: Wed, 16 Sep 2026 09:56:21 +0200 Subject: [PATCH] [android][ios] Fix disconnection mid-OTA update --- .../dashpilot/ble/DashKitOtaUpdate.kt | 64 ++++++++++++++----- .../dashpilot/ble/VehicleControl.kt | 13 ++-- .../dashpilot/datasource/DashKitBleManager.kt | 4 -- .../dashpilot/BLE/DashKitOtaUpdate.swift | 53 +++++++++++++-- .../dashpilot/BLE/FirmwareUpdateManager.swift | 20 +++++- .../dashpilot/BLE/VehicleControl.swift | 11 ++-- .../DataSource/DashKitBleManager.swift | 3 - .../dashpilot/UI/DashKitSettingsView.swift | 19 ++---- dashpilot-ios/dashpilot/UI/SettingsView.swift | 5 +- .../ViewModel/ConnectionViewModel.swift | 5 ++ 10 files changed, 140 insertions(+), 57 deletions(-) diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/DashKitOtaUpdate.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/DashKitOtaUpdate.kt index 3a24c8d9..709055b1 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/DashKitOtaUpdate.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/DashKitOtaUpdate.kt @@ -33,6 +33,7 @@ class DashKitOtaUpdate( private val OTA_DATA_UUID = UUID.fromString("CADA0102-CA00-B1E0-B0D6-C000AA0100A1") private val OTA_STATUS_UUID = UUID.fromString("CADA0103-CA00-B1E0-B0D6-C000AA0100A1") private val CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + private const val PING_INTERVAL_MS = 15_000L } private val _state = MutableStateFlow(OtaState.Idle) @@ -43,6 +44,11 @@ class DashKitOtaUpdate( private var ctrlChar: BluetoothGattCharacteristic? = null private var dataChar: BluetoothGattCharacteristic? = null private var statusChar: BluetoothGattCharacteristic? = null + private var controlChar: BluetoothGattCharacteristic? = null + private var lastPingAt = 0L + private var pingInFlight = false + @Volatile + private var uploadFinished = false fun start(fw: ByteArray) { if (fw.isEmpty()) { @@ -51,6 +57,7 @@ class DashKitOtaUpdate( } firmware = fw firmwareOffset = 0 + uploadFinished = false manager.suppressPings = true manager.addGattListener(this) @@ -72,6 +79,9 @@ class DashKitOtaUpdate( ctrlChar = null dataChar = null statusChar = null + controlChar = null + pingInFlight = false + uploadFinished = false _state.value = OtaState.Idle } @@ -100,6 +110,8 @@ class DashKitOtaUpdate( _state.value = OtaState.Error("OTA characteristics not found") return } + controlChar = g.getService(VehicleControl.SERVICE_UUID) + ?.getCharacteristic(VehicleControl.CONTROL_CHAR_UUID) g.setCharacteristicNotification(statusChar, true) val descriptor = statusChar!!.getDescriptor(CCCD_UUID) @@ -140,11 +152,20 @@ class DashKitOtaUpdate( characteristic: BluetoothGattCharacteristic, status: Int ) { - if (characteristic.uuid != OTA_CTRL_UUID && characteristic.uuid != OTA_DATA_UUID) return + if (characteristic.uuid == VehicleControl.CONTROL_CHAR_UUID) { + if (!pingInFlight) return + pingInFlight = false + } else if (characteristic.uuid != OTA_CTRL_UUID && characteristic.uuid != OTA_DATA_UUID) { + return + } if (status != BluetoothGatt.GATT_SUCCESS) { _state.value = OtaState.Error("Write failed (status $status)") return } + val fw = firmware + if (characteristic.uuid == OTA_DATA_UUID && fw != null && firmwareOffset >= fw.size) { + uploadFinished = true + } sendNextChunk(gatt) } @@ -152,7 +173,10 @@ class DashKitOtaUpdate( val currentState = _state.value // Rebooting expects this drop; the listener stays registered so the // reconnect's onServicesReady can clear the completed state. - if (currentState !is OtaState.Rebooting && currentState !is OtaState.Idle) { + if (uploadFinished && currentState !is OtaState.Rebooting) { + Log.d(TAG, "Link dropped after the last chunk; treating as reboot") + _state.value = OtaState.Rebooting + } else if (currentState !is OtaState.Rebooting && currentState !is OtaState.Idle) { _state.value = OtaState.Error("Disconnected unexpectedly") } manager.suppressPings = false @@ -160,6 +184,8 @@ class DashKitOtaUpdate( ctrlChar = null dataChar = null statusChar = null + controlChar = null + pingInFlight = false } private fun sendBeginCommand(g: BluetoothGatt) { @@ -175,23 +201,26 @@ class DashKitOtaUpdate( Log.d(TAG, "Sending OTA Begin: ${fw.size} bytes") _state.value = OtaState.Uploading(0f) firmwareOffset = 0 + lastPingAt = 0L + pingInFlight = false val ctrl = ctrlChar ?: return - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - g.writeCharacteristic(ctrl, cmd, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) - } else { - @Suppress("DEPRECATION") - ctrl.value = cmd - ctrl.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT - @Suppress("DEPRECATION") - g.writeCharacteristic(ctrl) - } + write(g, ctrl, cmd) } private fun sendNextChunk(g: BluetoothGatt) { val fw = firmware ?: return if (firmwareOffset >= fw.size) return + val control = controlChar + val now = System.currentTimeMillis() + if (control != null && now - lastPingAt >= PING_INTERVAL_MS) { + lastPingAt = now + pingInFlight = true + write(g, control, VehicleControl.payload(VehicleControl.CMD_PING, 1)) + return + } + val chunkSize = minOf(manager.mtu - 3, fw.size - firmwareOffset) val chunk = fw.copyOfRange(firmwareOffset, firmwareOffset + chunkSize) firmwareOffset += chunkSize @@ -200,14 +229,18 @@ class DashKitOtaUpdate( _state.value = OtaState.Uploading(progress) val data = dataChar ?: return + write(g, data, chunk) + } + + private fun write(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - g.writeCharacteristic(data, chunk, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) + g.writeCharacteristic(characteristic, value, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) } else { @Suppress("DEPRECATION") - data.value = chunk - data.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + characteristic.value = value + characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT @Suppress("DEPRECATION") - g.writeCharacteristic(data) + g.writeCharacteristic(characteristic) } } @@ -235,6 +268,7 @@ class DashKitOtaUpdate( _state.value = OtaState.Error("Device reported error (0x${errCode.toString(16)})") manager.removeGattListener(this) manager.suppressPings = false + uploadFinished = false firmware = null } } 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 52a22c81..f8bb7bab 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 @@ -15,8 +15,8 @@ object VehicleControl { private const val TAG = "VehicleControl" - private val SERVICE_UUID = UUID.fromString("CADA0000-CA00-B1E0-B0D6-C000AA0100A1") - private val CONTROL_CHAR_UUID = UUID.fromString("CADA0004-CA00-B1E0-B0D6-C000AA0100A1") + val SERVICE_UUID: UUID = UUID.fromString("CADA0000-CA00-B1E0-B0D6-C000AA0100A1") + val CONTROL_CHAR_UUID: UUID = UUID.fromString("CADA0004-CA00-B1E0-B0D6-C000AA0100A1") // --- UI_vehicleControl (0x273) --- const val CMD_CLOSURE: Int = 0x02 // UI_remoteClosureRequest: 1=REAR_TRUNK 2=FRONT_TRUNK @@ -146,13 +146,16 @@ object VehicleControl { * link is down or the control characteristic is unavailable. */ fun send(manager: DashKitBleManager, opcode: Int, value: Int): Boolean { + Log.d(TAG, "Sending control 0x%02X value=%d".format(opcode, value and 0xFFFF)) + return manager.writeCommand(SERVICE_UUID, CONTROL_CHAR_UUID, payload(opcode, value), TAG) + } + + fun payload(opcode: Int, value: Int): ByteArray { val v = value and 0xFFFF - val payload = byteArrayOf( + return 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 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 1c4091b7..201236a0 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 @@ -145,10 +145,6 @@ class DashKitBleManager(private val context: Context) { // this handler; disconnect() clears it wholesale. private val handler = Handler(Looper.getMainLooper()) - // Set by the OTA uploader for the duration of a transfer: a ping racing a - // chunk write steals the single in-flight GATT slot, and the firmware - // exempts the updating phone from its keepalive cull anyway. The loop - // keeps rescheduling so pings resume as soon as the flag clears. @Volatile var suppressPings = false diff --git a/dashpilot-ios/dashpilot/BLE/DashKitOtaUpdate.swift b/dashpilot-ios/dashpilot/BLE/DashKitOtaUpdate.swift index 1b7a96bb..7384c41b 100644 --- a/dashpilot-ios/dashpilot/BLE/DashKitOtaUpdate.swift +++ b/dashpilot-ios/dashpilot/BLE/DashKitOtaUpdate.swift @@ -23,6 +23,9 @@ final class DashKitOtaUpdate: DashKitGattListener { private(set) var state: OtaState = .idle + @ObservationIgnored + var onCompleted: (() -> Void)? + private let manager: DashKitBleManager // Mutated only on the manager's BLE queue (listener callbacks) once the @@ -41,9 +44,15 @@ final class DashKitOtaUpdate: DashKitGattListener { private var canChar: CBCharacteristic? private var canWasNotifying = false + private static let pingInterval: TimeInterval = 15 + private var controlChar: CBCharacteristic? + private var lastPingAt = Date.distantPast + private var pingInFlight = false + // Set synchronously on the BLE queue when the device reports completion; // `state` is published on main so it can lag the disconnect that follows. private var rebooting = false + private var uploadFinished = false init(manager: DashKitBleManager) { self.manager = manager @@ -57,6 +66,7 @@ final class DashKitOtaUpdate: DashKitGattListener { firmware = fw firmwareOffset = 0 rebooting = false + uploadFinished = false manager.suppressPings = true setState(.connecting) // If already connected the manager replays onServicesReady right away; @@ -69,6 +79,7 @@ final class DashKitOtaUpdate: DashKitGattListener { manager.removeGattListener(self) manager.suppressPings = false rebooting = false + uploadFinished = false resumeCanNotifications() firmware = nil peripheral = nil @@ -76,6 +87,8 @@ final class DashKitOtaUpdate: DashKitGattListener { dataChar = nil statusChar = nil canChar = nil + controlChar = nil + pingInFlight = false setState(.idle) } @@ -98,6 +111,7 @@ final class DashKitOtaUpdate: DashKitGattListener { rebooting = false manager.removeGattListener(self) setState(.idle) + onCompleted?() return } guard let service = peripheral.services?.first(where: { $0.uuid == DashKitGatt.otaService }) else { @@ -112,10 +126,9 @@ final class DashKitOtaUpdate: DashKitGattListener { setState(.error("OTA characteristics not found")) return } - canChar = peripheral.services? - .first { $0.uuid == DashKitGatt.canService }? - .characteristics? - .first { $0.uuid == DashKitGatt.canCharacteristic } + let canService = peripheral.services?.first { $0.uuid == DashKitGatt.canService } + canChar = canService?.characteristics?.first { $0.uuid == DashKitGatt.canCharacteristic } + controlChar = canService?.characteristics?.first { $0.uuid == DashKitGatt.controlCharacteristic } if let canChar, canChar.isNotifying { canWasNotifying = true peripheral.setNotifyValue(false, for: canChar) @@ -141,20 +154,33 @@ final class DashKitOtaUpdate: DashKitGattListener { } func onCharacteristicWrite(_ characteristic: CBCharacteristic, error: Error?) { - guard characteristic.uuid == DashKitGatt.otaControlCharacteristic || - characteristic.uuid == DashKitGatt.otaDataCharacteristic else { return } + if characteristic.uuid == DashKitGatt.controlCharacteristic { + guard pingInFlight else { return } + pingInFlight = false + } else if characteristic.uuid != DashKitGatt.otaControlCharacteristic, + characteristic.uuid != DashKitGatt.otaDataCharacteristic { + return + } if let error { setState(.error("Write failed (\(error.localizedDescription))")) resumeCanNotifications() return } + if characteristic.uuid == DashKitGatt.otaDataCharacteristic, + let fw = firmware, firmwareOffset >= fw.count { + uploadFinished = true + } sendNextChunk() } func onDisconnected() { // Rebooting expects this drop; the listener stays registered so the // reconnect's onServicesReady can clear the completed state. - if !rebooting, state != .idle { + if uploadFinished, !rebooting { + print("[DashKitOta] link dropped after the last chunk; treating as reboot") + rebooting = true + setState(.rebooting) + } else if !rebooting, state != .idle { setState(.error("Disconnected unexpectedly")) } manager.suppressPings = false @@ -164,7 +190,9 @@ final class DashKitOtaUpdate: DashKitGattListener { dataChar = nil statusChar = nil canChar = nil + controlChar = nil canWasNotifying = false + pingInFlight = false } // MARK: - Upload @@ -182,6 +210,8 @@ final class DashKitOtaUpdate: DashKitGattListener { print("[DashKitOta] sending OTA Begin: \(size) bytes") setState(.uploading(0)) firmwareOffset = 0 + lastPingAt = .distantPast + pingInFlight = false peripheral.writeValue(cmd, for: ctrl, type: .withResponse) } @@ -189,6 +219,14 @@ final class DashKitOtaUpdate: DashKitGattListener { guard let fw = firmware, let peripheral, let data = dataChar else { return } guard firmwareOffset < fw.count else { return } + if let controlChar, Date().timeIntervalSince(lastPingAt) >= Self.pingInterval { + lastPingAt = Date() + pingInFlight = true + let ping = VehicleControl.payload(opcode: VehicleControl.cmdPing, value: 1) + peripheral.writeValue(ping, for: controlChar, type: .withResponse) + return + } + // .withoutResponse reports the true MTU-3 payload; .withResponse // reports 512, which turns each chunk into a slow ATT long write. let maxLen = peripheral.maximumWriteValueLength(for: .withoutResponse) @@ -223,6 +261,7 @@ final class DashKitOtaUpdate: DashKitGattListener { setState(.error("Device reported error (0x\(String(errCode, radix: 16)))")) manager.removeGattListener(self) manager.suppressPings = false + uploadFinished = false resumeCanNotifications() firmware = nil default: diff --git a/dashpilot-ios/dashpilot/BLE/FirmwareUpdateManager.swift b/dashpilot-ios/dashpilot/BLE/FirmwareUpdateManager.swift index a16c4fee..286da0f1 100644 --- a/dashpilot-ios/dashpilot/BLE/FirmwareUpdateManager.swift +++ b/dashpilot-ios/dashpilot/BLE/FirmwareUpdateManager.swift @@ -59,8 +59,8 @@ enum FirmwareUpdateRepository { /// 4. download the binary, /// 5. hand the bytes to the `DashKitOtaUpdate` BLE upload path. /// -/// One instance per connected `DashKitBleManager`, disposed when the settings -/// screen goes away. +/// One instance per connected `DashKitBleManager`, owned by +/// `ConnectionViewModel` for the life of the session. @Observable final class FirmwareUpdateManager { @@ -81,9 +81,19 @@ final class FirmwareUpdateManager { var otaState: OtaState { ota.state } var installedVersion: String? { versionReader.version } + private var otaActive: Bool { + switch ota.state { + case .connecting, .uploading, .rebooting: return true + case .idle, .error: return false + } + } + init(manager: DashKitBleManager) { ota = DashKitOtaUpdate(manager: manager) versionReader = DashKitFirmwareVersion(manager: manager) + ota.onCompleted = { [weak self] in + Task { @MainActor in await self?.runCheck() } + } } /// Begin reading the installed firmware version over BLE. @@ -95,6 +105,12 @@ final class FirmwareUpdateManager { /// installed version first (waiting briefly if it hasn't arrived yet). @MainActor func checkForUpdate() async { + guard !downloading, !otaActive, check != .checking else { return } + await runCheck() + } + + @MainActor + private func runCheck() async { check = .checking guard let manifest = await FirmwareUpdateRepository.fetchManifest() else { check = .error("Could not reach update server") diff --git a/dashpilot-ios/dashpilot/BLE/VehicleControl.swift b/dashpilot-ios/dashpilot/BLE/VehicleControl.swift index ec5fa1d6..29df58c6 100644 --- a/dashpilot-ios/dashpilot/BLE/VehicleControl.swift +++ b/dashpilot-ios/dashpilot/BLE/VehicleControl.swift @@ -164,14 +164,17 @@ enum VehicleControl { print("[VehicleControl] no connection; cannot send command 0x\(String(opcode, radix: 16))") return false } + print("[VehicleControl] sending control 0x\(String(opcode, radix: 16)) value=\(value & 0xFFFF)") + peripheral.writeValue(payload(opcode: opcode, value: value), for: characteristic, type: .withResponse) + return true + } + + static func payload(opcode: Int, value: Int) -> Data { let v = value & 0xFFFF - let payload = Data([ + return Data([ UInt8(opcode & 0xFF), UInt8(v & 0xFF), UInt8((v >> 8) & 0xFF) ]) - print("[VehicleControl] sending control 0x\(String(opcode, radix: 16)) value=\(v)") - peripheral.writeValue(payload, for: characteristic, type: .withResponse) - return true } } diff --git a/dashpilot-ios/dashpilot/DataSource/DashKitBleManager.swift b/dashpilot-ios/dashpilot/DataSource/DashKitBleManager.swift index ae9ef41e..cafc38b9 100644 --- a/dashpilot-ios/dashpilot/DataSource/DashKitBleManager.swift +++ b/dashpilot-ios/dashpilot/DataSource/DashKitBleManager.swift @@ -130,9 +130,6 @@ final class DashKitBleManager: NSObject { private var retryTask: DispatchWorkItem? private var pingTask: DispatchWorkItem? - // Set by the OTA uploader for the duration of a transfer; the firmware - // exempts the updating phone from its keepalive cull. The loop keeps - // rescheduling so pings resume as soon as the flag clears. var suppressPings = false /// Keepalive foreground gate. Suspension alone stops pings too late (and diff --git a/dashpilot-ios/dashpilot/UI/DashKitSettingsView.swift b/dashpilot-ios/dashpilot/UI/DashKitSettingsView.swift index 7958e93d..735f2d6b 100644 --- a/dashpilot-ios/dashpilot/UI/DashKitSettingsView.swift +++ b/dashpilot-ios/dashpilot/UI/DashKitSettingsView.swift @@ -11,8 +11,8 @@ private let errorRed = Color(red: 1, green: 0.32, blue: 0.32) struct DashKitSettingsView: View { let bleManager: DashKitBleManager + let updateManager: FirmwareUpdateManager - @State private var updateManager: FirmwareUpdateManager? @State private var showPairDialog = false @State private var showRebootDialog = false @State private var pairStatus: String? @@ -25,24 +25,13 @@ struct DashKitSettingsView: View { var body: some View { VStack(alignment: .leading, spacing: 24) { firmwareInfoSection - if let updateManager { - FirmwareUpdateSection(updateManager: updateManager) - } + FirmwareUpdateSection(updateManager: updateManager) pairingSection maintenanceSection } - .onAppear { - if updateManager == nil { - updateManager = FirmwareUpdateManager(manager: bleManager) - } - } - .onDisappear { - updateManager?.dispose() - updateManager = nil - } // On connect, read the installed version and check for updates. .task(id: connected) { - guard connected, let updateManager else { return } + guard connected else { return } updateManager.start() await updateManager.checkForUpdate() } @@ -76,7 +65,7 @@ struct DashKitSettingsView: View { VStack(alignment: .leading, spacing: 8) { SectionHeader("Firmware info") InfoRow(label: "Status", value: connected ? "Connected" : "Disconnected") - InfoRow(label: "Firmware version", value: updateManager?.installedVersion ?? "Unknown") + InfoRow(label: "Firmware version", value: updateManager.installedVersion ?? "Unknown") } } diff --git a/dashpilot-ios/dashpilot/UI/SettingsView.swift b/dashpilot-ios/dashpilot/UI/SettingsView.swift index fae10322..91f861c8 100644 --- a/dashpilot-ios/dashpilot/UI/SettingsView.swift +++ b/dashpilot-ios/dashpilot/UI/SettingsView.swift @@ -133,8 +133,9 @@ struct SettingsView: View { @ViewBuilder private var dashKitTab: some View { - if let bleManager = connectionVM.bleManager { - DashKitSettingsView(bleManager: bleManager) + if let bleManager = connectionVM.bleManager, + let updateManager = connectionVM.firmwareUpdateManager { + DashKitSettingsView(bleManager: bleManager, updateManager: updateManager) .padding(.horizontal, 24) } else { Text("Connect to a DashKit device to manage it.") diff --git a/dashpilot-ios/dashpilot/ViewModel/ConnectionViewModel.swift b/dashpilot-ios/dashpilot/ViewModel/ConnectionViewModel.swift index dacf3efb..a090131a 100644 --- a/dashpilot-ios/dashpilot/ViewModel/ConnectionViewModel.swift +++ b/dashpilot-ios/dashpilot/ViewModel/ConnectionViewModel.swift @@ -34,6 +34,8 @@ final class ConnectionViewModel { /// send control commands and the settings screen for firmware/OTA access. private(set) var bleManager: DashKitBleManager? + private(set) var firmwareUpdateManager: FirmwareUpdateManager? + /// Source of the current (or last) session; stays set after a DashKit /// error so foregrounding retries the same source, like Android. private(set) var activeSourceType: DataSourceType? @@ -128,6 +130,7 @@ final class ConnectionViewModel { let manager = DashKitBleManager() bleManager = manager + firmwareUpdateManager = FirmwareUpdateManager(manager: manager) manager.onStateChange = { [weak self] state in guard let self else { return } // Surface BLE failures while we are still waiting for the first @@ -241,6 +244,8 @@ final class ConnectionViewModel { connectTask = nil dataSource?.disconnect() dataSource = nil + firmwareUpdateManager?.dispose() + firmwareUpdateManager = nil bleManager?.onStateChange = nil bleManager?.disconnect() bleManager = nil