fix: response gating between BLE writes (matches Android) #15
1 changed files with 76 additions and 8 deletions
|
|
@ -196,6 +196,15 @@ class BeaconProvisioner: NSObject, ObservableObject {
|
|||
private var writeRetryCount = 0
|
||||
private static let MAX_WRITE_RETRIES = 1 // Retry once per command before failing
|
||||
|
||||
// Response gating — wait for beacon's FFE1 notification response after each
|
||||
// write before sending the next command. Android does this with a 1000ms
|
||||
// responseChannel.receive() after every FFE2 write. Without this gate, iOS
|
||||
// hammers the beacon's MCU faster than it can process, causing supervision
|
||||
// timeout disconnects.
|
||||
private var awaitingCommandResponse = false
|
||||
private var responseGateTimer: DispatchWorkItem?
|
||||
private static let RESPONSE_GATE_TIMEOUT: Double = 1.0 // 1s matches Android's withTimeoutOrNull(1000L)
|
||||
|
||||
// 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
|
||||
|
|
@ -243,6 +252,8 @@ class BeaconProvisioner: NSObject, ObservableObject {
|
|||
self.skipDeviceInfoRead = false
|
||||
self.isTerminating = false
|
||||
self.resumeWriteAfterDisconnect = false
|
||||
self.awaitingCommandResponse = false
|
||||
cancelResponseGateTimeout()
|
||||
self.connectionRetryCount = 0
|
||||
self.deviceInfoRetryCount = 0
|
||||
self.disconnectRetryCount = 0
|
||||
|
|
@ -319,6 +330,8 @@ class BeaconProvisioner: NSObject, ObservableObject {
|
|||
|
||||
private func cleanup() {
|
||||
cancelWriteTimeout()
|
||||
cancelResponseGateTimeout()
|
||||
awaitingCommandResponse = false
|
||||
peripheral = nil
|
||||
config = nil
|
||||
completion = nil
|
||||
|
|
@ -686,6 +699,41 @@ class BeaconProvisioner: NSObject, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Response Gating (matches Android responseChannel.receive pattern)
|
||||
|
||||
/// After a successful write, advance to the next command with the appropriate delay.
|
||||
/// Called either when FFE1 response arrives or when the 1s response gate timeout fires.
|
||||
private func advanceToNextCommand() {
|
||||
let justWritten = dxSmartWriteIndex
|
||||
dxSmartWriteIndex += 1
|
||||
let delay = delayForCommand(at: justWritten)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.dxSmartSendNextCommand()
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule a 1s timeout for beacon response. If the beacon doesn't send an FFE1
|
||||
/// notification within 1s, advance anyway (some commands don't produce responses).
|
||||
/// Matches Android's: withTimeoutOrNull(1000L) { responseChannel.receive() }
|
||||
private func scheduleResponseGateTimeout() {
|
||||
cancelResponseGateTimeout()
|
||||
let timer = DispatchWorkItem { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard self.awaitingCommandResponse else { return }
|
||||
self.awaitingCommandResponse = false
|
||||
DebugLog.shared.log("BLE: No FFE1 response within 1s for command \(self.dxSmartWriteIndex + 1) — advancing (OK)")
|
||||
self.advanceToNextCommand()
|
||||
}
|
||||
responseGateTimer = timer
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + BeaconProvisioner.RESPONSE_GATE_TIMEOUT, execute: timer)
|
||||
}
|
||||
|
||||
/// Cancel any pending response gate timeout
|
||||
private func cancelResponseGateTimeout() {
|
||||
responseGateTimer?.cancel()
|
||||
responseGateTimer = nil
|
||||
}
|
||||
|
||||
// MARK: - DX-Smart Packet Builder
|
||||
|
||||
/// Build a DX-Smart CP28 packet: [4E][4F][CMD][LEN][DATA...][XOR_CHECKSUM]
|
||||
|
|
@ -1181,8 +1229,10 @@ extension BeaconProvisioner: CBCentralManagerDelegate {
|
|||
return
|
||||
}
|
||||
|
||||
// Cancel any pending write timeout — disconnect supersedes it
|
||||
// Cancel any pending timers — disconnect supersedes them
|
||||
cancelWriteTimeout()
|
||||
cancelResponseGateTimeout()
|
||||
awaitingCommandResponse = false
|
||||
|
||||
// Unexpected disconnect during any active provisioning phase — retry with full reconnect
|
||||
let isActivePhase = (state == .discoveringServices || state == .authenticating || state == .writing || state == .verifying)
|
||||
|
|
@ -1413,7 +1463,8 @@ extension BeaconProvisioner: CBPeripheralDelegate {
|
|||
return
|
||||
}
|
||||
|
||||
// Command write succeeded → send next
|
||||
// Command write succeeded → wait for beacon response before sending next
|
||||
// (matches Android: writeCharacteristic → responseChannel.receive(1000ms) → delay → next)
|
||||
if characteristic.uuid == BeaconProvisioner.DXSMART_COMMAND_CHAR {
|
||||
if operationMode == .readingConfig {
|
||||
dxReadQueryIndex += 1
|
||||
|
|
@ -1424,12 +1475,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
|
||||
let delay = delayForCommand(at: justWritten)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.dxSmartSendNextCommand()
|
||||
}
|
||||
// Gate on FFE1 response — don't fire next command until beacon responds
|
||||
// or 1s timeout elapses (some commands don't send responses)
|
||||
awaitingCommandResponse = true
|
||||
scheduleResponseGateTimeout()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -1480,6 +1529,25 @@ extension BeaconProvisioner: CBPeripheralDelegate {
|
|||
// If awaiting device info for MAC address, process the response
|
||||
if awaitingDeviceInfoForProvisioning {
|
||||
processDeviceInfoForProvisioning(data)
|
||||
} else if awaitingCommandResponse {
|
||||
// Beacon responded to our command — check for rejection and advance
|
||||
awaitingCommandResponse = false
|
||||
cancelResponseGateTimeout()
|
||||
|
||||
// Check for rejection: 4E 4F 00 means command rejected (matches Android check)
|
||||
let bytes = [UInt8](data)
|
||||
if bytes.count >= 3 && bytes[0] == 0x4E && bytes[1] == 0x4F && bytes[2] == 0x00 {
|
||||
let isNonFatal = dxSmartWriteIndex < 6
|
||||
if isNonFatal {
|
||||
DebugLog.shared.log("BLE: Command \(dxSmartWriteIndex + 1) rejected by beacon (non-fatal, continuing)")
|
||||
} else {
|
||||
DebugLog.shared.log("BLE: Command \(dxSmartWriteIndex + 1) REJECTED by beacon")
|
||||
// Don't fail here — let the advance logic handle it like Android does
|
||||
// (Android logs rejection but continues for most commands)
|
||||
}
|
||||
}
|
||||
|
||||
advanceToNextCommand()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue