From df6601c50bed134297aadc2381fa6f83c303fa99 Mon Sep 17 00:00:00 2001 From: Schwifty Date: Sat, 21 Mar 2026 23:54:13 +0000 Subject: [PATCH] fix: add reconnect retry for unexpected disconnects during auth/write Instead of immediately failing on disconnect during authenticating or writing states, retry up to 2 times with backoff. Resets passwordIndex on reconnect so re-auth starts fresh (fixes issue where burned password attempts caused retry failures). Also fixes passwordIndex reset in the device-info safety-net reconnect path. Co-Authored-By: Claude Opus 4.6 (1M context) --- PayfritBeacon/BeaconProvisioner.swift | 39 +++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/PayfritBeacon/BeaconProvisioner.swift b/PayfritBeacon/BeaconProvisioner.swift index 14d333e..b0b50e9 100644 --- a/PayfritBeacon/BeaconProvisioner.swift +++ b/PayfritBeacon/BeaconProvisioner.swift @@ -179,8 +179,10 @@ class BeaconProvisioner: NSObject, ObservableObject { // Connection retry state private var connectionRetryCount = 0 private var deviceInfoRetryCount = 0 + 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 = 2 private var currentBeacon: DiscoveredBeacon? override init() { @@ -222,6 +224,7 @@ class BeaconProvisioner: NSObject, ObservableObject { self.isTerminating = false self.connectionRetryCount = 0 self.deviceInfoRetryCount = 0 + self.disconnectRetryCount = 0 self.currentBeacon = beacon state = .connecting @@ -270,6 +273,7 @@ class BeaconProvisioner: NSObject, ObservableObject { self.allDiscoveredServices.removeAll() self.connectionRetryCount = 0 self.deviceInfoRetryCount = 0 + self.disconnectRetryCount = 0 self.isTerminating = false self.currentBeacon = beacon self.servicesToExplore.removeAll() @@ -308,6 +312,7 @@ class BeaconProvisioner: NSObject, ObservableObject { isTerminating = false connectionRetryCount = 0 deviceInfoRetryCount = 0 + disconnectRetryCount = 0 currentBeacon = nil state = .idle progress = "" @@ -888,6 +893,7 @@ class BeaconProvisioner: NSObject, ObservableObject { characteristics.removeAll() connectionRetryCount = 0 deviceInfoRetryCount = 0 + disconnectRetryCount = 0 currentBeacon = nil operationMode = .provisioning state = .idle @@ -1019,6 +1025,7 @@ extension BeaconProvisioner: CBCentralManagerDelegate { dxSmartNotifySubscribed = false dxSmartCommandQueue.removeAll() dxSmartWriteIndex = 0 + passwordIndex = 0 characteristics.removeAll() responseBuffer.removeAll() state = .connecting @@ -1034,8 +1041,36 @@ extension BeaconProvisioner: CBCentralManagerDelegate { return } - // All other disconnects are unexpected - DebugLog.shared.log("BLE: UNEXPECTED disconnect — state=\(state) writeIdx=\(dxSmartWriteIndex) queueCount=\(dxSmartCommandQueue.count) authenticated=\(dxSmartAuthenticated)") + // Unexpected disconnect during auth or writing — retry with full reconnect + if (state == .authenticating || state == .writing) && disconnectRetryCount < BeaconProvisioner.MAX_DISCONNECT_RETRIES { + disconnectRetryCount += 1 + DebugLog.shared.log("BLE: Disconnect during \(state) — reconnecting (attempt \(disconnectRetryCount)/\(BeaconProvisioner.MAX_DISCONNECT_RETRIES))") + progress = "Beacon disconnected, reconnecting (\(disconnectRetryCount)/\(BeaconProvisioner.MAX_DISCONNECT_RETRIES))..." + + // Reset connection state for clean reconnect + dxSmartAuthenticated = false + dxSmartNotifySubscribed = false + dxSmartCommandQueue.removeAll() + dxSmartWriteIndex = 0 + passwordIndex = 0 + characteristics.removeAll() + responseBuffer.removeAll() + state = .connecting + + let delay = Double(disconnectRetryCount) + 1.0 // 2s, 3s backoff + 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 } + let resolvedPeripheral = self.resolvePeripheral(beacon) + self.peripheral = resolvedPeripheral + resolvedPeripheral.delegate = self + self.centralManager.connect(resolvedPeripheral, options: nil) + } + return + } + + // All retries exhausted or disconnect in unexpected state — fail + DebugLog.shared.log("BLE: UNEXPECTED disconnect — state=\(state) writeIdx=\(dxSmartWriteIndex) queueCount=\(dxSmartCommandQueue.count) authenticated=\(dxSmartAuthenticated) disconnectRetries=\(disconnectRetryCount)") fail("Unexpected disconnect (state: \(state))", code: .disconnected) } }