diff --git a/PayfritBeacon/BeaconProvisioner.swift b/PayfritBeacon/BeaconProvisioner.swift index 6d5464c..232f1de 100644 --- a/PayfritBeacon/BeaconProvisioner.swift +++ b/PayfritBeacon/BeaconProvisioner.swift @@ -470,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) @@ -487,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) @@ -553,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") @@ -590,23 +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 - scheduleCharRediscoveryTimeout() - 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 } @@ -1227,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