diff --git a/PayfritBeacon/BeaconProvisioner.swift b/PayfritBeacon/BeaconProvisioner.swift index a36b34a..232f1de 100644 --- a/PayfritBeacon/BeaconProvisioner.swift +++ b/PayfritBeacon/BeaconProvisioner.swift @@ -192,6 +192,8 @@ class BeaconProvisioner: NSObject, ObservableObject { private var writeTimeoutTimer: DispatchWorkItem? private var charRediscoveryCount = 0 private static let MAX_CHAR_REDISCOVERY = 2 + private var charRediscoveryTimer: DispatchWorkItem? + private static let CHAR_REDISCOVERY_TIMEOUT: Double = 5.0 private static let WRITE_TIMEOUT_SECONDS: Double = 5.0 private var writeRetryCount = 0 private static let MAX_WRITE_RETRIES = 1 // Retry once per command before failing @@ -331,6 +333,7 @@ class BeaconProvisioner: NSObject, ObservableObject { private func cleanup() { cancelWriteTimeout() cancelResponseGateTimeout() + cancelCharRediscoveryTimeout() awaitingCommandResponse = false peripheral = nil config = nil @@ -467,8 +470,8 @@ class BeaconProvisioner: NSObject, ObservableObject { dxSmartWriteConfig() } - /// Build the full command sequence and start writing - /// New 24-step write sequence for DX-Smart CP28: + /// Build the command sequence and start writing + /// Slim write sequence for DX-Smart CP28 (no extra frame overwrites): /// 1. DeviceName 0x71 [name bytes] — service point name (max 20 ASCII chars) /// 2. Frame1_Select 0x11 — select frame 1 /// 3. Frame1_Type 0x61 — enable as device info (broadcasts name) @@ -484,8 +487,11 @@ class BeaconProvisioner: NSObject, ObservableObject { /// 13. AdvInterval 0x78 [advInterval] /// 14. TxPower 0x79 [txPower] /// 15. TriggerOff 0xA0 - /// 16-23. Frames 3-6 select + 0xFF (disable each) - /// 24. SaveConfig 0x60 — persist to flash + /// 16. SaveConfig 0x60 — persist to flash + /// + /// NOTE: Frames 3-6 are intentionally left untouched. Disabling extra frames + /// caused additional BLE writes that increased disconnect risk. We only write + /// the two frames we care about (device info + iBeacon) and save. private func dxSmartWriteConfig() { guard let config = config else { fail("No config provided", code: .noConfig) @@ -550,21 +556,9 @@ class BeaconProvisioner: NSObject, ObservableObject { // 15. TriggerOff (0xA0) dxSmartCommandQueue.append(buildDXPacket(cmd: .triggerOff, data: [])) - // --- Frames 3-6: Disable each --- - // 16-17. Frame 3: select (0x13) + disable (0xFF) - dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot2, data: [])) - dxSmartCommandQueue.append(buildDXPacket(cmd: .frameDisable, data: [])) - // 18-19. Frame 4: select (0x14) + disable (0xFF) - dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot3, data: [])) - dxSmartCommandQueue.append(buildDXPacket(cmd: .frameDisable, data: [])) - // 20-21. Frame 5: select (0x15) + disable (0xFF) - dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot4, data: [])) - dxSmartCommandQueue.append(buildDXPacket(cmd: .frameDisable, data: [])) - // 22-23. Frame 6: select (0x16) + disable (0xFF) - dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot5, data: [])) - dxSmartCommandQueue.append(buildDXPacket(cmd: .frameDisable, data: [])) + // Frames 3-6 intentionally left untouched — fewer writes = fewer disconnects - // 24. SaveConfig (0x60) — persist to flash + // 16. SaveConfig (0x60) — persist to flash dxSmartCommandQueue.append(buildDXPacket(cmd: .saveConfig, data: [])) DebugLog.shared.log("BLE: DX-Smart command queue built with \(dxSmartCommandQueue.count) commands") @@ -587,22 +581,43 @@ class BeaconProvisioner: NSObject, ObservableObject { progress = "Writing config (\(current)/\(total))..." guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else { - // After disconnect+reconnect, characteristic discovery may return incomplete results. - // Re-discover characteristics instead of hard-failing. - if charRediscoveryCount < BeaconProvisioner.MAX_CHAR_REDISCOVERY, let service = configService { + // FFE2 missing — the GATT cache is stale after a disconnect. Rediscovering + // characteristics on the same connection doesn't work because CoreBluetooth + // returns cached (stale) results. We need a full disconnect → reconnect → + // re-discover services → re-discover characteristics cycle. + if charRediscoveryCount < BeaconProvisioner.MAX_CHAR_REDISCOVERY { charRediscoveryCount += 1 - DebugLog.shared.log("BLE: FFE2 missing — re-discovering characteristics (attempt \(charRediscoveryCount)/\(BeaconProvisioner.MAX_CHAR_REDISCOVERY))") - progress = "Re-discovering characteristics..." - state = .discoveringServices - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - self?.peripheral?.discoverCharacteristics([ - BeaconProvisioner.DXSMART_NOTIFY_CHAR, - BeaconProvisioner.DXSMART_COMMAND_CHAR, - BeaconProvisioner.DXSMART_PASSWORD_CHAR - ], for: service) + DebugLog.shared.log("BLE: FFE2 missing — triggering full disconnect/reconnect (attempt \(charRediscoveryCount)/\(BeaconProvisioner.MAX_CHAR_REDISCOVERY))") + progress = "FFE2 missing, reconnecting..." + + // Use the existing disconnect retry path — it handles reconnect + re-auth + resume + resumeWriteAfterDisconnect = true + dxSmartAuthenticated = false + dxSmartNotifySubscribed = false + passwordIndex = 0 + characteristics.removeAll() + responseBuffer.removeAll() + awaitingCommandResponse = false + cancelResponseGateTimeout() + state = .connecting + + // Disconnect — didDisconnectPeripheral won't re-enter because we set state = .connecting + // and handle reconnect manually here + if let peripheral = peripheral, peripheral.state == .connected { + centralManager.cancelPeripheralConnection(peripheral) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + guard let self = self, let beacon = self.currentBeacon else { return } + guard self.state == .connecting else { return } + let resolvedPeripheral = self.resolvePeripheral(beacon) + self.peripheral = resolvedPeripheral + resolvedPeripheral.delegate = self + DebugLog.shared.log("BLE: Reconnecting after FFE2 miss...") + self.centralManager.connect(resolvedPeripheral, options: nil) } } else { - fail("DX-Smart command characteristic (FFE2) not found after \(charRediscoveryCount) rediscovery attempts", code: .serviceNotFound) + fail("DX-Smart command characteristic (FFE2) not found after \(charRediscoveryCount) reconnect attempts", code: .serviceNotFound) } return } @@ -666,6 +681,42 @@ class BeaconProvisioner: NSObject, ObservableObject { writeTimeoutTimer = nil } + /// Schedule a timeout for characteristic rediscovery. + /// If didDiscoverCharacteristicsFor doesn't fire within 5 seconds, + /// either retry or fail instead of hanging forever. + private func scheduleCharRediscoveryTimeout() { + cancelCharRediscoveryTimeout() + let timer = DispatchWorkItem { [weak self] in + guard let self = self else { return } + guard self.state == .discoveringServices else { return } + + let attempt = self.charRediscoveryCount + DebugLog.shared.log("BLE: Characteristic rediscovery timeout (attempt \(attempt)/\(BeaconProvisioner.MAX_CHAR_REDISCOVERY))") + + if attempt < BeaconProvisioner.MAX_CHAR_REDISCOVERY, let service = self.configService { + // Try another rediscovery attempt + self.charRediscoveryCount += 1 + DebugLog.shared.log("BLE: Retrying characteristic rediscovery (attempt \(self.charRediscoveryCount)/\(BeaconProvisioner.MAX_CHAR_REDISCOVERY))") + self.scheduleCharRediscoveryTimeout() + self.peripheral?.discoverCharacteristics([ + BeaconProvisioner.DXSMART_NOTIFY_CHAR, + BeaconProvisioner.DXSMART_COMMAND_CHAR, + BeaconProvisioner.DXSMART_PASSWORD_CHAR + ], for: service) + } else { + self.fail("Characteristic rediscovery timed out after \(attempt) attempts", code: .timeout) + } + } + charRediscoveryTimer = timer + DispatchQueue.main.asyncAfter(deadline: .now() + BeaconProvisioner.CHAR_REDISCOVERY_TIMEOUT, execute: timer) + } + + /// Cancel any pending characteristic rediscovery timeout + private func cancelCharRediscoveryTimeout() { + charRediscoveryTimer?.cancel() + charRediscoveryTimer = nil + } + /// Calculate adaptive delay after writing a command. /// Frame selection (0x11-0x16) and type commands (0x61, 0x62) trigger internal /// state changes on the beacon MCU that need extra processing time. @@ -1187,6 +1238,12 @@ extension BeaconProvisioner: CBCentralManagerDelegate { if state == .success || state == .idle { return } + + // Intentional disconnect for FFE2 reconnect — we're already handling reconnect + if state == .connecting && resumeWriteAfterDisconnect { + DebugLog.shared.log("BLE: Intentional disconnect for FFE2 reconnect, ignoring") + return + } if case .failed = state { DebugLog.shared.log("BLE: Disconnect after failure, ignoring") return @@ -1368,6 +1425,7 @@ extension BeaconProvisioner: CBPeripheralDelegate { exploreNextService() } else if charRediscoveryCount > 0 && dxSmartAuthenticated && !dxSmartCommandQueue.isEmpty { // Rediscovery during active write — resume writing directly (already authenticated) + cancelCharRediscoveryTimeout() DebugLog.shared.log("BLE: Characteristics re-discovered after FFE2 miss — resuming write") state = .writing DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in