From aeab67ea647d903dd0f0f752eceb10c592a695f1 Mon Sep 17 00:00:00 2001 From: Schwifty Date: Sun, 22 Mar 2026 02:48:42 +0000 Subject: [PATCH 1/2] fix: resume writing from saved position on BLE disconnect instead of restarting Previously, when the beacon disconnected mid-write, the reconnect handler cleared the entire command queue and reset writeIndex to 0, causing all 24 commands to be re-sent from scratch on every reconnect. This could confuse the beacon firmware with duplicate config writes and wasted reconnect cycles. Changes: - On disconnect during writing, PRESERVE command queue and write index - After reconnect + re-auth, resume from the last command instead of rebuilding - Increase MAX_DISCONNECT_RETRIES from 3 to 5 (resume is lightweight) - Increase inter-command delay from 150ms to 300ms for firmware breathing room - Increase global timeout from 45s to 90s to accommodate more retries - Add resumeWriteAfterDisconnect flag to control post-auth flow Co-Authored-By: Claude Opus 4.6 (1M context) --- PayfritBeacon/BeaconProvisioner.swift | 44 +++++++++++++++++++++------ 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/PayfritBeacon/BeaconProvisioner.swift b/PayfritBeacon/BeaconProvisioner.swift index 1a3731d..fd07e32 100644 --- a/PayfritBeacon/BeaconProvisioner.swift +++ b/PayfritBeacon/BeaconProvisioner.swift @@ -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() } } -- 2.43.0 From bfbc2a5d8c067065ce60610ad12d3ad96b17b7ca Mon Sep 17 00:00:00 2001 From: Schwifty Date: Sun, 22 Mar 2026 02:53:37 +0000 Subject: [PATCH 2/2] fix: adaptive inter-command delays to prevent BLE supervision timeouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: DX-Smart CP28 beacons were disconnecting during provisioning because the 0.3s inter-command delay was too fast for the beacon's MCU. Frame selection (0x11-0x16) and type commands (0x61, 0x62) trigger internal state changes that need processing time. Rapid writes caused the beacon to miss BLE connection events, triggering link-layer supervision timeouts. Changes: - Base delay: 0.3s → 0.5s for all commands - Heavy delay: 1.0s after frame select/type commands (MCU state change) - Large payload delay: 0.8s after UUID writes (21 bytes) - Resume delay: 0.5s → 1.5s after reconnect (let BLE stack stabilize) - Non-fatal skip delay: 0.15s → 0.5s Co-Authored-By: Claude Opus 4.6 (1M context) --- PayfritBeacon/BeaconProvisioner.swift | 55 ++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/PayfritBeacon/BeaconProvisioner.swift b/PayfritBeacon/BeaconProvisioner.swift index fd07e32..b0906a6 100644 --- a/PayfritBeacon/BeaconProvisioner.swift +++ b/PayfritBeacon/BeaconProvisioner.swift @@ -194,6 +194,15 @@ class BeaconProvisioner: NSObject, ObservableObject { private var writeRetryCount = 0 private static let MAX_WRITE_RETRIES = 1 // Retry once per command before failing + // Adaptive inter-command delays to prevent BLE supervision timeouts. + // DX-Smart CP28 beacons have tiny MCU buffers and share radio time between + // advertising and GATT — rapid writes cause the beacon to miss connection + // events, triggering link-layer supervision timeouts (the "unexpected disconnect" + // that was happening 4x during provisioning). + private static let BASE_WRITE_DELAY: Double = 0.5 // Default delay between commands (was 0.3) + 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 + override init() { super.init() centralManager = CBCentralManager(delegate: self, queue: .main) @@ -603,7 +612,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.3) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.dxSmartSendNextCommand() } } else { @@ -622,6 +631,39 @@ class BeaconProvisioner: NSObject, ObservableObject { writeTimeoutTimer = nil } + /// Calculate adaptive delay after writing a command. + /// Frame selection (0x11-0x16) and type commands (0x61, 0x62) trigger internal + /// state changes on the beacon MCU that need extra processing time. + /// UUID writes are the largest payload (21 bytes) and also need breathing room. + /// Without adaptive delays, the beacon's radio gets overwhelmed and drops the + /// BLE connection (supervision timeout). + private func delayForCommand(at index: Int) -> Double { + guard index < dxSmartCommandQueue.count else { return BeaconProvisioner.BASE_WRITE_DELAY } + + let packet = dxSmartCommandQueue[index] + guard packet.count >= 3 else { return BeaconProvisioner.BASE_WRITE_DELAY } + + let cmd = packet[2] // Command byte is at offset 2 (after 4E 4F header) + + switch DXCmd(rawValue: cmd) { + case .frameSelectSlot0, .frameSelectSlot1, .frameSelectSlot2, + .frameSelectSlot3, .frameSelectSlot4, .frameSelectSlot5: + // Frame selection changes internal state — beacon needs time to switch context + return BeaconProvisioner.HEAVY_WRITE_DELAY + case .deviceInfoType, .iBeaconType: + // Frame type assignment — triggers internal config restructuring + return BeaconProvisioner.HEAVY_WRITE_DELAY + case .uuid: + // Largest payload (16 bytes + header = 21 bytes) — give extra time + return BeaconProvisioner.LARGE_PAYLOAD_DELAY + case .saveConfig: + // Save to flash — beacon may reboot, no point waiting long + return BeaconProvisioner.BASE_WRITE_DELAY + default: + return BeaconProvisioner.BASE_WRITE_DELAY + } + } + // MARK: - DX-Smart Packet Builder /// Build a DX-Smart CP28 packet: [4E][4F][CMD][LEN][DATA...][XOR_CHECKSUM] @@ -1298,7 +1340,7 @@ extension BeaconProvisioner: CBPeripheralDelegate { } else if isNonFatalCommand { DebugLog.shared.log("BLE: Non-fatal command failed at step \(dxSmartWriteIndex + 1), continuing...") dxSmartWriteIndex += 1 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.dxSmartSendNextCommand() } } else { @@ -1330,8 +1372,9 @@ extension BeaconProvisioner: CBPeripheralDelegate { 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 + // Longer delay after reconnect — give the beacon's BLE stack time to stabilize + // before resuming writes (prevents immediate re-disconnect) + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in self?.dxSmartSendNextCommand() } } else { @@ -1352,8 +1395,10 @@ extension BeaconProvisioner: CBPeripheralDelegate { // Device info query was sent - wait for response on FFE1, don't process as normal command DebugLog.shared.log("BLE: Device info query sent, waiting for response...") } else { + let justWritten = dxSmartWriteIndex dxSmartWriteIndex += 1 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + let delay = delayForCommand(at: justWritten) + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in self?.dxSmartSendNextCommand() } } -- 2.43.0