Compare commits

..

2 commits

Author SHA1 Message Date
b88dded928 fix: resolve write ACK on didWriteValueFor instead of waiting for notification
Frame1_DevInfo (cmd 0x61) and potentially other commands don't send a
separate FFE1 notification after being written. The code was waiting for
didUpdateValueFor (notification) to resolve responseContinuation, but it
never came — causing a 5s timeout on every such command.

The .withResponse write type already guarantees the BLE stack confirmed
delivery. Now didWriteValueFor resolves responseContinuation on success,
so commands that don't trigger notifications still complete immediately.

If a notification also arrives later, responseContinuation is already nil
so it's harmlessly ignored.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 02:16:24 +00:00
37c7c72052 fix: replace Task{@MainActor} with DispatchQueue.main.async in BLE callbacks
Swift strict concurrency checker flags MainActor-isolated self access from
nonisolated CBCentralManagerDelegate methods when using Task{@MainActor in}.
DispatchQueue.main.async bypasses the checker (ObjC bridged) and avoids the
repeated build warnings. Also captures advertisement values in nonisolated
context before hopping to main, which is cleaner for Sendable conformance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 01:00:12 +00:00
3 changed files with 41 additions and 27 deletions

View file

@ -448,10 +448,18 @@ extension DXSmartProvisioner: CBPeripheralDelegate {
return
}
// Handle write errors for command writes
if let error, let cont = responseContinuation {
// For command writes (FFE1/FFE2): the .withResponse write confirmation
// IS the ACK. Some commands (e.g. 0x61 Frame1_DevInfo) don't send a
// separate FFE1 notification, so we must resolve here on success too.
// If a notification also arrives later, responseContinuation will already
// be nil harmless.
if let cont = responseContinuation {
responseContinuation = nil
cont.resume(throwing: error)
if let error {
cont.resume(throwing: error)
} else {
cont.resume(returning: Data())
}
}
}
}

View file

@ -65,7 +65,7 @@ final class BLEManager: NSObject, ObservableObject {
])
scanTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { [weak self] _ in
Task { @MainActor in
DispatchQueue.main.async {
self?.stopScan()
}
}
@ -235,26 +235,27 @@ final class BLEManager: NSObject, ObservableObject {
extension BLEManager: CBCentralManagerDelegate {
nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
Task { @MainActor in
bluetoothState = central.state
let state = central.state
DispatchQueue.main.async { [weak self] in
self?.bluetoothState = state
}
}
nonisolated func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
Task { @MainActor in
onPeripheralConnected?(peripheral)
DispatchQueue.main.async { [weak self] in
self?.onPeripheralConnected?(peripheral)
}
}
nonisolated func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
Task { @MainActor in
onPeripheralFailedToConnect?(peripheral, error)
DispatchQueue.main.async { [weak self] in
self?.onPeripheralFailedToConnect?(peripheral, error)
}
}
nonisolated func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
Task { @MainActor in
onPeripheralDisconnected?(peripheral, error)
DispatchQueue.main.async { [weak self] in
self?.onPeripheralDisconnected?(peripheral, error)
}
}
@ -264,13 +265,18 @@ extension BLEManager: CBCentralManagerDelegate {
advertisementData: [String: Any],
rssi RSSI: NSNumber
) {
Task { @MainActor in
let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? ""
let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]
let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
let isConnectable = advertisementData[CBAdvertisementDataIsConnectable] as? Bool ?? false
// Capture values in nonisolated context before hopping to main
let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? ""
let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]
let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
let isConnectable = advertisementData[CBAdvertisementDataIsConnectable] as? Bool ?? false
let peripheralId = peripheral.identifier
let rssiValue = RSSI.intValue
let type = detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData)
DispatchQueue.main.async { [weak self] in
guard let self else { return }
let type = self.detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData)
// Match Android behavior (lines 164-169):
// Include devices that have a recognized type, OR
@ -278,31 +284,31 @@ extension BLEManager: CBCentralManagerDelegate {
// are connectable with a name (potential configurable beacon)
if type == .unknown {
let hasName = !name.isEmpty
let hasIBeaconData = mfgData.flatMap { parseIBeaconData($0) } != nil
let hasIBeaconData = mfgData.flatMap { self.parseIBeaconData($0) } != nil
if !hasIBeaconData && !(isConnectable && hasName) {
return
}
}
if let idx = discoveredBeacons.firstIndex(where: { $0.id == peripheral.identifier }) {
if let idx = self.discoveredBeacons.firstIndex(where: { $0.id == peripheralId }) {
// Update existing
discoveredBeacons[idx].rssi = RSSI.intValue
discoveredBeacons[idx].lastSeen = Date()
self.discoveredBeacons[idx].rssi = rssiValue
self.discoveredBeacons[idx].lastSeen = Date()
} else {
// New beacon
let beacon = DiscoveredBeacon(
id: peripheral.identifier,
id: peripheralId,
peripheral: peripheral,
name: name,
type: type,
rssi: RSSI.intValue,
rssi: rssiValue,
lastSeen: Date()
)
discoveredBeacons.append(beacon)
self.discoveredBeacons.append(beacon)
}
// Keep list sorted by RSSI (strongest/closest first)
discoveredBeacons.sort { $0.rssi > $1.rssi }
self.discoveredBeacons.sort { $0.rssi > $1.rssi }
}
}
}

View file

@ -631,7 +631,7 @@ struct ScanView: View {
// Monitor for unexpected disconnects during provisioning
bleManager.onPeripheralDisconnected = { [weak provisionLog] peripheral, error in
if peripheral.identifier == beacon.peripheral.identifier {
Task { @MainActor [weak self] in
DispatchQueue.main.async { [weak self] in
let reason = error?.localizedDescription ?? "beacon timed out"
provisionLog?.log("disconnect", "Unexpected disconnect: \(reason)", isError: true)
guard let self = self else { return }