diff --git a/PayfritBeacon/BeaconProvisioner.swift b/PayfritBeacon/BeaconProvisioner.swift index 64ed005..a58b178 100644 --- a/PayfritBeacon/BeaconProvisioner.swift +++ b/PayfritBeacon/BeaconProvisioner.swift @@ -409,9 +409,9 @@ class BeaconProvisioner: NSObject, ObservableObject { requiredCharsConfirmed = true // Subscribe to FFE1 notifications first (if available), then authenticate - if hasFFE1 { + if hasFFE1, let notifyChar = resolveCharacteristic(BeaconProvisioner.DXSMART_NOTIFY_CHAR) { DebugLog.shared.log("BLE: Subscribing to FFE1 notifications") - peripheral?.setNotifyValue(true, for: characteristics[BeaconProvisioner.DXSMART_NOTIFY_CHAR]!) + peripheral?.setNotifyValue(true, for: notifyChar) } else { DebugLog.shared.log("BLE: FFE1 not found, proceeding to auth after stabilization delay") dxSmartNotifySubscribed = true @@ -425,7 +425,7 @@ class BeaconProvisioner: NSObject, ObservableObject { /// Write password to FFE3 (tries multiple passwords in sequence) private func dxSmartAuthenticate() { - guard let passwordChar = characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] else { + guard let passwordChar = resolveCharacteristic(BeaconProvisioner.DXSMART_PASSWORD_CHAR) else { fail("FFE3 not found", code: .serviceNotFound) return } @@ -540,6 +540,29 @@ class BeaconProvisioner: NSObject, ObservableObject { dxSmartSendNextCommand() } + /// Resolve a characteristic from our cache, falling back to live service lookup. + /// CoreBluetooth can invalidate cached CBCharacteristic references during connection + /// parameter renegotiation (common at edge-of-range). When that happens, our dictionary + /// entry goes stale. This method re-resolves from the peripheral's live service list. + private func resolveCharacteristic(_ uuid: CBUUID) -> CBCharacteristic? { + // Fast path: cached reference is still valid + if let cached = characteristics[uuid] { + return cached + } + + // Fallback: walk the peripheral's live services to find it + guard let services = peripheral?.services else { return nil } + for service in services { + guard let chars = service.characteristics else { continue } + for char in chars where char.uuid == uuid { + DebugLog.shared.log("BLE: Re-resolved \(uuid) from live service \(service.uuid)") + characteristics[uuid] = char // Re-cache for next lookup + return char + } + } + return nil + } + /// Send the next command in the queue private func dxSmartSendNextCommand() { guard dxSmartWriteIndex < dxSmartCommandQueue.count else { @@ -550,10 +573,9 @@ class BeaconProvisioner: NSObject, ObservableObject { return } - guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else { - // If FFE2 is gone after we already confirmed it, something went very wrong. - // Don't try to re-discover — fail cleanly. Prevention means we don't get here. - fail("FFE2 characteristic lost during write", code: .writeFailed) + guard let commandChar = resolveCharacteristic(BeaconProvisioner.DXSMART_COMMAND_CHAR) else { + // If FFE2 can't be resolved even from live services, connection is truly broken. + fail("FFE2 characteristic lost during write (not recoverable from live services)", code: .writeFailed) return } @@ -722,8 +744,8 @@ class BeaconProvisioner: NSObject, ObservableObject { } private func dxSmartReadAuth() { - guard let passwordChar = characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] else { - DebugLog.shared.log("BLE: No FFE3 for auth, finishing") + guard let passwordChar = resolveCharacteristic(BeaconProvisioner.DXSMART_PASSWORD_CHAR) else { + DebugLog.shared.log("BLE: No FFE3 for auth (even after live lookup), finishing") finishRead() return } @@ -770,8 +792,8 @@ class BeaconProvisioner: NSObject, ObservableObject { return } - guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else { - DebugLog.shared.log("BLE: FFE2 not found, finishing read") + guard let commandChar = resolveCharacteristic(BeaconProvisioner.DXSMART_COMMAND_CHAR) else { + DebugLog.shared.log("BLE: FFE2 not found (even after live lookup), finishing read") finishRead() return } @@ -1111,6 +1133,32 @@ extension BeaconProvisioner: CBCentralManagerDelegate { extension BeaconProvisioner: CBPeripheralDelegate { + /// Handle service invalidation — CoreBluetooth calls this when the remote device's + /// GATT database changes (e.g., connection parameter renegotiation at edge-of-range). + /// Invalidated services have their characteristics wiped. We clear our cache and + /// re-discover so resolveCharacteristic() can find them again. + func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { + let uuids = invalidatedServices.map { $0.uuid.uuidString } + DebugLog.shared.log("BLE: Services invalidated: \(uuids)") + + // Clear cached characteristics for invalidated services + for service in invalidatedServices { + if let chars = service.characteristics { + for char in chars { + characteristics.removeValue(forKey: char.uuid) + } + } + } + + // If our config service was invalidated, re-discover it + let invalidatedUUIDs = invalidatedServices.map { $0.uuid } + if invalidatedUUIDs.contains(BeaconProvisioner.DXSMART_SERVICE) { + DebugLog.shared.log("BLE: FFE0 service invalidated — re-discovering") + configService = nil + peripheral.discoverServices([BeaconProvisioner.DXSMART_SERVICE]) + } + } + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { if let error = error { if operationMode == .readingConfig {