perf: port Android BLE timeout optimizations to iOS #20

Closed
koda wants to merge 1 commit from koda/faster-ble-timeouts into main
Showing only changes of commit 2ebff4879c - Show all commits

View file

@ -102,7 +102,7 @@ struct BeaconCheckResult {
/// 3. Full reconnect on FFE2 miss (CoreBluetooth caches stale GATT)
/// 4. SaveConfig write-error = success (beacon reboots immediately)
/// 5. Response gating between writes prevents MCU overload
/// 6. Adaptive delays: heavy commands (frame select/type) need 1s, others 0.5s
/// 6. Adaptive delays: heavy commands (frame select/type) need 0.4s, others 0.2s
class BeaconProvisioner: NSObject, ObservableObject {
// MARK: - Constants
@ -140,13 +140,13 @@ class BeaconProvisioner: NSObject, ObservableObject {
case frameDisable = 0xFF
}
// Timing constants (tuned from extensive testing)
private static let BASE_WRITE_DELAY: Double = 0.5 // Default delay between commands
private static let HEAVY_WRITE_DELAY: Double = 1.0 // After frame select/type commands (MCU state change)
private static let LARGE_PAYLOAD_DELAY: Double = 0.8 // After UUID/large payload writes
private static let RESPONSE_GATE_TIMEOUT: Double = 1.0 // 1s matches Android's withTimeoutOrNull(1000L)
private static let WRITE_TIMEOUT_SECONDS: Double = 5.0 // Per-write timeout (matches Android)
private static let GLOBAL_TIMEOUT: Double = 90.0 // Overall provisioning timeout
// Timing constants (aggressively tuned to match Android BLE optimizations)
private static let BASE_WRITE_DELAY: Double = 0.2 // Default delay between commands (was 0.5)
private static let HEAVY_WRITE_DELAY: Double = 0.4 // After frame select/type commands (was 1.0)
private static let LARGE_PAYLOAD_DELAY: Double = 0.3 // After UUID/large payload writes (was 0.8)
private static let RESPONSE_GATE_TIMEOUT: Double = 0.5 // Response gate timeout (was 1.0)
private static let WRITE_TIMEOUT_SECONDS: Double = 2.0 // Per-write timeout (was 5.0, matches Android)
private static let GLOBAL_TIMEOUT: Double = 30.0 // Overall provisioning timeout (was 90.0)
// Retry limits
private static let MAX_CONNECTION_RETRIES = 3
@ -284,14 +284,14 @@ class BeaconProvisioner: NSObject, ObservableObject {
centralManager.connect(resolvedPeripheral, options: nil)
// 15-second timeout for read operations
// 8-second timeout for read operations (was 15s)
let timeout = DispatchWorkItem { [weak self] in
guard let self = self, self.operationMode == .readingConfig else { return }
DebugLog.shared.log("BLE: Read timeout reached")
self.finishRead()
}
readTimeout = timeout
DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: timeout)
DispatchQueue.main.asyncAfter(deadline: .now() + 8, execute: timeout)
}
// MARK: - State Reset
@ -428,7 +428,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
passwordIndex += 1
if passwordIndex < BeaconProvisioner.DXSMART_PASSWORDS.count {
DebugLog.shared.log("BLE: Password rejected, trying next (\(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count))")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.authenticate()
}
} else if operationMode == .readingConfig {
@ -565,7 +565,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
disconnectPeripheral()
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
guard let self = self, let beacon = self.currentBeacon else { return }
guard self.state == .connecting else { return }
let resolvedPeripheral = self.resolvePeripheral(beacon)
@ -596,7 +596,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
guard let self = self else { return }
guard self.awaitingResponse else { return }
self.awaitingResponse = false
DebugLog.shared.log("BLE: No FFE1 response within 1s for command \(self.writeIndex + 1) — advancing (OK)")
DebugLog.shared.log("BLE: No FFE1 response within 0.5s for command \(self.writeIndex + 1) — advancing (OK)")
self.advanceToNextCommand()
}
responseGateTimer = timer
@ -639,7 +639,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
// Non-fatal: skip
DebugLog.shared.log("BLE: Write timeout for non-fatal command \(current)/\(total) — skipping")
self.writeIndex += 1
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.sendNextCommand()
}
} else {
@ -824,9 +824,9 @@ class BeaconProvisioner: NSObject, ObservableObject {
private func sendNextReadQuery() {
guard dxReadQueryIndex < dxReadQueries.count else {
DebugLog.shared.log("BLE: All read queries sent, waiting 2s for final responses")
DebugLog.shared.log("BLE: All read queries sent, waiting 1s for final responses")
progress = "Collecting responses..."
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
guard let self = self, self.operationMode == .readingConfig else { return }
self.finishRead()
}
@ -1081,7 +1081,7 @@ extension BeaconProvisioner: CBCentralManagerDelegate {
if connectionRetryCount < BeaconProvisioner.MAX_CONNECTION_RETRIES {
connectionRetryCount += 1
let delay = Double(connectionRetryCount)
let delay = Double(connectionRetryCount) * 0.5 // 0.5s, 1.0s, 1.5s (was 1s, 2s, 3s)
progress = "Connection failed, retrying (\(connectionRetryCount)/\(BeaconProvisioner.MAX_CONNECTION_RETRIES))..."
DebugLog.shared.log("BLE: Retrying in \(delay)s...")
@ -1170,7 +1170,7 @@ extension BeaconProvisioner: CBCentralManagerDelegate {
}
state = .connecting
let delay = Double(disconnectRetryCount) + 2.0 // 3s, 4s, 5s... backoff
let delay = Double(disconnectRetryCount) * 0.5 + 0.5 // 1.0s, 1.5s, 2.0s... backoff (was 3s, 4s, 5s)
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self = self, let beacon = self.currentBeacon else { return }
guard self.state == .connecting else { return }
@ -1307,7 +1307,7 @@ extension BeaconProvisioner: CBPeripheralDelegate {
if operationMode == .readingConfig {
DebugLog.shared.log("BLE: Read query failed, skipping")
dxReadQueryIndex += 1
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.sendNextReadQuery()
}
} else {
@ -1321,7 +1321,7 @@ extension BeaconProvisioner: CBPeripheralDelegate {
} else if isNonFatal {
DebugLog.shared.log("BLE: Non-fatal command failed at step \(writeIndex + 1), continuing...")
writeIndex += 1
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.sendNextCommand()
}
} else {
@ -1353,8 +1353,8 @@ extension BeaconProvisioner: CBPeripheralDelegate {
state = .writing
DebugLog.shared.log("BLE: Resuming write from command \(writeIndex + 1)/\(commandQueue.count)")
progress = "Resuming config write..."
// 1.5s delay after reconnect for BLE stability
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
// 0.5s delay after reconnect for BLE stability (was 1.5s)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.sendNextCommand()
}
} else {
@ -1369,7 +1369,7 @@ extension BeaconProvisioner: CBPeripheralDelegate {
if characteristic.uuid == BeaconProvisioner.DXSMART_COMMAND_CHAR {
if operationMode == .readingConfig {
dxReadQueryIndex += 1
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.sendNextReadQuery()
}
} else {