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 return
} }
// Handle write errors for command writes // For command writes (FFE1/FFE2): the .withResponse write confirmation
if let error, let cont = responseContinuation { // 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 responseContinuation = nil
if let error {
cont.resume(throwing: 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 scanTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { [weak self] _ in
Task { @MainActor in DispatchQueue.main.async {
self?.stopScan() self?.stopScan()
} }
} }
@ -235,26 +235,27 @@ final class BLEManager: NSObject, ObservableObject {
extension BLEManager: CBCentralManagerDelegate { extension BLEManager: CBCentralManagerDelegate {
nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) { nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
Task { @MainActor in let state = central.state
bluetoothState = central.state DispatchQueue.main.async { [weak self] in
self?.bluetoothState = state
} }
} }
nonisolated func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { nonisolated func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
Task { @MainActor in DispatchQueue.main.async { [weak self] in
onPeripheralConnected?(peripheral) self?.onPeripheralConnected?(peripheral)
} }
} }
nonisolated func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { nonisolated func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
Task { @MainActor in DispatchQueue.main.async { [weak self] in
onPeripheralFailedToConnect?(peripheral, error) self?.onPeripheralFailedToConnect?(peripheral, error)
} }
} }
nonisolated func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { nonisolated func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
Task { @MainActor in DispatchQueue.main.async { [weak self] in
onPeripheralDisconnected?(peripheral, error) self?.onPeripheralDisconnected?(peripheral, error)
} }
} }
@ -264,13 +265,18 @@ extension BLEManager: CBCentralManagerDelegate {
advertisementData: [String: Any], advertisementData: [String: Any],
rssi RSSI: NSNumber rssi RSSI: NSNumber
) { ) {
Task { @MainActor in // Capture values in nonisolated context before hopping to main
let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "" let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? ""
let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]
let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
let isConnectable = advertisementData[CBAdvertisementDataIsConnectable] as? Bool ?? false 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): // Match Android behavior (lines 164-169):
// Include devices that have a recognized type, OR // Include devices that have a recognized type, OR
@ -278,31 +284,31 @@ extension BLEManager: CBCentralManagerDelegate {
// are connectable with a name (potential configurable beacon) // are connectable with a name (potential configurable beacon)
if type == .unknown { if type == .unknown {
let hasName = !name.isEmpty let hasName = !name.isEmpty
let hasIBeaconData = mfgData.flatMap { parseIBeaconData($0) } != nil let hasIBeaconData = mfgData.flatMap { self.parseIBeaconData($0) } != nil
if !hasIBeaconData && !(isConnectable && hasName) { if !hasIBeaconData && !(isConnectable && hasName) {
return return
} }
} }
if let idx = discoveredBeacons.firstIndex(where: { $0.id == peripheral.identifier }) { if let idx = self.discoveredBeacons.firstIndex(where: { $0.id == peripheralId }) {
// Update existing // Update existing
discoveredBeacons[idx].rssi = RSSI.intValue self.discoveredBeacons[idx].rssi = rssiValue
discoveredBeacons[idx].lastSeen = Date() self.discoveredBeacons[idx].lastSeen = Date()
} else { } else {
// New beacon // New beacon
let beacon = DiscoveredBeacon( let beacon = DiscoveredBeacon(
id: peripheral.identifier, id: peripheralId,
peripheral: peripheral, peripheral: peripheral,
name: name, name: name,
type: type, type: type,
rssi: RSSI.intValue, rssi: rssiValue,
lastSeen: Date() lastSeen: Date()
) )
discoveredBeacons.append(beacon) self.discoveredBeacons.append(beacon)
} }
// Keep list sorted by RSSI (strongest/closest first) // 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 // Monitor for unexpected disconnects during provisioning
bleManager.onPeripheralDisconnected = { [weak provisionLog] peripheral, error in bleManager.onPeripheralDisconnected = { [weak provisionLog] peripheral, error in
if peripheral.identifier == beacon.peripheral.identifier { if peripheral.identifier == beacon.peripheral.identifier {
Task { @MainActor [weak self] in DispatchQueue.main.async { [weak self] in
let reason = error?.localizedDescription ?? "beacon timed out" let reason = error?.localizedDescription ?? "beacon timed out"
provisionLog?.log("disconnect", "Unexpected disconnect: \(reason)", isError: true) provisionLog?.log("disconnect", "Unexpected disconnect: \(reason)", isError: true)
guard let self = self else { return } guard let self = self else { return }