fix: add response gating between BLE writes to prevent beacon disconnects
Root cause: iOS was firing the next GATT command as soon as the BLE write ACK arrived, without waiting for the beacon's FFE1 notification response. Android explicitly waits up to 1s for the beacon to respond (via responseChannel.receive) before sending the next command. This gives the beacon MCU time to process each command before the next one arrives. Without this gate, the beacon gets overwhelmed and drops the BLE connection (supervision timeout), causing the "DX Smart command characteristic" error John reported after repeated disconnects. Changes: - Add awaitingCommandResponse flag + 1s response gate timer - After each FFE2 write success, wait for FFE1 notification before advancing - If no response within 1s, advance anyway (some commands don't respond) - Check for 4E 4F 00 rejection pattern (matches Android) - Clean up gate timer on disconnect, cleanup, and state resets Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9319cad2d6
commit
41c26acad3
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