fix: prevent beacon disconnects during provisioning #13
1 changed files with 80 additions and 11 deletions
|
|
@ -159,6 +159,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
|
|||
private var awaitingDeviceInfoForProvisioning = false
|
||||
private var skipDeviceInfoRead = false // set after disconnect during device info — skip MAC read on reconnect
|
||||
private var isTerminating = false // guards against re-entrant disconnect handling
|
||||
private var resumeWriteAfterDisconnect = false // when true, skip queue rebuild on reconnect and resume from saved index
|
||||
|
||||
// Read config mode
|
||||
private enum OperationMode { case provisioning, readingConfig }
|
||||
|
|
@ -182,7 +183,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
|
|||
private var disconnectRetryCount = 0
|
||||
private static let MAX_CONNECTION_RETRIES = 3
|
||||
private static let MAX_DEVICE_INFO_RETRIES = 2
|
||||
private static let MAX_DISCONNECT_RETRIES = 3
|
||||
private static let MAX_DISCONNECT_RETRIES = 5
|
||||
private var currentBeacon: DiscoveredBeacon?
|
||||
|
||||
// Per-write timeout (matches Android's 5-second per-write timeout)
|
||||
|
|
@ -193,6 +194,15 @@ class BeaconProvisioner: NSObject, ObservableObject {
|
|||
private var writeRetryCount = 0
|
||||
private static let MAX_WRITE_RETRIES = 1 // Retry once per command before failing
|
||||
|
||||
// Adaptive inter-command delays to prevent BLE supervision timeouts.
|
||||
// DX-Smart CP28 beacons have tiny MCU buffers and share radio time between
|
||||
// advertising and GATT — rapid writes cause the beacon to miss connection
|
||||
// events, triggering link-layer supervision timeouts (the "unexpected disconnect"
|
||||
// that was happening 4x during provisioning).
|
||||
private static let BASE_WRITE_DELAY: Double = 0.5 // Default delay between commands (was 0.3)
|
||||
private static let HEAVY_WRITE_DELAY: Double = 1.0 // After frame select/type commands (MCU state change)
|
||||
private static let LARGE_PAYLOAD_DELAY: Double = 0.8 // After UUID/large payload writes
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
centralManager = CBCentralManager(delegate: self, queue: .main)
|
||||
|
|
@ -230,6 +240,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
|
|||
self.awaitingDeviceInfoForProvisioning = false
|
||||
self.skipDeviceInfoRead = false
|
||||
self.isTerminating = false
|
||||
self.resumeWriteAfterDisconnect = false
|
||||
self.connectionRetryCount = 0
|
||||
self.deviceInfoRetryCount = 0
|
||||
self.disconnectRetryCount = 0
|
||||
|
|
@ -241,8 +252,8 @@ class BeaconProvisioner: NSObject, ObservableObject {
|
|||
|
||||
centralManager.connect(resolvedPeripheral, options: nil)
|
||||
|
||||
// Timeout after 45 seconds (increased from 30s to accommodate 3 disconnect retries with backoff)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 45) { [weak self] in
|
||||
// Timeout after 90 seconds (increased to accommodate 5 disconnect retries with resume)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 90) { [weak self] in
|
||||
if self?.state != .success && self?.state != .idle {
|
||||
self?.fail("Connection timeout", code: .connectionTimeout)
|
||||
}
|
||||
|
|
@ -320,6 +331,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
|
|||
awaitingDeviceInfoForProvisioning = false
|
||||
skipDeviceInfoRead = false
|
||||
isTerminating = false
|
||||
resumeWriteAfterDisconnect = false
|
||||
connectionRetryCount = 0
|
||||
deviceInfoRetryCount = 0
|
||||
disconnectRetryCount = 0
|
||||
|
|
@ -600,7 +612,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
|
|||
// 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
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.dxSmartSendNextCommand()
|
||||
}
|
||||
} else {
|
||||
|
|
@ -619,6 +631,39 @@ class BeaconProvisioner: NSObject, ObservableObject {
|
|||
writeTimeoutTimer = nil
|
||||
}
|
||||
|
||||
/// Calculate adaptive delay after writing a command.
|
||||
/// Frame selection (0x11-0x16) and type commands (0x61, 0x62) trigger internal
|
||||
/// state changes on the beacon MCU that need extra processing time.
|
||||
/// UUID writes are the largest payload (21 bytes) and also need breathing room.
|
||||
/// Without adaptive delays, the beacon's radio gets overwhelmed and drops the
|
||||
/// BLE connection (supervision timeout).
|
||||
private func delayForCommand(at index: Int) -> Double {
|
||||
guard index < dxSmartCommandQueue.count else { return BeaconProvisioner.BASE_WRITE_DELAY }
|
||||
|
||||
let packet = dxSmartCommandQueue[index]
|
||||
guard packet.count >= 3 else { return BeaconProvisioner.BASE_WRITE_DELAY }
|
||||
|
||||
let cmd = packet[2] // Command byte is at offset 2 (after 4E 4F header)
|
||||
|
||||
switch DXCmd(rawValue: cmd) {
|
||||
case .frameSelectSlot0, .frameSelectSlot1, .frameSelectSlot2,
|
||||
.frameSelectSlot3, .frameSelectSlot4, .frameSelectSlot5:
|
||||
// Frame selection changes internal state — beacon needs time to switch context
|
||||
return BeaconProvisioner.HEAVY_WRITE_DELAY
|
||||
case .deviceInfoType, .iBeaconType:
|
||||
// Frame type assignment — triggers internal config restructuring
|
||||
return BeaconProvisioner.HEAVY_WRITE_DELAY
|
||||
case .uuid:
|
||||
// Largest payload (16 bytes + header = 21 bytes) — give extra time
|
||||
return BeaconProvisioner.LARGE_PAYLOAD_DELAY
|
||||
case .saveConfig:
|
||||
// Save to flash — beacon may reboot, no point waiting long
|
||||
return BeaconProvisioner.BASE_WRITE_DELAY
|
||||
default:
|
||||
return BeaconProvisioner.BASE_WRITE_DELAY
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DX-Smart Packet Builder
|
||||
|
||||
/// Build a DX-Smart CP28 packet: [4E][4F][CMD][LEN][DATA...][XOR_CHECKSUM]
|
||||
|
|
@ -1121,20 +1166,31 @@ extension BeaconProvisioner: CBCentralManagerDelegate {
|
|||
let isActivePhase = (state == .discoveringServices || state == .authenticating || state == .writing || state == .verifying)
|
||||
if isActivePhase && disconnectRetryCount < BeaconProvisioner.MAX_DISCONNECT_RETRIES {
|
||||
disconnectRetryCount += 1
|
||||
DebugLog.shared.log("BLE: Disconnect during \(state) — reconnecting (attempt \(disconnectRetryCount)/\(BeaconProvisioner.MAX_DISCONNECT_RETRIES))")
|
||||
let wasWriting = (state == .writing && !dxSmartCommandQueue.isEmpty)
|
||||
DebugLog.shared.log("BLE: Disconnect during \(state) — reconnecting (attempt \(disconnectRetryCount)/\(BeaconProvisioner.MAX_DISCONNECT_RETRIES)) wasWriting=\(wasWriting) writeIdx=\(dxSmartWriteIndex)")
|
||||
progress = "Beacon disconnected, reconnecting (\(disconnectRetryCount)/\(BeaconProvisioner.MAX_DISCONNECT_RETRIES))..."
|
||||
|
||||
// Reset connection state for clean reconnect
|
||||
// Reset connection-level state, but PRESERVE command queue and write index
|
||||
// so we can resume from where we left off instead of starting over
|
||||
dxSmartAuthenticated = false
|
||||
dxSmartNotifySubscribed = false
|
||||
dxSmartCommandQueue.removeAll()
|
||||
dxSmartWriteIndex = 0
|
||||
passwordIndex = 0
|
||||
characteristics.removeAll()
|
||||
responseBuffer.removeAll()
|
||||
|
||||
if wasWriting {
|
||||
// Resume mode: keep the command queue and write index intact
|
||||
resumeWriteAfterDisconnect = true
|
||||
DebugLog.shared.log("BLE: Will resume writing from command \(dxSmartWriteIndex + 1)/\(dxSmartCommandQueue.count) after reconnect")
|
||||
} else {
|
||||
// Full reset for non-writing phases
|
||||
dxSmartCommandQueue.removeAll()
|
||||
dxSmartWriteIndex = 0
|
||||
resumeWriteAfterDisconnect = false
|
||||
}
|
||||
state = .connecting
|
||||
|
||||
let delay = Double(disconnectRetryCount) + 2.0 // 3s, 4s, 5s backoff — give BLE time to settle
|
||||
let delay = Double(disconnectRetryCount) + 2.0 // 3s, 4s, 5s, 6s, 7s backoff — give BLE time to settle
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
guard let self = self, let beacon = self.currentBeacon else { return }
|
||||
guard self.state == .connecting else { return }
|
||||
|
|
@ -1284,7 +1340,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.15) { [weak self] in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.dxSmartSendNextCommand()
|
||||
}
|
||||
} else {
|
||||
|
|
@ -1310,6 +1366,17 @@ extension BeaconProvisioner: CBPeripheralDelegate {
|
|||
dxSmartAuthenticated = true
|
||||
if operationMode == .readingConfig {
|
||||
dxSmartReadQueryAfterAuth()
|
||||
} else if resumeWriteAfterDisconnect {
|
||||
// Reconnected after disconnect during writing — resume from saved position
|
||||
resumeWriteAfterDisconnect = false
|
||||
state = .writing
|
||||
DebugLog.shared.log("BLE: Resuming write from command \(dxSmartWriteIndex + 1)/\(dxSmartCommandQueue.count) after reconnect")
|
||||
progress = "Resuming config write..."
|
||||
// Longer delay after reconnect — give the beacon's BLE stack time to stabilize
|
||||
// before resuming writes (prevents immediate re-disconnect)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
|
||||
self?.dxSmartSendNextCommand()
|
||||
}
|
||||
} else {
|
||||
// Read device info first to get MAC address, then write config
|
||||
dxSmartReadDeviceInfoBeforeWrite()
|
||||
|
|
@ -1328,8 +1395,10 @@ extension BeaconProvisioner: CBPeripheralDelegate {
|
|||
// Device info query was sent - wait for response on FFE1, don't process as normal command
|
||||
DebugLog.shared.log("BLE: Device info query sent, waiting for response...")
|
||||
} else {
|
||||
let justWritten = dxSmartWriteIndex
|
||||
dxSmartWriteIndex += 1
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
|
||||
let delay = delayForCommand(at: justWritten)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.dxSmartSendNextCommand()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue