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:
Schwifty 2026-03-22 03:08:53 +00:00
parent 9319cad2d6
commit 41c26acad3

View file

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