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 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue