fix: resolve FFE2 characteristic lost during write race condition

When CoreBluetooth renegotiates connection parameters (common at edge-of-range),
it can invalidate cached CBCharacteristic references, causing the characteristics
dictionary to return nil mid-write. This resulted in "FFE2 characteristic lost
during write" failures.

Changes:
- Add resolveCharacteristic() with live service fallback when cache misses
- Implement peripheral(_:didModifyServices:) to handle service invalidation
- Replace all direct characteristics[] lookups with resolveCharacteristic()
- Eliminates force-unwrap on FFE1 notification subscribe

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Schwifty 2026-03-22 04:48:04 +00:00
parent fc13986396
commit 37e3364e1e

View file

@ -409,9 +409,9 @@ class BeaconProvisioner: NSObject, ObservableObject {
requiredCharsConfirmed = true requiredCharsConfirmed = true
// Subscribe to FFE1 notifications first (if available), then authenticate // 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") DebugLog.shared.log("BLE: Subscribing to FFE1 notifications")
peripheral?.setNotifyValue(true, for: characteristics[BeaconProvisioner.DXSMART_NOTIFY_CHAR]!) peripheral?.setNotifyValue(true, for: notifyChar)
} else { } else {
DebugLog.shared.log("BLE: FFE1 not found, proceeding to auth after stabilization delay") DebugLog.shared.log("BLE: FFE1 not found, proceeding to auth after stabilization delay")
dxSmartNotifySubscribed = true dxSmartNotifySubscribed = true
@ -425,7 +425,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
/// Write password to FFE3 (tries multiple passwords in sequence) /// Write password to FFE3 (tries multiple passwords in sequence)
private func dxSmartAuthenticate() { 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) fail("FFE3 not found", code: .serviceNotFound)
return return
} }
@ -540,6 +540,29 @@ class BeaconProvisioner: NSObject, ObservableObject {
dxSmartSendNextCommand() 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 /// Send the next command in the queue
private func dxSmartSendNextCommand() { private func dxSmartSendNextCommand() {
guard dxSmartWriteIndex < dxSmartCommandQueue.count else { guard dxSmartWriteIndex < dxSmartCommandQueue.count else {
@ -550,10 +573,9 @@ class BeaconProvisioner: NSObject, ObservableObject {
return return
} }
guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else { guard let commandChar = resolveCharacteristic(BeaconProvisioner.DXSMART_COMMAND_CHAR) else {
// If FFE2 is gone after we already confirmed it, something went very wrong. // If FFE2 can't be resolved even from live services, connection is truly broken.
// Don't try to re-discover fail cleanly. Prevention means we don't get here. fail("FFE2 characteristic lost during write (not recoverable from live services)", code: .writeFailed)
fail("FFE2 characteristic lost during write", code: .writeFailed)
return return
} }
@ -722,8 +744,8 @@ class BeaconProvisioner: NSObject, ObservableObject {
} }
private func dxSmartReadAuth() { private func dxSmartReadAuth() {
guard let passwordChar = characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] else { guard let passwordChar = resolveCharacteristic(BeaconProvisioner.DXSMART_PASSWORD_CHAR) else {
DebugLog.shared.log("BLE: No FFE3 for auth, finishing") DebugLog.shared.log("BLE: No FFE3 for auth (even after live lookup), finishing")
finishRead() finishRead()
return return
} }
@ -770,8 +792,8 @@ class BeaconProvisioner: NSObject, ObservableObject {
return return
} }
guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else { guard let commandChar = resolveCharacteristic(BeaconProvisioner.DXSMART_COMMAND_CHAR) else {
DebugLog.shared.log("BLE: FFE2 not found, finishing read") DebugLog.shared.log("BLE: FFE2 not found (even after live lookup), finishing read")
finishRead() finishRead()
return return
} }
@ -1111,6 +1133,32 @@ extension BeaconProvisioner: CBCentralManagerDelegate {
extension BeaconProvisioner: CBPeripheralDelegate { 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?) { func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
if let error = error { if let error = error {
if operationMode == .readingConfig { if operationMode == .readingConfig {