fix: response gating between BLE writes (matches Android) #15

Merged
schwifty merged 1 commit from schwifty/response-gating-fix into main 2026-03-22 03:12:49 +00:00

View file

@ -196,6 +196,15 @@ class BeaconProvisioner: NSObject, ObservableObject {
private var writeRetryCount = 0 private var writeRetryCount = 0
private static let MAX_WRITE_RETRIES = 1 // Retry once per command before failing 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. // Adaptive inter-command delays to prevent BLE supervision timeouts.
// DX-Smart CP28 beacons have tiny MCU buffers and share radio time between // DX-Smart CP28 beacons have tiny MCU buffers and share radio time between
// advertising and GATT rapid writes cause the beacon to miss connection // advertising and GATT rapid writes cause the beacon to miss connection
@ -243,6 +252,8 @@ class BeaconProvisioner: NSObject, ObservableObject {
self.skipDeviceInfoRead = false self.skipDeviceInfoRead = false
self.isTerminating = false self.isTerminating = false
self.resumeWriteAfterDisconnect = false self.resumeWriteAfterDisconnect = false
self.awaitingCommandResponse = false
cancelResponseGateTimeout()
self.connectionRetryCount = 0 self.connectionRetryCount = 0
self.deviceInfoRetryCount = 0 self.deviceInfoRetryCount = 0
self.disconnectRetryCount = 0 self.disconnectRetryCount = 0
@ -319,6 +330,8 @@ class BeaconProvisioner: NSObject, ObservableObject {
private func cleanup() { private func cleanup() {
cancelWriteTimeout() cancelWriteTimeout()
cancelResponseGateTimeout()
awaitingCommandResponse = false
peripheral = nil peripheral = nil
config = nil config = nil
completion = 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 // MARK: - DX-Smart Packet Builder
/// Build a DX-Smart CP28 packet: [4E][4F][CMD][LEN][DATA...][XOR_CHECKSUM] /// Build a DX-Smart CP28 packet: [4E][4F][CMD][LEN][DATA...][XOR_CHECKSUM]
@ -1181,8 +1229,10 @@ extension BeaconProvisioner: CBCentralManagerDelegate {
return return
} }
// Cancel any pending write timeout disconnect supersedes it // Cancel any pending timers disconnect supersedes them
cancelWriteTimeout() cancelWriteTimeout()
cancelResponseGateTimeout()
awaitingCommandResponse = false
// Unexpected disconnect during any active provisioning phase retry with full reconnect // Unexpected disconnect during any active provisioning phase retry with full reconnect
let isActivePhase = (state == .discoveringServices || state == .authenticating || state == .writing || state == .verifying) let isActivePhase = (state == .discoveringServices || state == .authenticating || state == .writing || state == .verifying)
@ -1413,7 +1463,8 @@ extension BeaconProvisioner: CBPeripheralDelegate {
return 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 characteristic.uuid == BeaconProvisioner.DXSMART_COMMAND_CHAR {
if operationMode == .readingConfig { if operationMode == .readingConfig {
dxReadQueryIndex += 1 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 // 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...") DebugLog.shared.log("BLE: Device info query sent, waiting for response...")
} else { } else {
let justWritten = dxSmartWriteIndex // Gate on FFE1 response don't fire next command until beacon responds
dxSmartWriteIndex += 1 // or 1s timeout elapses (some commands don't send responses)
let delay = delayForCommand(at: justWritten) awaitingCommandResponse = true
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in scheduleResponseGateTimeout()
self?.dxSmartSendNextCommand()
}
} }
return return
} }
@ -1480,6 +1529,25 @@ extension BeaconProvisioner: CBPeripheralDelegate {
// If awaiting device info for MAC address, process the response // If awaiting device info for MAC address, process the response
if awaitingDeviceInfoForProvisioning { if awaitingDeviceInfoForProvisioning {
processDeviceInfoForProvisioning(data) 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()
} }
} }
} }