From 91669515bd6a0653fafb2329210ece60c7a0cd1f Mon Sep 17 00:00:00 2001 From: Schwifty Date: Sun, 22 Mar 2026 02:14:32 +0000 Subject: [PATCH] fix: add per-write timeouts and reduce inter-write delay to match Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iOS provisioner had no per-write timeout — if a BLE write callback never came back, the operation hung until the 30s global timeout, by which point the connection was likely dead. Android uses a 5-second per-write timeout with withTimeoutOrNull(5000L). Changes: - Add 5-second per-write timeout with retry (1 retry per command) - On timeout: retry once, skip if non-fatal (steps 1-6), or fail - SaveConfig timeout treated as success (beacon reboots = no callback) - Reduce inter-write delay from 200ms to 150ms (Android uses 100ms) - Log negotiated MTU on connect to diagnose packet size issues - Cancel write timeout on cleanup/succeed/fail to prevent stale timers Co-Authored-By: Claude Opus 4.6 (1M context) --- PayfritBeacon/BeaconProvisioner.swift | 79 ++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/PayfritBeacon/BeaconProvisioner.swift b/PayfritBeacon/BeaconProvisioner.swift index 7a2c1b9..cb23fd7 100644 --- a/PayfritBeacon/BeaconProvisioner.swift +++ b/PayfritBeacon/BeaconProvisioner.swift @@ -185,6 +185,14 @@ class BeaconProvisioner: NSObject, ObservableObject { private static let MAX_DISCONNECT_RETRIES = 2 private var currentBeacon: DiscoveredBeacon? + // Per-write timeout (matches Android's 5-second per-write timeout) + // If a write callback doesn't come back in time, we retry or fail gracefully + // instead of hanging until the 30s global timeout + private var writeTimeoutTimer: DispatchWorkItem? + private static let WRITE_TIMEOUT_SECONDS: Double = 5.0 + private var writeRetryCount = 0 + private static let MAX_WRITE_RETRIES = 1 // Retry once per command before failing + override init() { super.init() centralManager = CBCentralManager(delegate: self, queue: .main) @@ -225,6 +233,7 @@ class BeaconProvisioner: NSObject, ObservableObject { self.connectionRetryCount = 0 self.deviceInfoRetryCount = 0 self.disconnectRetryCount = 0 + self.writeRetryCount = 0 self.currentBeacon = beacon state = .connecting @@ -296,6 +305,7 @@ class BeaconProvisioner: NSObject, ObservableObject { // MARK: - Cleanup private func cleanup() { + cancelWriteTimeout() peripheral = nil config = nil completion = nil @@ -313,6 +323,7 @@ class BeaconProvisioner: NSObject, ObservableObject { connectionRetryCount = 0 deviceInfoRetryCount = 0 disconnectRetryCount = 0 + writeRetryCount = 0 currentBeacon = nil state = .idle progress = "" @@ -535,6 +546,7 @@ class BeaconProvisioner: NSObject, ObservableObject { /// Send the next command in the DX-Smart queue private func dxSmartSendNextCommand() { guard dxSmartWriteIndex < dxSmartCommandQueue.count else { + cancelWriteTimeout() DebugLog.shared.log("BLE: All DX-Smart commands written!") progress = "Configuration saved!" succeed() @@ -552,9 +564,61 @@ class BeaconProvisioner: NSObject, ObservableObject { } DebugLog.shared.log("BLE: Writing command \(current)/\(total): \(packet.map { String(format: "%02X", $0) }.joined(separator: " "))") + writeRetryCount = 0 + scheduleWriteTimeout() peripheral?.writeValue(packet, for: commandChar, type: .withResponse) } + /// Schedule a per-write timeout — if the write callback doesn't come back + /// within WRITE_TIMEOUT_SECONDS, retry the write once or skip if non-fatal. + /// This matches Android's 5-second per-write timeout via withTimeoutOrNull(5000L). + private func scheduleWriteTimeout() { + cancelWriteTimeout() + let timer = DispatchWorkItem { [weak self] in + guard let self = self else { return } + guard self.state == .writing else { return } + + let current = self.dxSmartWriteIndex + 1 + let total = self.dxSmartCommandQueue.count + let isNonFatal = self.dxSmartWriteIndex < 6 + let isSaveConfig = self.dxSmartWriteIndex >= self.dxSmartCommandQueue.count - 1 + + if isSaveConfig { + // SaveConfig may not get a callback — beacon reboots. Treat as success. + DebugLog.shared.log("BLE: SaveConfig write timeout (beacon likely rebooted) — treating as success") + self.succeed() + } else if self.writeRetryCount < BeaconProvisioner.MAX_WRITE_RETRIES { + // Retry the write once + self.writeRetryCount += 1 + DebugLog.shared.log("BLE: Write timeout for command \(current)/\(total) — retrying (\(self.writeRetryCount)/\(BeaconProvisioner.MAX_WRITE_RETRIES))") + if let commandChar = self.characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] { + let packet = self.dxSmartCommandQueue[self.dxSmartWriteIndex] + self.scheduleWriteTimeout() + self.peripheral?.writeValue(packet, for: commandChar, type: .withResponse) + } + } else if isNonFatal { + // 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 + self?.dxSmartSendNextCommand() + } + } else { + // Fatal command timed out after retry — fail + DebugLog.shared.log("BLE: Write timeout for critical command \(current)/\(total) — failing") + self.fail("Write timeout at step \(current)/\(total)", code: .writeFailed) + } + } + writeTimeoutTimer = timer + DispatchQueue.main.asyncAfter(deadline: .now() + BeaconProvisioner.WRITE_TIMEOUT_SECONDS, execute: timer) + } + + /// Cancel any pending write timeout + private func cancelWriteTimeout() { + writeTimeoutTimer?.cancel() + writeTimeoutTimer = nil + } + // MARK: - DX-Smart Packet Builder /// Build a DX-Smart CP28 packet: [4E][4F][CMD][LEN][DATA...][XOR_CHECKSUM] @@ -940,6 +1004,15 @@ extension BeaconProvisioner: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { DebugLog.shared.log("BLE: Connected to \(peripheral.name ?? "unknown")") peripheral.delegate = self + + // Log negotiated MTU — CoreBluetooth auto-negotiates, but we need to verify + // the max write length can handle our largest packet (UUID write = ~21 bytes) + let maxWriteLen = peripheral.maximumWriteValueLength(for: .withResponse) + DebugLog.shared.log("BLE: Max write length (withResponse): \(maxWriteLen) bytes") + if maxWriteLen < 21 { + DebugLog.shared.log("BLE: WARNING — max write length \(maxWriteLen) may be too small for UUID packet (21 bytes)") + } + state = .discoveringServices progress = "Discovering services..." @@ -1169,6 +1242,8 @@ extension BeaconProvisioner: CBPeripheralDelegate { } func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + cancelWriteTimeout() // Write callback received — cancel the per-write timeout + if let error = error { DebugLog.shared.log("BLE: Write failed for \(characteristic.uuid): \(error.localizedDescription)") @@ -1206,7 +1281,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.2) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in self?.dxSmartSendNextCommand() } } else { @@ -1251,7 +1326,7 @@ extension BeaconProvisioner: CBPeripheralDelegate { DebugLog.shared.log("BLE: Device info query sent, waiting for response...") } else { dxSmartWriteIndex += 1 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in self?.dxSmartSendNextCommand() } }