Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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>(OtaState.Idle)
Expand All @@ -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()) {
Expand All @@ -51,6 +57,7 @@ class DashKitOtaUpdate(
}
firmware = fw
firmwareOffset = 0
uploadFinished = false

manager.suppressPings = true
manager.addGattListener(this)
Expand All @@ -72,6 +79,9 @@ class DashKitOtaUpdate(
ctrlChar = null
dataChar = null
statusChar = null
controlChar = null
pingInFlight = false
uploadFinished = false
_state.value = OtaState.Idle
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -140,26 +152,40 @@ 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)
}

override fun onDisconnected() {
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
firmware = null
ctrlChar = null
dataChar = null
statusChar = null
controlChar = null
pingInFlight = false
}

private fun sendBeginCommand(g: BluetoothGatt) {
Expand All @@ -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
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
53 changes: 46 additions & 7 deletions dashpilot-ios/dashpilot/BLE/DashKitOtaUpdate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -69,13 +79,16 @@ final class DashKitOtaUpdate: DashKitGattListener {
manager.removeGattListener(self)
manager.suppressPings = false
rebooting = false
uploadFinished = false
resumeCanNotifications()
firmware = nil
peripheral = nil
ctrlChar = nil
dataChar = nil
statusChar = nil
canChar = nil
controlChar = nil
pingInFlight = false
setState(.idle)
}

Expand All @@ -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 {
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -164,7 +190,9 @@ final class DashKitOtaUpdate: DashKitGattListener {
dataChar = nil
statusChar = nil
canChar = nil
controlChar = nil
canWasNotifying = false
pingInFlight = false
}

// MARK: - Upload
Expand All @@ -182,13 +210,23 @@ 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)
}

private func sendNextChunk() {
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)
Expand Down Expand Up @@ -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:
Expand Down
20 changes: 18 additions & 2 deletions dashpilot-ios/dashpilot/BLE/FirmwareUpdateManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -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.
Expand All @@ -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")
Expand Down
Loading
Loading