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:
parent
fc13986396
commit
37e3364e1e
1 changed files with 59 additions and 11 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue