diff --git a/PayfritBeacon/BeaconProvisioner.swift b/PayfritBeacon/BeaconProvisioner.swift index d7d4eb4..a36b34a 100644 --- a/PayfritBeacon/BeaconProvisioner.swift +++ b/PayfritBeacon/BeaconProvisioner.swift @@ -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() } } }