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 {