fix: prevent beacon disconnects during provisioning #13

Merged
schwifty merged 2 commits from schwifty/fix-beacon-disconnect-root-cause into main 2026-03-22 02:57:36 +00:00
Showing only changes of commit aeab67ea64 - Show all commits

View file

@ -159,6 +159,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
private var awaitingDeviceInfoForProvisioning = false
private var skipDeviceInfoRead = false // set after disconnect during device info skip MAC read on reconnect
private var isTerminating = false // guards against re-entrant disconnect handling
private var resumeWriteAfterDisconnect = false // when true, skip queue rebuild on reconnect and resume from saved index
// Read config mode
private enum OperationMode { case provisioning, readingConfig }
@ -182,7 +183,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
private var disconnectRetryCount = 0
private static let MAX_CONNECTION_RETRIES = 3
private static let MAX_DEVICE_INFO_RETRIES = 2
private static let MAX_DISCONNECT_RETRIES = 3
private static let MAX_DISCONNECT_RETRIES = 5
private var currentBeacon: DiscoveredBeacon?
// Per-write timeout (matches Android's 5-second per-write timeout)
@ -230,6 +231,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
self.awaitingDeviceInfoForProvisioning = false
self.skipDeviceInfoRead = false
self.isTerminating = false
self.resumeWriteAfterDisconnect = false
self.connectionRetryCount = 0
self.deviceInfoRetryCount = 0
self.disconnectRetryCount = 0
@ -241,8 +243,8 @@ class BeaconProvisioner: NSObject, ObservableObject {
centralManager.connect(resolvedPeripheral, options: nil)
// Timeout after 45 seconds (increased from 30s to accommodate 3 disconnect retries with backoff)
DispatchQueue.main.asyncAfter(deadline: .now() + 45) { [weak self] in
// Timeout after 90 seconds (increased to accommodate 5 disconnect retries with resume)
DispatchQueue.main.asyncAfter(deadline: .now() + 90) { [weak self] in
if self?.state != .success && self?.state != .idle {
self?.fail("Connection timeout", code: .connectionTimeout)
}
@ -320,6 +322,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
awaitingDeviceInfoForProvisioning = false
skipDeviceInfoRead = false
isTerminating = false
resumeWriteAfterDisconnect = false
connectionRetryCount = 0
deviceInfoRetryCount = 0
disconnectRetryCount = 0
@ -600,7 +603,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
// Non-fatal commands (first 6) skip and continue
DebugLog.shared.log("BLE: Write timeout for non-fatal command \(current)/\(total) — skipping")
self.dxSmartWriteIndex += 1
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
self?.dxSmartSendNextCommand()
}
} else {
@ -1121,20 +1124,31 @@ extension BeaconProvisioner: CBCentralManagerDelegate {
let isActivePhase = (state == .discoveringServices || state == .authenticating || state == .writing || state == .verifying)
if isActivePhase && disconnectRetryCount < BeaconProvisioner.MAX_DISCONNECT_RETRIES {
disconnectRetryCount += 1
DebugLog.shared.log("BLE: Disconnect during \(state) — reconnecting (attempt \(disconnectRetryCount)/\(BeaconProvisioner.MAX_DISCONNECT_RETRIES))")
let wasWriting = (state == .writing && !dxSmartCommandQueue.isEmpty)
DebugLog.shared.log("BLE: Disconnect during \(state) — reconnecting (attempt \(disconnectRetryCount)/\(BeaconProvisioner.MAX_DISCONNECT_RETRIES)) wasWriting=\(wasWriting) writeIdx=\(dxSmartWriteIndex)")
progress = "Beacon disconnected, reconnecting (\(disconnectRetryCount)/\(BeaconProvisioner.MAX_DISCONNECT_RETRIES))..."
// Reset connection state for clean reconnect
// Reset connection-level state, but PRESERVE command queue and write index
// so we can resume from where we left off instead of starting over
dxSmartAuthenticated = false
dxSmartNotifySubscribed = false
dxSmartCommandQueue.removeAll()
dxSmartWriteIndex = 0
passwordIndex = 0
characteristics.removeAll()
responseBuffer.removeAll()
if wasWriting {
// Resume mode: keep the command queue and write index intact
resumeWriteAfterDisconnect = true
DebugLog.shared.log("BLE: Will resume writing from command \(dxSmartWriteIndex + 1)/\(dxSmartCommandQueue.count) after reconnect")
} else {
// Full reset for non-writing phases
dxSmartCommandQueue.removeAll()
dxSmartWriteIndex = 0
resumeWriteAfterDisconnect = false
}
state = .connecting
let delay = Double(disconnectRetryCount) + 2.0 // 3s, 4s, 5s backoff give BLE time to settle
let delay = Double(disconnectRetryCount) + 2.0 // 3s, 4s, 5s, 6s, 7s backoff give BLE time to settle
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 }
@ -1310,6 +1324,16 @@ extension BeaconProvisioner: CBPeripheralDelegate {
dxSmartAuthenticated = true
if operationMode == .readingConfig {
dxSmartReadQueryAfterAuth()
} else if resumeWriteAfterDisconnect {
// Reconnected after disconnect during writing resume from saved position
resumeWriteAfterDisconnect = false
state = .writing
DebugLog.shared.log("BLE: Resuming write from command \(dxSmartWriteIndex + 1)/\(dxSmartCommandQueue.count) after reconnect")
progress = "Resuming config write..."
// Small delay to let the BLE stack settle before resuming writes
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.dxSmartSendNextCommand()
}
} else {
// Read device info first to get MAC address, then write config
dxSmartReadDeviceInfoBeforeWrite()
@ -1329,7 +1353,7 @@ extension BeaconProvisioner: CBPeripheralDelegate {
DebugLog.shared.log("BLE: Device info query sent, waiting for response...")
} else {
dxSmartWriteIndex += 1
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
self?.dxSmartSendNextCommand()
}
}