From 3c41ecb49de2c6838376976730ae4fc86b08314c Mon Sep 17 00:00:00 2001 From: Schwifty Date: Mon, 23 Mar 2026 04:08:40 +0000 Subject: [PATCH] fix: add disconnect detection + drop inter-command delay to 50ms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes: 1. DXSmartProvisioner now registers for BLE disconnect callbacks. Previously if the beacon dropped the link mid-write, the provisioner would sit waiting for ACK timeouts (5s × 2 retries = 10s of dead air). Now it fails immediately with a clear error. 2. Inter-command delay reduced from 150ms → 50ms since beacon handles fast writes fine. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Provisioners/DXSmartProvisioner.swift | 47 ++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/PayfritBeacon/Provisioners/DXSmartProvisioner.swift b/PayfritBeacon/Provisioners/DXSmartProvisioner.swift index ff3e66e..d3f6963 100644 --- a/PayfritBeacon/Provisioners/DXSmartProvisioner.swift +++ b/PayfritBeacon/Provisioners/DXSmartProvisioner.swift @@ -41,6 +41,7 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner { private(set) var isConnected = false private(set) var isFlashing = false // Beacon LED flashing after trigger private var useNewSDK = true // Prefer new SDK, fallback to old + private var disconnected = false // Set true when BLE link drops unexpectedly var diagnosticLog: ProvisionLog? var bleManager: BLEManager? @@ -76,6 +77,14 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner { await diagnosticLog?.log("auth", "Authenticating (trigger + password)…") try await authenticate() await diagnosticLog?.log("auth", "Auth complete — SDK: \(useNewSDK ? "new (FFE2)" : "old (FFE1)")") + + // Register for unexpected disconnects so we fail fast instead of + // waiting for per-command ACK timeouts (5s × 2 = 10s of dead air). + bleManager?.onPeripheralDisconnected = { [weak self] disconnectedPeripheral, error in + guard disconnectedPeripheral.identifier == self?.peripheral.identifier else { return } + self?.handleUnexpectedDisconnect(error: error) + } + isConnected = true isFlashing = true return @@ -125,6 +134,8 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner { } func disconnect() { + // Unregister disconnect handler so intentional disconnect doesn't trigger error path + bleManager?.onPeripheralDisconnected = nil if peripheral.state == .connected || peripheral.state == .connecting { centralManager.cancelPeripheralConnection(peripheral) } @@ -171,6 +182,12 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner { ] for (index, (name, packet)) in commands.enumerated() { + // Bail immediately if BLE link dropped between commands + if disconnected { + await diagnosticLog?.log("write", "Aborting — BLE disconnected", isError: true) + throw ProvisionError.writeFailed("BLE disconnected during write sequence") + } + await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) (\(packet.count) bytes)") // SaveConfig (last command) causes beacon MCU to reboot — it never sends an ACK. @@ -205,8 +222,8 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner { throw lastError } - // 150ms between commands — aggressive speedup (was 300ms, originally 500ms) - try await Task.sleep(nanoseconds: 150_000_000) + // 50ms between commands — beacon handles fast writes fine (was 150ms, 300ms, 500ms) + try await Task.sleep(nanoseconds: 50_000_000) } } @@ -282,6 +299,32 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner { return packet } + // MARK: - Disconnect Detection + + /// Called when BLE link drops unexpectedly during provisioning. + /// Immediately resolves any pending continuations so we fail fast + /// instead of waiting for the 5s operationTimeout. + private func handleUnexpectedDisconnect(error: Error?) { + disconnected = true + isConnected = false + let disconnectError = ProvisionError.writeFailed("BLE disconnected unexpectedly: \(error?.localizedDescription ?? "unknown")") + Task { await diagnosticLog?.log("ble", "⚠️ Unexpected disconnect during provisioning", isError: true) } + + // Cancel any pending write/response continuation immediately + if let cont = responseContinuation { + responseContinuation = nil + cont.resume(throwing: disconnectError) + } + if let cont = writeContinuation { + writeContinuation = nil + cont.resume(throwing: disconnectError) + } + if let cont = connectionContinuation { + connectionContinuation = nil + cont.resume(throwing: disconnectError) + } + } + // MARK: - Private Helpers private func connectOnce() async throws {