From e387b9ceb12ee55ac3386c293c48574e924a584b Mon Sep 17 00:00:00 2001 From: Schwifty Date: Sat, 21 Mar 2026 22:28:47 +0000 Subject: [PATCH 1/2] fix: handle expected BLE disconnect after SaveConfig command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DX-Smart CP28 beacon reboots after receiving SaveConfig (0x60) to persist config to flash. This drops the BLE connection before the write callback fires, causing the app to report "Unexpected disconnect" even though the config was successfully saved. Now we check if we're on the last command (SaveConfig) when disconnect occurs — if so, treat it as success instead of failure. Co-Authored-By: Luna --- PayfritBeacon/BeaconProvisioner.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/PayfritBeacon/BeaconProvisioner.swift b/PayfritBeacon/BeaconProvisioner.swift index 256903d..89e8bd8 100644 --- a/PayfritBeacon/BeaconProvisioner.swift +++ b/PayfritBeacon/BeaconProvisioner.swift @@ -974,6 +974,11 @@ extension BeaconProvisioner: CBCentralManagerDelegate { } else if state != .success && state != .idle { if case .failed = state { // Already failed + } else if state == .writing && dxSmartWriteIndex >= dxSmartCommandQueue.count - 1 { + // SaveConfig (last command) was sent — beacon rebooted to apply config + // This is expected behavior, treat as success + DebugLog.shared.log("BLE: Disconnect after SaveConfig — treating as success") + succeed() } else { fail("Unexpected disconnect", code: .disconnected) } From c62dace54d5fbef9dc8d6304d70e9aedb357c8e9 Mon Sep 17 00:00:00 2001 From: Schwifty Date: Sat, 21 Mar 2026 22:47:11 +0000 Subject: [PATCH 2/2] fix: handle SaveConfig write-error path + add disconnect diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix only caught the disconnect callback path. But CoreBluetooth can also fire didWriteValueFor with an error when the beacon reboots mid-ATT response. This was hitting fail() at the isNonFatalCommand check instead of being treated as success. Now handles both paths: 1. didDisconnectPeripheral with state=.writing at last command → succeed() 2. didWriteValueFor error for SaveConfig (last command) → succeed() Also added detailed state/index logging to disconnect handler for diagnostics. --- PayfritBeacon/BeaconProvisioner.swift | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/PayfritBeacon/BeaconProvisioner.swift b/PayfritBeacon/BeaconProvisioner.swift index 89e8bd8..420a846 100644 --- a/PayfritBeacon/BeaconProvisioner.swift +++ b/PayfritBeacon/BeaconProvisioner.swift @@ -966,20 +966,22 @@ extension BeaconProvisioner: CBCentralManagerDelegate { } func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { - DebugLog.shared.log("BLE: Disconnected from \(peripheral.name ?? "unknown")") + DebugLog.shared.log("BLE: Disconnected from \(peripheral.name ?? "unknown") | state=\(state) mode=\(operationMode) writeIdx=\(dxSmartWriteIndex) queueCount=\(dxSmartCommandQueue.count) error=\(error?.localizedDescription ?? "none")") if operationMode == .readingConfig { if state != .success && state != .idle { finishRead() } } else if state != .success && state != .idle { if case .failed = state { - // Already failed - } else if state == .writing && dxSmartWriteIndex >= dxSmartCommandQueue.count - 1 { + // Already failed — disconnect is just cleanup + DebugLog.shared.log("BLE: Disconnect after failure, ignoring") + } else if state == .writing && dxSmartCommandQueue.count > 0 && dxSmartWriteIndex >= dxSmartCommandQueue.count - 1 { // SaveConfig (last command) was sent — beacon rebooted to apply config // This is expected behavior, treat as success - DebugLog.shared.log("BLE: Disconnect after SaveConfig — treating as success") + DebugLog.shared.log("BLE: Disconnect after SaveConfig (idx=\(dxSmartWriteIndex)/\(dxSmartCommandQueue.count)) — treating as success") succeed() } else { + DebugLog.shared.log("BLE: UNEXPECTED disconnect — state=\(state) writeIdx=\(dxSmartWriteIndex) queueCount=\(dxSmartCommandQueue.count)") fail("Unexpected disconnect", code: .disconnected) } } @@ -1106,14 +1108,21 @@ extension BeaconProvisioner: CBPeripheralDelegate { // Device name (0x71) and Frame 1 commands (steps 1-6) may be rejected by some firmware // Treat these as non-fatal: log and continue to next command let isNonFatalCommand = dxSmartWriteIndex < 6 // First 6 commands are optional - if isNonFatalCommand { + let isSaveConfig = dxSmartWriteIndex >= dxSmartCommandQueue.count - 1 + if isSaveConfig { + // SaveConfig (0x60) write "error" is expected — beacon reboots immediately + // after processing the save, which kills the BLE connection before the + // ATT write response can be delivered. This is success, not failure. + DebugLog.shared.log("BLE: SaveConfig write error (beacon rebooted) — treating as success") + succeed() + } else if isNonFatalCommand { DebugLog.shared.log("BLE: Non-fatal command failed at step \(dxSmartWriteIndex + 1), continuing...") dxSmartWriteIndex += 1 DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in self?.dxSmartSendNextCommand() } } else { - fail("Command write failed at step \(dxSmartWriteIndex + 1): \(error.localizedDescription)", code: .writeFailed) + fail("Command write failed at step \(dxSmartWriteIndex + 1)/\(dxSmartCommandQueue.count): \(error.localizedDescription)", code: .writeFailed) } } return