fix: add disconnect detection + drop inter-command delay to 50ms

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) <noreply@anthropic.com>
This commit is contained in:
Schwifty 2026-03-23 04:08:40 +00:00
parent a08d3db893
commit 3c41ecb49d

View file

@ -41,6 +41,7 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
private(set) var isConnected = false private(set) var isConnected = false
private(set) var isFlashing = false // Beacon LED flashing after trigger private(set) var isFlashing = false // Beacon LED flashing after trigger
private var useNewSDK = true // Prefer new SDK, fallback to old 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 diagnosticLog: ProvisionLog?
var bleManager: BLEManager? var bleManager: BLEManager?
@ -76,6 +77,14 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
await diagnosticLog?.log("auth", "Authenticating (trigger + password)…") await diagnosticLog?.log("auth", "Authenticating (trigger + password)…")
try await authenticate() try await authenticate()
await diagnosticLog?.log("auth", "Auth complete — SDK: \(useNewSDK ? "new (FFE2)" : "old (FFE1)")") 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 isConnected = true
isFlashing = true isFlashing = true
return return
@ -125,6 +134,8 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
} }
func disconnect() { func disconnect() {
// Unregister disconnect handler so intentional disconnect doesn't trigger error path
bleManager?.onPeripheralDisconnected = nil
if peripheral.state == .connected || peripheral.state == .connecting { if peripheral.state == .connected || peripheral.state == .connecting {
centralManager.cancelPeripheralConnection(peripheral) centralManager.cancelPeripheralConnection(peripheral)
} }
@ -171,6 +182,12 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
] ]
for (index, (name, packet)) in commands.enumerated() { 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)") 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. // SaveConfig (last command) causes beacon MCU to reboot it never sends an ACK.
@ -205,8 +222,8 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
throw lastError throw lastError
} }
// 150ms between commands aggressive speedup (was 300ms, originally 500ms) // 50ms between commands beacon handles fast writes fine (was 150ms, 300ms, 500ms)
try await Task.sleep(nanoseconds: 150_000_000) try await Task.sleep(nanoseconds: 50_000_000)
} }
} }
@ -282,6 +299,32 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
return packet 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 // MARK: - Private Helpers
private func connectOnce() async throws { private func connectOnce() async throws {