fix: per-write timeouts + reduced inter-write delay #10

Merged
schwifty merged 1 commit from schwifty/per-write-timeout-and-mtu into main 2026-03-22 02:17:19 +00:00

View file

@ -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()
}
}