diff --git a/Package.swift b/Package.swift index dcd027a3..82b79b5c 100644 --- a/Package.swift +++ b/Package.swift @@ -32,8 +32,8 @@ let package = Package( .library(name: "SpeziBluetooth", targets: ["SpeziBluetooth"]) ], dependencies: [ - .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "1.1.0"), - .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.6.0"), + .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "2.0.0-beta.1"), + .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.7.1"), .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.1.0"), .package(url: "https://github.com/apple/swift-nio.git", from: "2.59.0"), .package(url: "https://github.com/apple/swift-collections.git", from: "1.0.4"), @@ -55,7 +55,8 @@ let package = Package( .process("Resources") ], swiftSettings: [ - swiftConcurrency + swiftConcurrency, + .enableUpcomingFeature("InferSendableFromCaptures") ], plugins: [] + swiftLintPlugin() ), @@ -123,7 +124,7 @@ func swiftLintPlugin() -> [Target.PluginUsage] { func swiftLintPackage() -> [PackageDescription.Package.Dependency] { if ProcessInfo.processInfo.environment["SPEZI_DEVELOPMENT_SWIFTLINT"] != nil { - [.package(url: "https://github.com/realm/SwiftLint.git", .upToNextMinor(from: "0.55.1"))] + [.package(url: "https://github.com/realm/SwiftLint.git", from: "0.55.1")] } else { [] } diff --git a/Sources/SpeziBluetooth/Bluetooth.swift b/Sources/SpeziBluetooth/Bluetooth.swift index 6e28f055..440641c6 100644 --- a/Sources/SpeziBluetooth/Bluetooth.swift +++ b/Sources/SpeziBluetooth/Bluetooth.swift @@ -293,10 +293,10 @@ public final class Bluetooth: Module, EnvironmentAccessible, Sendable { /// Devices might be part of `nearbyDevices` as well or just retrieved devices that are eventually connected. /// Values are stored weakly. All properties (like `@Characteristic`, `@DeviceState` or `@DeviceAction`) store a reference to `Bluetooth` and report once they are de-initialized /// to clear the respective initialized devices from this dictionary. - @SpeziBluetooth private var initializedDevices: OrderedDictionary = [:] + private var initializedDevices: OrderedDictionary = [:] @Application(\.spezi) - @MainActor private var spezi + private var spezi private nonisolated var logger: Logger { Self.logger @@ -389,7 +389,7 @@ public final class Bluetooth: Module, EnvironmentAccessible, Sendable { } else { let advertisementData = entry.value.advertisementData guard let configuration = configuration.find(for: advertisementData, logger: logger) else { - logger.warning("Ignoring peripheral \(entry.value.debugDescription) that cannot be mapped to a device class.") + logger.warning("Ignoring peripheral \(entry.value) that cannot be mapped to a device class.") return } @@ -402,6 +402,7 @@ public final class Bluetooth: Module, EnvironmentAccessible, Sendable { } + let spezi = spezi Task { @MainActor [newlyPreparedDevices] in var checkForConnected = false @@ -548,6 +549,7 @@ public final class Bluetooth: Module, EnvironmentAccessible, Sendable { let device = prepareDevice(id: uuid, Device.self, peripheral: peripheral) // We load the module with external ownership. Meaning, Spezi won't keep any strong references to the Module and deallocation of // the module is possible, freeing all Spezi related resources. + let spezi = spezi await spezi.loadModule(device, ownership: .external) // The semantics of retrievePeripheral is as follows: it returns a BluetoothPeripheral that is weakly allocated by the BluetoothManager.ยด diff --git a/Sources/SpeziBluetooth/Configuration/Discover.swift b/Sources/SpeziBluetooth/Configuration/Discover.swift index ff183320..b80402e1 100644 --- a/Sources/SpeziBluetooth/Configuration/Discover.swift +++ b/Sources/SpeziBluetooth/Configuration/Discover.swift @@ -36,3 +36,6 @@ public struct Discover { self.discoveryCriteria = discoveryCriteria } } + + +extension Discover: Sendable {} diff --git a/Sources/SpeziBluetooth/Configuration/DiscoveryDescriptorBuilder.swift b/Sources/SpeziBluetooth/Configuration/DiscoveryDescriptorBuilder.swift index 40f7c83d..583393fd 100644 --- a/Sources/SpeziBluetooth/Configuration/DiscoveryDescriptorBuilder.swift +++ b/Sources/SpeziBluetooth/Configuration/DiscoveryDescriptorBuilder.swift @@ -12,7 +12,7 @@ public enum DiscoveryDescriptorBuilder { /// Build a ``Discover`` expression to define a ``DeviceDiscoveryDescriptor``. public static func buildExpression(_ expression: Discover) -> Set { - [DeviceDiscoveryDescriptor(discoveryCriteria: expression.discoveryCriteria, deviceType: expression.deviceType)] + [DeviceDiscoveryDescriptor(discoveryCriteria: expression.discoveryCriteria, deviceType: Device.self)] } /// Build a block of ``DeviceDiscoveryDescriptor``s. diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift index 3c3b37ce..0aa83dde 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -6,7 +6,6 @@ // SPDX-License-Identifier: MIT // -@preconcurrency import class CoreBluetooth.CBCentralManager // swiftlint:disable:this duplicate_imports import CoreBluetooth import NIO import Observation @@ -84,15 +83,15 @@ import OSLog public class BluetoothManager: Observable, Sendable, Identifiable { // swiftlint:disable:this type_body_length private let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "BluetoothManager") - private var _centralManager: CBCentralManager? + private var _centralManager: CBInstance? private var centralManager: CBCentralManager { guard let centralManager = _centralManager else { let centralManager = supplyCBCentral() - self._centralManager = centralManager + self._centralManager = CBInstance(instantiatedOnDispatchQueue: centralManager) return centralManager } - return centralManager + return centralManager.cbObject } private lazy var centralDelegate: Delegate = { // swiftlint:disable:this weak_delegate @@ -472,7 +471,7 @@ public class BluetoothManager: Observable, Sendable, Identifiable { // swiftlint } func connect(peripheral: BluetoothPeripheral) { - logger.debug("Trying to connect to \(peripheral.debugDescription) ...") + logger.debug("Trying to connect to \(peripheral) ...") let cancelled = discoverySession?.cancelStaleTask(for: peripheral) @@ -484,18 +483,18 @@ public class BluetoothManager: Observable, Sendable, Identifiable { // swiftlint } func disconnect(peripheral: BluetoothPeripheral) { - logger.debug("Disconnecting peripheral \(peripheral.debugDescription) ...") + logger.debug("Disconnecting peripheral \(peripheral) ...") // stale timer is handled in the delegate method centralManager.cancelPeripheralConnection(peripheral.cbPeripheral) discoverySession?.deviceManuallyDisconnected(id: peripheral.id) } - private func handledConnected(device: BluetoothPeripheral) { - device.handleConnect() - + private func handledConnected(device: BluetoothPeripheral) async { // we might have connected a bluetooth peripheral that was weakly referenced ensurePeripheralReference(device) + + await device.handleConnect() } private func discardDevice(device: BluetoothPeripheral, error: Error?) { @@ -522,7 +521,7 @@ public class BluetoothManager: Observable, Sendable, Identifiable { // swiftlint Task { @SpeziBluetooth [storage, _centralManager, isScanning, logger] in if isScanning { storage.isScanning = false - _centralManager?.stopScan() + _centralManager?.cbObject.stopScan() logger.debug("Scanning stopped") } @@ -575,6 +574,8 @@ extension BluetoothManager { static let defaultMinimumRSSI = -80 /// The default time in seconds after which we check for auto connectable devices after the initial advertisement. static let defaultAutoConnectDebounce: Int = 1 + /// The amount of times we try to automatically (if enabled) subscribe to a notify characteristic. + static let autoSubscribeAttempts = 3 } } @@ -673,7 +674,7 @@ extension BluetoothManager { return } - logger.debug("Discovered peripheral \(peripheral.debugIdentifier) at \(rssi.intValue) dB (data: \(String(describing: data))") + logger.debug("Discovered peripheral \(peripheral.debugIdentifier) at \(rssi.intValue) dB with data \(data)") let descriptor = session.configuredDevices.find(for: data, logger: logger) @@ -705,10 +706,10 @@ extension BluetoothManager { return } - logger.debug("Peripheral \(peripheral.debugIdentifier) connected.") - manager.handledConnected(device: device) - + logger.debug("Peripheral \(device) connected.") await manager.storage.cbDelegateSignal(connected: true, for: peripheral.identifier) + + await manager.handledConnected(device: device) } } @@ -730,9 +731,9 @@ extension BluetoothManager { } if let error { - logger.error("Failed to connect to \(peripheral.debugDescription): \(error)") + logger.error("Failed to connect to \(device): \(error)") } else { - logger.error("Failed to connect to \(peripheral.debugDescription)") + logger.error("Failed to connect to \(device)") } // just to make sure @@ -756,9 +757,9 @@ extension BluetoothManager { } if let error { - logger.debug("Peripheral \(peripheral.debugIdentifier) disconnected due to an error: \(error)") + logger.debug("Peripheral \(device) disconnected due to an error: \(error)") } else { - logger.debug("Peripheral \(peripheral.debugIdentifier) disconnected.") + logger.debug("Peripheral \(device) disconnected.") } manager.discardDevice(device: device, error: error) diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift index 8856b25c..d45bd073 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift @@ -46,6 +46,7 @@ import SpeziFoundation /// /// ### Notifications and handling changes /// - ``enableNotifications(_:serviceId:characteristicId:)`` +/// - ``setNotifications(_:for:)`` /// - ``registerOnChangeHandler(service:characteristic:_:)`` /// - ``registerOnChangeHandler(for:_:)`` /// - ``OnChangeRegistration`` @@ -66,29 +67,25 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length /// Observable state container for local state. private let storage: PeripheralStorage - /// Protecting concurrent access to an ongoing connect attempt. - private let connectAccess = AsyncSemaphore() - /// Continuation for a currently ongoing connect attempt. - private var connectContinuation: CheckedContinuation? - /// Ongoing accessed per characteristic. - private var characteristicAccesses = CharacteristicAccesses() - /// Protecting concurrent access to an ongoing write without response. - private let writeWithoutResponseAccess = AsyncSemaphore() - /// Continuation for the current write without response access. - private var writeWithoutResponseContinuation: CheckedContinuation? - /// Protecting concurrent access to an ongoing rssi read access. - private let rssiAccess = AsyncSemaphore() - /// Continuation for a currently ongoing rssi read access. - private var rssiContinuation: CheckedContinuation? + /// Manage asynchronous accesses for an ongoing connection attempt. + private let connectAccess = ManagedAsynchronousAccess() + /// Manage asynchronous accesses per characteristic. + private let characteristicAccesses = CharacteristicAccesses() + /// Manage asynchronous accesses for an ongoing writhe without response. + private let writeWithoutResponseAccess = ManagedAsynchronousAccess() + /// Manage asynchronous accesses for the rssi read action. + private let rssiAccess = ManagedAsynchronousAccess() + /// Manage asynchronous accesses for service discovery. + private let discoverServicesAccess = ManagedAsynchronousAccess<[BTUUID], Error>() + /// Manage asynchronous accesses for characteristic discovery of a given service. + private var discoverCharacteristicAccesses: [BTUUID: ManagedAsynchronousAccess] = [:] /// On-change handler registrations for all characteristics. private var onChangeHandlers: [CharacteristicLocator: [UUID: CharacteristicOnChangeHandler]] = [:] /// The list of characteristics that are requested to enable notifications. private var notifyRequested: Set = [] - - - /// A set of service ids we are currently awaiting characteristics discovery for - private var servicesAwaitingCharacteristicsDiscovery: Set = [] + /// A set of characteristics identifier which is populated while the initial value is being read. + private var currentlyReadingInitialValue: Set = [] /// The internally managed identifier for the peripheral. public nonisolated let id: UUID @@ -122,7 +119,7 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length /// /// Services are discovered automatically upon connection public var services: [GATTService]? { // swiftlint:disable:this discouraged_optional_collection - storage.services + storage.services.map { Array($0.values) } } /// The last device activity. @@ -183,7 +180,9 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length /// Establish a connection to the peripheral and wait until it is connected. /// - /// Make a connection to the peripheral. + /// Make a connection to the peripheral. The method returns once the device is connected and fully discovered according to + /// the ``DeviceDescription`` (e.g., enabling notifications for certain characteristics). + /// If service or characteristic discovery fails, this method will throw the respective error and automatically disconnect the device. /// /// - Note: You might want to verify via the ``AdvertisementData/isConnectable`` property that the device is connectable. public func connect() async throws { @@ -192,17 +191,15 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length return } - try await connectAccess.waitCheckingCancellation() - try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - assert(connectContinuation == nil, "connectContinuation was unexpectedly not nil") - connectContinuation = continuation + try await connectAccess.perform { manager.connect(peripheral: self) } } onCancel: { Task { @SpeziBluetooth in - disconnect() + if connectAccess.isRunning { + disconnect() + } } } } @@ -227,9 +224,7 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length /// - Parameter id: The Bluetooth service id. /// - Returns: The service instance if present. public func getService(id: BTUUID) -> GATTService? { - services?.first { service in - service.uuid == id - } + storage.services?[id] } /// Retrieve a characteristic. @@ -245,19 +240,174 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length storage.onChange(of: keyPath, perform: closure) } - func handleConnect() { + func isReadingInitialValue(for characteristicId: BTUUID, on serviceId: BTUUID) -> Bool { + let locator = CharacteristicLocator(serviceId: serviceId, characteristicId: characteristicId) + return currentlyReadingInitialValue.contains(locator) + } + + func handleConnect() async { // ensure that it is updated instantly. storage.update(state: PeripheralState(from: cbPeripheral.state)) - logger.debug("Discovering services for \(self.cbPeripheral.debugIdentifier) ...") + logger.debug("Discovering services for \(self) ...") let serviceIds = configuration.services?.reduce(into: Set()) { result, description in - result.insert(description.serviceId.cbuuid) + result.insert(description.serviceId) + } + + do { + let discoveredServices = try await self.discoverServices(serviceIds) + let serviceDiscoveries = try await discoverCharacteristics(for: discoveredServices) + + // handle auto-subscribe and discover descriptors if descriptions exist + try await withThrowingDiscardingTaskGroup { group in + for (service, descriptions) in serviceDiscoveries { + group.addTask { @Sendable @SpeziBluetooth in + try await self.enableNotificationsForDiscoveredCharacteristics(for: service) + } + + if let descriptions { + group.addTask { @Sendable @SpeziBluetooth in + try await self.handleDiscoveredCharacteristic(descriptions, for: service) + } + } + } + } + } catch { + logger.error("Failed to discover initial services: \(error)") + connectAccess.resume(throwing: error) + disconnect() + return } - - if let serviceIds, serviceIds.isEmpty { - signalFullyDiscovered() + + storage.signalFullyDiscovered() + connectAccess.resume() + } + + private func discoverServices(_ services: Set?) async throws -> [BTUUID] { // swiftlint:disable:this discouraged_optional_collection + let cbServiceIds = services.map { $0.map { $0.cbuuid } } + + if let services { + logger.debug("Discovering services for peripheral \(self): \(services)") } else { - cbPeripheral.discoverServices(serviceIds.map { Array($0) }) + logger.debug("Discovering all services for peripheral \(self)") + } + + return try await discoverServicesAccess.perform { + cbPeripheral.discoverServices(cbServiceIds) + } + } + + private func discoverCharacteristics( + for discoveredServices: [BTUUID] + ) async throws -> [(service: GATTService, characteristics: Set?)] { + // swiftlint:disable:previous discouraged_optional_collection + + // swiftlint:disable:next discouraged_optional_collection + let discoveryJobs: [(service: GATTService, characteristics: Set?)] = discoveredServices + .reduce(into: []) { partialResult, serviceId in + guard let service = getService(id: serviceId), + let serviceDescription = configuration.description(for: serviceId) else { + return + } + + partialResult.append((service, serviceDescription.characteristics)) + } + + try await withThrowingTaskGroup(of: Void.self) { group in + for job in discoveryJobs { + group.addTask { @Sendable @SpeziBluetooth in + let characteristicIds = job.characteristics.map { Set($0.map { $0.characteristicId }) } + try await self.discoverCharacteristic(characteristicIds, for: job.service) + } + } + + try await group.waitForAll() + } + + return discoveryJobs + } + + private func discoverCharacteristic(_ characteristics: Set?, for service: GATTService) async throws { + // swiftlint:disable:previous discouraged_optional_collection + let cbCharacteristicIds = characteristics.map { Array($0.map { $0.cbuuid }) } + + if let characteristics { + logger.debug("Discovering characteristics on \(service) for peripheral \(self): \(characteristics)") + } else { + logger.debug("Discovering all characteristics on \(service) for peripheral \(self)") + } + + let access: ManagedAsynchronousAccess + if let existing = discoverCharacteristicAccesses[service.id] { + access = existing + } else { + access = .init() + discoverCharacteristicAccesses[service.id] = access + } + + try await access.perform { + cbPeripheral.discoverCharacteristics(cbCharacteristicIds, for: service.underlyingService) + } + } + + private func handleDiscoveredCharacteristic(_ descriptions: Set, for service: GATTService) async throws { + try await withThrowingDiscardingTaskGroup { group in + for description in descriptions { + guard let characteristic = getCharacteristic(id: description.characteristicId, on: service.id) else { + continue + } + + // pull initial value if none is present + if description.autoRead && characteristic.value == nil && characteristic.properties.contains(.read) { + group.addTask { @Sendable @SpeziBluetooth in + let locator = CharacteristicLocator(serviceId: service.id, characteristicId: characteristic.id) + let (inserted, _) = self.currentlyReadingInitialValue.insert(locator) + do { + _ = try await self.read(characteristic: characteristic) + } catch { + self.logger.warning("Failed to read the initial value of \(characteristic): \(error)") + } + if inserted { + self.currentlyReadingInitialValue.remove(locator) + } + } + } + + if description.discoverDescriptors { + logger.debug("Discovering descriptors for \(characteristic)...") + // Currently descriptor interactions aren't really supported by SpeziBluetooth. However, we support the initial + // discovery of descriptors. Therefore, it is fine that this operation is currently not made fully async. + cbPeripheral.discoverDescriptors(for: characteristic.underlyingCharacteristic) + } + } + } + } + + private func enableNotificationsForDiscoveredCharacteristics(for service: GATTService) async throws { + try await withThrowingDiscardingTaskGroup { group in + for characteristic in service.characteristics { + guard characteristic.properties.supportsNotifications, + didRequestNotifications(serviceId: service.id, characteristicId: characteristic.id) else { + continue + } + + group.addTask { @Sendable @SpeziBluetooth in + self.logger.debug("Automatically subscribing to discovered characteristic \(characteristic.id) on \(service.id)...") + + var attempts = BluetoothManager.Defaults.autoSubscribeAttempts + while true { + do { + try await self.setNotifications(true, for: characteristic) + break + } catch { + attempts -= 1 + if attempts <= 0 { + throw error + } + } + } + } + } } } @@ -268,29 +418,21 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length // clear all the ongoing access - self.servicesAwaitingCharacteristicsDiscovery.removeAll() - - if let services { - self.invalidateServices(Set(services.map { $0.uuid })) + if let serviceIds = storage.services?.keys { + self.invalidateServices(Set(serviceIds)) } - connectAccess.cancelAll() + connectAccess.cancelAll(error: error) writeWithoutResponseAccess.cancelAll() - rssiAccess.cancelAll() + rssiAccess.cancelAll(error: error) + discoverServicesAccess.cancelAll(error: error) characteristicAccesses.cancelAll(disconnectError: error) - if let connectContinuation { - self.connectContinuation = nil - connectContinuation.resume(throwing: error ?? CancellationError()) - } - if let writeWithoutResponseContinuation { - self.writeWithoutResponseContinuation = nil - writeWithoutResponseContinuation.resume() - } - if let rssiContinuation { - self.rssiContinuation = nil - rssiContinuation.resume(throwing: error ?? CancellationError()) + let discoverCharacteristicAccesses = discoverCharacteristicAccesses + self.discoverCharacteristicAccesses.removeAll() + for access in discoverCharacteristicAccesses.values { + access.cancelAll(error: error) } } @@ -333,10 +475,10 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length _ onChange: @escaping (Data) -> Void ) throws -> OnChangeRegistration { guard let service = characteristic.service else { - throw BluetoothError.notPresent(service: nil, characteristic: characteristic.uuid) + throw BluetoothError.notPresent(service: nil, characteristic: characteristic.id) } - return registerOnChangeHandler(service: service.uuid, characteristic: characteristic.uuid, onChange) + return registerOnChangeHandler(service: service.id, characteristic: characteristic.id, onChange) } /// Register a on-change handler for a characteristic. @@ -381,9 +523,17 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length return OnChangeRegistration(peripheral: self, locator: locator, handlerId: id) } + func deregisterOnChange(_ registration: OnChangeRegistration) { + deregisterOnChange(locator: registration.locator, handlerId: registration.handlerId) + } + + func deregisterOnChange(locator: CharacteristicLocator, handlerId: UUID) { + onChangeHandlers[locator]?.removeValue(forKey: handlerId) + } + /// Enable or disable notifications for a given characteristic. /// - /// - Tip: It is not required that the device is connected. Notifications will be automatically enabled for the + /// It is not required that the device is connected. Notifications will be automatically enabled for the /// respective characteristic upon device discovery. /// /// - Parameters: @@ -401,7 +551,15 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length } // if setting notify doesn't work here, we do it upon discovery of the characteristics - trySettingNotifyValue(enabled, serviceId: serviceId, characteristicId: characteristicId) + guard let characteristic = getCharacteristic(id: characteristicId, on: serviceId) else { + return + } + + if characteristic.properties.supportsNotifications { + Task { + try? await setNotifications(enabled, for: characteristic) + } + } } func didRequestNotifications(serviceId: BTUUID, characteristicId: BTUUID) -> Bool { @@ -409,24 +567,22 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length return notifyRequested.contains(id) } - func deregisterOnChange(_ registration: OnChangeRegistration) { - deregisterOnChange(locator: registration.locator, handlerId: registration.handlerId) - } - - func deregisterOnChange(locator: CharacteristicLocator, handlerId: UUID) { - onChangeHandlers[locator]?.removeValue(forKey: handlerId) - } - - private func trySettingNotifyValue(_ notify: Bool, serviceId: BTUUID, characteristicId: BTUUID) { - guard let characteristic = getCharacteristic(id: characteristicId, on: serviceId) else { - return - } - - if characteristic.properties.supportsNotifications { - cbPeripheral.setNotifyValue(notify, for: characteristic.underlyingCharacteristic) + /// Set notification value for a given characteristic. + /// + /// In contrast to ``enableNotifications(_:serviceId:characteristicId:)`` this method instantly sends the command to the peripheral and awaits the response. + /// Therefore, the device must be connected when calling this method. + /// + /// - Parameters: + /// - enabled: Enable or disable notifications. + /// - characteristic: The characteristic for which to enable notifications. + public func setNotifications(_ enabled: Bool, for characteristic: GATTCharacteristic) async throws { + try await characteristicAccesses.performNotify(for: characteristic.underlyingCharacteristic) { + cbPeripheral.setNotifyValue(enabled, for: characteristic.underlyingCharacteristic) } } + /// Reset all notification values back to `false`. + /// /// Call this when things either go wrong, or you're done with the connection. /// This cancels any subscriptions if there are any, or straight disconnects if not. /// (didUpdateNotificationStateForCharacteristic will cancel the connection if a subscription is involved) @@ -453,16 +609,10 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length /// - Parameters: /// - data: The value to write. /// - characteristic: The characteristic to which the value is written. - /// - Returns: The response from the device. /// - Throws: Throws an `CBError` or `CBATTError` if the write fails. public func write(data: Data, for characteristic: GATTCharacteristic) async throws { - let characteristic = characteristic.underlyingCharacteristic - let access = characteristicAccesses.makeAccess(for: characteristic) - try await access.waitCheckingCancellation() - - try await withCheckedThrowingContinuation { continuation in - access.store(.write(continuation)) - cbPeripheral.writeValue(data, for: characteristic, type: .withResponse) + try await characteristicAccesses.performWrite(for: characteristic.underlyingCharacteristic) { + cbPeripheral.writeValue(data, for: characteristic.underlyingCharacteristic, type: .withResponse) } } @@ -478,17 +628,13 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length /// - characteristic: The characteristic to which the value is written. public func writeWithoutResponse(data: Data, for characteristic: GATTCharacteristic) async { do { - try await writeWithoutResponseAccess.waitCheckingCancellation() + try await writeWithoutResponseAccess.perform { + cbPeripheral.writeValue(data, for: characteristic.underlyingCharacteristic, type: .withoutResponse) + } } catch { // task got cancelled, so just throw away the written value return } - - await withCheckedContinuation { continuation in - assert(writeWithoutResponseContinuation == nil, "writeWithoutResponseAccess was unexpectedly not nil") - writeWithoutResponseContinuation = continuation - cbPeripheral.writeValue(data, for: characteristic.underlyingCharacteristic, type: .withoutResponse) - } } /// Read the value of a characteristic. @@ -499,14 +645,8 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length /// - Returns: The value that the peripheral was returned. /// - Throws: Throws an `CBError` or `CBATTError` if the read fails. public func read(characteristic: GATTCharacteristic) async throws -> Data { - let characteristic = characteristic.underlyingCharacteristic - - let access = characteristicAccesses.makeAccess(for: characteristic) - try await access.waitCheckingCancellation() - - return try await withCheckedThrowingContinuation { continuation in - access.store(.read(continuation)) - cbPeripheral.readValue(for: characteristic) + try await characteristicAccesses.performRead(for: characteristic.underlyingCharacteristic) { + cbPeripheral.readValue(for: characteristic.underlyingCharacteristic) } } @@ -516,11 +656,7 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length /// - Returns: The read rssi value. /// - Throws: Throws an `CBError` or `CBATTError` if the read fails. public func readRSSI() async throws -> Int { - try await rssiAccess.waitCheckingCancellation() - - return try await withCheckedThrowingContinuation { continuation in - assert(rssiContinuation == nil, "rssiAccess was unexpectedly not nil") - rssiContinuation = continuation + try await rssiAccess.perform { cbPeripheral.readRSSI() } } @@ -545,7 +681,7 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length } for characteristic in changeProtocol.updatedCharacteristics { - let locator = CharacteristicLocator(serviceId: uuid, characteristicId: characteristic.uuid) + let locator = CharacteristicLocator(serviceId: uuid, characteristicId: characteristic.id) for handler in onChangeHandlers[locator, default: [:]].values { if case let .instance(onChange) = handler { onChange(characteristic) @@ -565,25 +701,18 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length } private func invalidateServices(_ ids: Set) { - guard let services else { + guard storage.services != nil else { return } - for (index, service) in zip(services.indices, services).reversed() { - guard ids.contains(service.uuid) else { + for id in ids { + guard let service = storage.services?.removeValue(forKey: id) else { continue } - // Note: we iterate over the zipped array in reverse such that the indices stay valid if remove elements - - // the service was invalidated! - var services = self.services - services?.remove(at: index) - self.storage.services = services - // make sure we notify subscribed handlers about removed services! for characteristic in service.characteristics { - let locator = CharacteristicLocator(serviceId: service.uuid, characteristicId: characteristic.uuid) + let locator = CharacteristicLocator(serviceId: service.id, characteristicId: characteristic.id) for handler in onChangeHandlers[locator, default: [:]].values { if case let .instance(onChange) = handler { onChange(nil) // signal removed characteristic! @@ -594,21 +723,26 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length } private func discovered(services: [CBService]) { - // ids of currently maintained ids - let existingServices = Set(self.services?.map { $0.uuid } ?? []) + let discoveredIds = Set(services.map { BTUUID(from: $0.uuid) }) + let removedServiceIds = self.storage.services?.keys.filter { uuid in + !discoveredIds.contains(uuid) + } - // if we re-discover services (e.g., if ones got invalidated), services might still be present. So only add new ones - let addedServices = services - .filter { !existingServices.contains(BTUUID(from: $0.uuid)) } - .map { - // we will discover characteristics for all services after that. - GATTService(service: $0) - } + if let removedServiceIds { + invalidateServices(Set(removedServiceIds)) + } + + let discoveredServices: [BTUUID: GATTService] = services.reduce(into: [:]) { partialResult, cbService in + let service = GATTService(service: cbService) + partialResult[service.id] = service + } - if let services = self.services { - storage.services = services + addedServices + if let services = self.storage.services { + storage.services = services.merging(discoveredServices) { previous, _ in + previous // just discard service instances that would override previous instance! + } } else { - storage.services = addedServices + storage.services = discoveredServices } } @@ -623,7 +757,7 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length self.logger.debug("Device \(id), \(name ?? "unnamed") was de-initialized...") - Task.detached { @SpeziBluetooth [storage, nearby] in + Task.detached { @Sendable @SpeziBluetooth [storage, nearby] in if nearby { // make sure signal is sent storage.nearby = false } @@ -636,103 +770,20 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length // MARK: Delegate Accessors extension BluetoothPeripheral { - private func discovered(service: CBService) { - guard let characteristics = service.characteristics else { - logger.warning("Characteristic discovery for service \(service.uuid) resulted in an empty list.") - return - } - - logger.debug("Discovered \(characteristics.count) characteristic(s) for service \(service.uuid): \(characteristics)") - - // automatically subscribe to discovered characteristics for which we have a handler subscribed! - for characteristic in characteristics { - let serviceId = BTUUID(from: service.uuid) - let characteristicId = BTUUID(from: characteristic.uuid) - - let description = configuration.description(for: serviceId)?.description(for: characteristicId) - - // pull initial value if none is present - if description?.autoRead != false && characteristic.value == nil && characteristic.properties.contains(.read) { - cbPeripheral.readValue(for: characteristic) - } - - // enable notifications if registered - if characteristic.properties.supportsNotifications { - let locator = CharacteristicLocator(serviceId: serviceId, characteristicId: characteristicId) - - if notifyRequested.contains(locator) { - logger.debug("Automatically subscribing to discovered characteristic \(locator)...") - cbPeripheral.setNotifyValue(true, for: characteristic) - } - } - - if description?.discoverDescriptors == true { - logger.debug("Discovering descriptors for \(characteristic.debugIdentifier)...") - cbPeripheral.discoverDescriptors(for: characteristic) - } - } - } - - private func signalFullyDiscovered() { - storage.signalFullyDiscovered() - - if let connectContinuation { - connectContinuation.resume() - self.connectContinuation = nil - connectAccess.signal() // balance async semaphore. - } - } - private func receivedUpdatedValue(for characteristic: CBCharacteristic, result: Result) { - if let access = characteristicAccesses.retrieveAccess(for: characteristic), - case let .read(continuation) = access.value { - if case let .failure(error) = result { - logger.debug("Characteristic read for \(characteristic.debugIdentifier) returned with error: \(error)") - } - - access.consume() - continuation.resume(with: result) - } else if case let .failure(error) = result { - logger.debug("Received unsolicited value update error for \(characteristic.debugIdentifier): \(error)") - } - - // notification handling - guard case let .success(data) = result else { - return - } + if case let .success(data) = result, + let service = characteristic.service { + let locator = CharacteristicLocator(serviceId: BTUUID(from: service.uuid), characteristicId: BTUUID(from: characteristic.uuid)) - guard let service = characteristic.service else { - logger.warning("Received updated value for characteristic \(characteristic.debugIdentifier) without associated service!") - return - } - - let locator = CharacteristicLocator(serviceId: BTUUID(from: service.uuid), characteristicId: BTUUID(from: characteristic.uuid)) - for onChange in onChangeHandlers[locator, default: [:]].values { - guard case let .value(handler) = onChange else { - continue - } - handler(data) - } - } - - private func receivedWriteResponse(for characteristic: CBCharacteristic, result: Result) { - guard let access = characteristicAccesses.retrieveAccess(for: characteristic), - case let .write(continuation) = access.value else { - switch result { - case .success: - logger.warning("Received write response for \(characteristic.debugIdentifier) without an ongoing access. Discarding write ...") - case let .failure(error): - logger.warning("Received erroneous write response for \(characteristic.debugIdentifier) without an ongoing access: \(error)") + for onChange in onChangeHandlers[locator, default: [:]].values { + guard case let .value(handler) = onChange else { + continue + } + handler(data) } - return - } - - if case let .failure(error) = result { - logger.debug("Characteristic write for \(characteristic.debugIdentifier) returned with error: \(error)") } - access.consume() - continuation.resume(with: result) + characteristicAccesses.resumeRead(with: result, for: characteristic) } } @@ -740,14 +791,18 @@ extension BluetoothPeripheral { extension BluetoothPeripheral: Identifiable, Sendable {} -extension BluetoothPeripheral: CustomDebugStringConvertible { - public nonisolated var debugDescription: String { +extension BluetoothPeripheral: CustomStringConvertible, CustomDebugStringConvertible { + public nonisolated var description: String { if let name { - "'\(name)' @ \(id)" + "'\(name)'@\(id)" } else { "\(id)" } } + + public nonisolated var debugDescription: String { + description + } } @@ -802,14 +857,7 @@ extension BluetoothPeripheral { device.storage.rssi = rssi let result: Result = error.map { .failure($0) } ?? .success(rssi) - - guard let rssiContinuation = device.rssiContinuation else { - return - } - - device.rssiContinuation = nil - rssiContinuation.resume(with: result) - assert(device.rssiAccess.signal(), "Signaled rssiAccess though no one was waiting") + device.rssiAccess.resume(with: result) } } @@ -844,46 +892,26 @@ extension BluetoothPeripheral { return } + let cbServices = peripheral.services.map { CBInstance(instantiatedOnDispatchQueue: $0) } + let result: Result<[BTUUID], Error> + if let error { logger.error("Error discovering services: \(error.localizedDescription)") - return - } - - guard let services = peripheral.services else { - logger.error("Discovered services but they weren't present!") - return + result = .failure(error) + } else if let services = peripheral.services { + logger.debug("Successfully discovered services for peripheral \(device): \(services.map { $0.uuid })") + result = .success(services.map { BTUUID(from: $0.uuid) }) + } else { + logger.debug("Discovered zero services for peripheral \(device)") + result = .success([]) } - let peripheral = CBInstance(instantiatedOnDispatchQueue: peripheral) - let cbServices = CBInstance(instantiatedOnDispatchQueue: services) - - Task { @SpeziBluetooth [logger] in - device.discovered(services: cbServices.cbObject) - - logger.debug("Discovered \(cbServices.cbObject) services for peripheral \(device.debugDescription)") - - for service in cbServices.cbObject { - let serviceId = BTUUID(from: service.uuid) - - guard let serviceDescription = device.configuration.description(for: serviceId) else { - continue - } - - let characteristicIds = serviceDescription.characteristics?.reduce(into: Set()) { partialResult, description in - partialResult.insert(description.characteristicId) - } - - if let characteristicIds, characteristicIds.isEmpty { - continue - } - - device.servicesAwaitingCharacteristicsDiscovery.insert(serviceId) - peripheral.cbObject.discoverCharacteristics(characteristicIds.map { Array($0.map { $0.cbuuid }) }, for: service) + Task { @SpeziBluetooth in + if let cbServices { + device.discovered(services: cbServices.cbObject) } - if device.servicesAwaitingCharacteristicsDiscovery.isEmpty { - device.signalFullyDiscovered() - } + device.discoverServicesAccess.resume(with: result) } } @@ -892,24 +920,31 @@ extension BluetoothPeripheral { return } + let result: Result + if let error { + logger.error("Error discovering characteristics for service \(service.uuid): \(error.localizedDescription)") + result = .failure(error) + } else { + if let characteristics = service.characteristics, !characteristics.isEmpty { + logger.debug("Successfully discovered characteristics for service \(service.uuid): \(characteristics.map { $0.uuid })") + } else { + logger.debug("Discovered zero characteristics for service \(service.uuid)") + } + result = .success(()) + } + let service = CBInstance(instantiatedOnDispatchQueue: service) - Task { @SpeziBluetooth [logger] in + Task { @SpeziBluetooth in // update our model with latest characteristics! device.synchronizeModel(for: service.cbObject) - // ensure we keep track of all discoveries, set .connected state - device.servicesAwaitingCharacteristicsDiscovery.remove(BTUUID(from: service.uuid)) - if device.servicesAwaitingCharacteristicsDiscovery.isEmpty { - device.signalFullyDiscovered() - } - - if let error { - logger.error("Error discovering characteristics: \(error.localizedDescription)") - return + let id = BTUUID(from: service.uuid) + if let access = device.discoverCharacteristicAccesses[id] { + let stillRequired = access.resume(with: result) + if !stillRequired { // no one was waiting for discovery on that characteristic, thus we can remove it safely + device.discoverCharacteristicAccesses.removeValue(forKey: id) + } } - - // handle auto-subscribe and discover descriptors - device.discovered(service: service.cbObject) } } @@ -922,7 +957,7 @@ extension BluetoothPeripheral { return } - logger.debug("Discovered descriptors for characteristic \(characteristic.debugIdentifier): \(descriptors)") + logger.debug("Discovered descriptors for characteristic \(characteristic.uuid): \(descriptors)") let capture = GATTCharacteristicCapture(from: characteristic) let characteristic = CBInstance(instantiatedOnDispatchQueue: characteristic) @@ -940,11 +975,12 @@ extension BluetoothPeripheral { let capture = GATTCharacteristicCapture(from: characteristic) let characteristic = CBInstance(instantiatedOnDispatchQueue: characteristic) - Task { @SpeziBluetooth in + Task { @SpeziBluetooth [logger] in // make sure value is propagated beforehand device.synchronizeModel(for: characteristic.cbObject, capture: capture) if let error { + logger.debug("Characteristic read for \(characteristic.uuid) returned with error: \(error)") device.receivedUpdatedValue(for: characteristic.cbObject, result: .failure(error)) } else if let value = capture.value { device.receivedUpdatedValue(for: characteristic.cbObject, result: .success(value)) @@ -960,11 +996,22 @@ extension BluetoothPeripheral { let capture = GATTCharacteristicCapture(from: characteristic) let characteristic = CBInstance(instantiatedOnDispatchQueue: characteristic) - Task { @SpeziBluetooth in + Task { @SpeziBluetooth [logger] in device.synchronizeModel(for: characteristic.cbObject, capture: capture) - let result: Result = error.map { .failure($0) } ?? .success(()) - device.receivedWriteResponse(for: characteristic.cbObject, result: result) + let result: Result + if let error { + result = .failure(error) + logger.warning("Received erroneous write response for \(characteristic.uuid) without an ongoing access: \(error)") + } else { + result = .success(()) + logger.debug("Characteristic write for \(characteristic.uuid) returned with error: \(error)") + } + + let didHandle = device.characteristicAccesses.resumeWrite(with: result, for: characteristic.cbObject) + if !didHandle { + logger.warning("Write response for \(characteristic.uuid) was received without an ongoing access!") + } } } @@ -974,13 +1021,7 @@ extension BluetoothPeripheral { } Task { @SpeziBluetooth in - guard let writeWithoutResponseContinuation = device.writeWithoutResponseContinuation else { - return - } - - device.writeWithoutResponseContinuation = nil - writeWithoutResponseContinuation.resume() - assert(device.writeWithoutResponseAccess.signal(), "Signaled writeWithoutResponseAccess though no one was waiting") + device.writeWithoutResponseAccess.resume() } } @@ -989,9 +1030,13 @@ extension BluetoothPeripheral { return } - if let error = error { + + let result: Result + if let error { logger.error("Error changing notification state for \(characteristic.uuid): \(error)") - return + result = .failure(error) + } else { + result = .success(()) } let capture = GATTCharacteristicCapture(from: characteristic) @@ -1000,12 +1045,21 @@ extension BluetoothPeripheral { Task { @SpeziBluetooth [logger] in device.synchronizeModel(for: characteristic.cbObject, capture: capture) - if capture.isNotifying { - logger.log("Notification began on \(characteristic.debugIdentifier)") - } else { - logger.log("Notification stopped on \(characteristic.debugIdentifier).") + if error == nil { + if capture.isNotifying { + logger.log("Notification began on \(characteristic.uuid)") + } else { + logger.log("Notification stopped on \(characteristic.uuid).") + } + } + + let didHandle = device.characteristicAccesses.resumeNotify(with: result, for: characteristic.cbObject) + if !didHandle { + logger.warning("Notification state update for \(characteristic.uuid) was received without an ongoing access!") } } } } -} // swiftlint:disable:this file_length +} + +// swiftlint:disable:this file_length diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift index a891f431..b25dbdc6 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift @@ -8,7 +8,7 @@ /// A characteristic description. -public struct CharacteristicDescription: Sendable { +public struct CharacteristicDescription { /// The characteristic id. public let characteristicId: BTUUID /// Flag indicating if descriptors should be discovered for this characteristic. @@ -30,6 +30,9 @@ public struct CharacteristicDescription: Sendable { } +extension CharacteristicDescription: Sendable {} + + extension CharacteristicDescription: ExpressibleByStringLiteral { public init(stringLiteral value: StringLiteralType) { self.init(id: BTUUID(stringLiteral: value)) diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift index 79e86568..ce921299 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift @@ -17,6 +17,7 @@ public struct DeviceDescription { /// The set of service configurations we expect from the device. /// /// This will be the list of services we are interested in and we try to discover. + /// - Note: If `nil`, we discover all services on a device. public var services: Set? { // swiftlint:disable:this discouraged_optional_collection let values: Dictionary.Values? = _services?.values return values.map { Set($0) } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift index f760a4e8..b502ceff 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift @@ -12,32 +12,42 @@ /// ## Topics /// /// ### Criteria -/// - ``advertisedService(_:)-swift.type.method`` -/// - ``advertisedService(_:)-swift.enum.case`` +/// - ``advertisedService(_:)-79pid`` +/// - ``advertisedService(_:)-5o92s`` +/// - ``advertisedServices(_:)-swift.type.method`` +/// - ``advertisedServices(_:)-swift.enum.case`` +/// - ``advertisedServices(_:_:)`` /// - ``accessory(manufacturer:advertising:)-swift.type.method`` /// - ``accessory(manufacturer:advertising:)-swift.enum.case`` -public enum DiscoveryCriteria: Sendable { - /// Identify a device by their advertised service. - case advertisedService(_ uuid: BTUUID) - /// Identify a device by its manufacturer and advertised service. - case accessory(manufacturer: ManufacturerIdentifier, advertising: BTUUID) +/// - ``accessory(manufacturer:advertising:_:)`` +public enum DiscoveryCriteria { + /// Identify a device by their advertised services. + /// + /// All supplied services need to be present in the advertisement. + case advertisedServices(_ uuids: [BTUUID]) + /// Identify a device by its manufacturer and advertised services. + /// + /// All supplied services need to be present in the advertisement. + case accessory(manufacturer: ManufacturerIdentifier, advertising: [BTUUID]) - var discoveryId: BTUUID { + var discoveryIds: [BTUUID] { switch self { - case let .advertisedService(uuid): - uuid - case let .accessory(_, service): - service + case let .advertisedServices(uuids): + uuids + case let .accessory(_, serviceIds): + serviceIds } } func matches(_ advertisementData: AdvertisementData) -> Bool { switch self { - case let .advertisedService(uuid): - return advertisementData.serviceUUIDs?.contains(uuid) ?? advertisementData.overflowServiceUUIDs?.contains(uuid) ?? false - case let .accessory(manufacturer, service): + case let .advertisedServices(uuids): + return uuids.allSatisfy { uuid in + advertisementData.serviceUUIDs?.contains(uuid) ?? advertisementData.overflowServiceUUIDs?.contains(uuid) ?? false + } + case let .accessory(manufacturer, serviceIds): guard let manufacturerData = advertisementData.manufacturerData, let identifier = ManufacturerIdentifier(data: manufacturerData) else { return false @@ -48,33 +58,91 @@ public enum DiscoveryCriteria: Sendable { } - return advertisementData.serviceUUIDs?.contains(service) ?? false + return serviceIds.allSatisfy { uuid in + advertisementData.serviceUUIDs?.contains(uuid) ?? advertisementData.overflowServiceUUIDs?.contains(uuid) ?? false + } } } } +extension DiscoveryCriteria: Sendable {} + + extension DiscoveryCriteria { + /// Identity a device by their advertised service. + /// - Parameter uuid: The service uuid the service advertises. + /// - Returns: A ``DiscoveryCriteria/advertisedServices(_:)-swift.enum.case`` criteria. + public static func advertisedService(_ uuid: BTUUID) -> DiscoveryCriteria { + .advertisedServices([uuid]) + } + + /// Identity a device by their advertised service. + /// + /// All supplied services need to be present in the advertisement. + /// - Parameter uuid: The service uuids the service advertises. + /// - Returns: A ``DiscoveryCriteria/advertisedServices(_:)-swift.enum.case`` criteria. + public static func advertisedServices(_ uuid: BTUUID...) -> DiscoveryCriteria { + .advertisedServices(uuid) + } + /// Identify a device by their advertised service. /// - Parameter service: The service type. - /// - Returns: A ``DiscoveryCriteria/advertisedService(_:)-swift.enum.case`` criteria. - public static func advertisedService(_ service: Service.Type) -> DiscoveryCriteria { - .advertisedService(service.id) + /// - Returns: A ``DiscoveryCriteria/advertisedServices(_:)-swift.enum.case`` criteria. + public static func advertisedService( + _ service: Service.Type + ) -> DiscoveryCriteria { + .advertisedServices(service.id) + } + + /// Identify a device by their advertised services. + /// + /// All supplied services need to be present in the advertisement. + /// - Parameters: + /// - service: The service type. + /// - additionalService: An optional parameter pack argument to supply additional service types the accessory is expected to advertise. + /// - Returns: A ``DiscoveryCriteria/advertisedServices(_:)-swift.enum.case`` criteria. + public static func advertisedServices( + _ service: Service.Type, + _ additionalService: repeat (each S).Type + ) -> DiscoveryCriteria { + var serviceIds: [BTUUID] = [service.id] + repeat serviceIds.append((each additionalService).id) + + return .advertisedServices(serviceIds) } } extension DiscoveryCriteria { + /// Identify a device by its manufacturer and advertised services. + /// + /// All supplied services need to be present in the advertisement. + /// - Parameters: + /// - manufacturer: The Bluetooth SIG-assigned manufacturer identifier. + /// - uuids: The service uuids the service advertises. + /// - Returns: A ``DiscoveryCriteria/accessory(manufacturer:advertising:)-swift.enum.case`` criteria. + public static func accessory(manufacturer: ManufacturerIdentifier, advertising uuids: BTUUID...) -> DiscoveryCriteria { + .accessory(manufacturer: manufacturer, advertising: uuids) + } + /// Identify a device by its manufacturer and advertised service. + /// + /// All supplied services need to be present in the advertisement. /// - Parameters: /// - manufacturer: The Bluetooth SIG-assigned manufacturer identifier. /// - service: The service type. + /// - additionalService: An optional parameter pack argument to supply additional service types the accessory is expected to advertise. /// - Returns: A ``DiscoveryCriteria/accessory(manufacturer:advertising:)-swift.enum.case`` criteria. - public static func accessory( + public static func accessory( manufacturer: ManufacturerIdentifier, - advertising service: Service.Type + advertising service: Service.Type, + _ additionalService: repeat (each S).Type ) -> DiscoveryCriteria { - .accessory(manufacturer: manufacturer, advertising: service.id) + var serviceIds: [BTUUID] = [service.id] + repeat serviceIds.append((each additionalService).id) + + return .accessory(manufacturer: manufacturer, advertising: serviceIds) } } @@ -82,8 +150,8 @@ extension DiscoveryCriteria { extension DiscoveryCriteria: Hashable, CustomStringConvertible { public var description: String { switch self { - case let .advertisedService(uuid): - ".advertisedService(\(uuid))" + case let .advertisedServices(uuids): + ".advertisedServices(\(uuids))" case let .accessory(manufacturer, service): "accessory(company: \(manufacturer), advertised: \(service))" } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift index 14bb54ae..f47d45d1 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift @@ -15,8 +15,8 @@ public struct ServiceDescription: Sendable { public let serviceId: BTUUID /// The description of characteristics present on the service. /// - /// Those are the characteristics we try to discover. If empty, we discover all characteristics - /// on a given service. + /// Those are the characteristics we try to discover. + /// - Note: If `nil`, we discover all characteristics on a given service. public var characteristics: Set? { // swiftlint:disable:this discouraged_optional_collection let values: Dictionary.Values? = _characteristics?.values return values.map { Set($0) } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBCharacteristic+DebugIdentifier.swift b/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBCharacteristic+DebugIdentifier.swift deleted file mode 100644 index bcb10da2..00000000 --- a/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBCharacteristic+DebugIdentifier.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import CoreBluetooth - - -// CustomDebugStringConvertible is already implemented for NSObjects. So we just define a custom property -extension CBCharacteristic { - var debugIdentifier: String { - if let service { - "\(uuid)@\(service)" - } else { - "\(uuid)" - } - } -} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/AdvertisementData.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/AdvertisementData.swift index a5d89b85..b50e1f37 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/AdvertisementData.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/AdvertisementData.swift @@ -95,4 +95,37 @@ extension AdvertisementData { } +extension AdvertisementData: CustomStringConvertible { + public var description: String { + var components: [String] = [] + if let localName { + components.append("localName: \"\(localName)\"") + } + if let manufacturerData { + components.append("manufacturerData: \"\(manufacturerData)\"") + } + if let serviceData { + components.append("serviceData: \(serviceData)") + } + if let serviceUUIDs { + components.append("serviceUUIDs: \(serviceUUIDs)") + } + if let overflowServiceUUIDs { + components.append("overflowServiceUUIDs: \(overflowServiceUUIDs)") + } + if let txPowerLevel { + components.append("txPowerLevel: \(txPowerLevel)") + } + if let isConnectable { + components.append("isConnectable: \(isConnectable)") + } + if let solicitedServiceUUIDs { + components.append("solicitedServiceUUIDs: \(solicitedServiceUUIDs)") + } + + return "AdvertisementData(\(components.joined(separator: ", ")))" + } +} + + extension AdvertisementData: Sendable, Hashable {} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift index 1f207144..a677990b 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift @@ -151,7 +151,7 @@ extension BluetoothManagerStorage { guard let self = self else { return } - Task.detached { @SpeziBluetooth in + Task.detached { @Sendable @SpeziBluetooth in self.unsubscribe(for: id) } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift index 4b7e77e0..6daf8373 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift @@ -10,28 +10,20 @@ import CoreBluetooth import Foundation import SpeziFoundation - @SpeziBluetooth class CharacteristicAccess: Sendable { enum Access { case read(CheckedContinuation) case write(CheckedContinuation) + case notify(CheckedContinuation) } - private let id: BTUUID - private let semaphore = AsyncSemaphore() + fileprivate let semaphore = AsyncSemaphore() private(set) var value: Access? - fileprivate init(id: BTUUID) { - self.id = id - } - - - func waitCheckingCancellation() async throws { - try await semaphore.waitCheckingCancellation() - } + fileprivate init() {} func store(_ value: Access) { precondition(self.value == nil, "Access was unexpectedly not nil") @@ -51,7 +43,7 @@ class CharacteristicAccess: Sendable { switch access { case let .read(continuation): continuation.resume(throwing: error ?? CancellationError()) - case let .write(continuation): + case let .write(continuation), let .notify(continuation): continuation.resume(throwing: error ?? CancellationError()) case .none: break @@ -61,25 +53,89 @@ class CharacteristicAccess: Sendable { @SpeziBluetooth -struct CharacteristicAccesses: Sendable { +final class CharacteristicAccesses: Sendable { private var ongoingAccesses: [CBCharacteristic: CharacteristicAccess] = [:] - mutating func makeAccess(for characteristic: CBCharacteristic) -> CharacteristicAccess { + private func makeAccess(for characteristic: CBCharacteristic) -> CharacteristicAccess { let access: CharacteristicAccess if let existing = ongoingAccesses[characteristic] { access = existing } else { - access = CharacteristicAccess(id: BTUUID(from: characteristic.uuid)) + access = CharacteristicAccess() self.ongoingAccesses[characteristic] = access } return access } - func retrieveAccess(for characteristic: CBCharacteristic) -> CharacteristicAccess? { - ongoingAccesses[characteristic] + private func perform( + for characteristic: CBCharacteristic, + returning value: Value.Type = Void.self, + action: () -> Void, + mapping: (CheckedContinuation) -> CharacteristicAccess.Access + ) async throws -> Value { + let access = makeAccess(for: characteristic) + + try await access.semaphore.waitCheckingCancellation() + return try await withCheckedThrowingContinuation { continuation in + access.store(mapping(continuation)) + action() + } + } + + func performRead(for characteristic: CBCharacteristic, action: () -> Void) async throws -> Data { + try await self.perform(for: characteristic, returning: Data.self, action: action) { continuation in + .read(continuation) + } + } + + func performWrite(for characteristic: CBCharacteristic, action: () -> Void) async throws { + try await self.perform(for: characteristic, action: action) { continuation in + .write(continuation) + } + } + + func performNotify(for characteristic: CBCharacteristic, action: () -> Void) async throws { + try await self.perform(for: characteristic, action: action) { continuation in + .notify(continuation) + } } - mutating func cancelAll(disconnectError error: (any Error)?) { + + @discardableResult + func resumeRead(with result: Result, for characteristic: CBCharacteristic) -> Bool { + guard let access = ongoingAccesses[characteristic], + case let .read(continuation) = access.value else { + return false + } + + access.consume() + continuation.resume(with: result) + return true + } + + func resumeWrite(with result: Result, for characteristic: CBCharacteristic) -> Bool { + guard let access = ongoingAccesses[characteristic], + case let .write(continuation) = access.value else { + return false + } + + access.consume() + continuation.resume(with: result) + return true + } + + func resumeNotify(with result: Result, for characteristic: CBCharacteristic) -> Bool { + guard let access = ongoingAccesses[characteristic], + case let .notify(continuation) = access.value else { + return false + } + + access.consume() + continuation.resume(with: result) + return true + } + + func cancelAll(disconnectError error: (any Error)?) { let accesses = ongoingAccesses ongoingAccesses.removeAll() diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift index 40cdaf19..a454e3c5 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift @@ -126,8 +126,8 @@ class DiscoverySession: Sendable { /// The set of serviceIds we request to discover upon scanning. /// Returning nil means scanning for all peripherals. var serviceDiscoveryIds: [BTUUID]? { // swiftlint:disable:this discouraged_optional_collection - let discoveryIds = configuration.configuredDevices.compactMap { configuration in - configuration.discoveryCriteria.discoveryId + let discoveryIds = configuration.configuredDevices.flatMap { configuration in + configuration.discoveryCriteria.discoveryIds } return discoveryIds.isEmpty ? nil : discoveryIds @@ -321,7 +321,7 @@ extension DiscoverySession { } for device in staleDevices { - logger.debug("Removing stale peripheral \(device.debugDescription)") + logger.debug("Removing stale peripheral \(device)") // we know it won't be connected, therefore we just need to remove it manager.clearDiscoveredPeripheral(forKey: device.id) } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTCharacteristic.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTCharacteristic.swift index 9128cc59..2d570901 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTCharacteristic.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTCharacteristic.swift @@ -37,7 +37,7 @@ struct GATTCharacteristicCapture: Sendable { /// ## Topics /// /// ### Instance Properties -/// - ``uuid`` +/// - ``id`` /// - ``value`` /// - ``isNotifying`` /// - ``properties`` @@ -58,7 +58,7 @@ public final class GATTCharacteristic { public private(set) var descriptors: [CBDescriptor]? // swiftlint:disable:this discouraged_optional_collection /// The Bluetooth UUID of the characteristic. - public var uuid: BTUUID { + public var id: BTUUID { BTUUID(data: underlyingCharacteristic.uuid.data) } @@ -114,9 +114,16 @@ public final class GATTCharacteristic { extension GATTCharacteristic {} -extension GATTCharacteristic: CustomDebugStringConvertible { +extension GATTCharacteristic: Identifiable {} + + +extension GATTCharacteristic: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { + "Characteristic(id: \(id), properties: \(properties), \(value.map { "value: \($0), " } ?? "")isNotifying, \(isNotifying))" + } + public var debugDescription: String { - underlyingCharacteristic.debugIdentifier + description } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTService.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTService.swift index 39bca035..806a484b 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTService.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTService.swift @@ -24,7 +24,7 @@ struct GATTServiceCapture: Sendable { /// ## Topics /// /// ### Instance Properties -/// - ``uuid`` +/// - ``id`` /// - ``isPrimary`` /// - ``characteristics`` @Observable @@ -34,7 +34,7 @@ public final class GATTService { private var _characteristics: [BTUUID: GATTCharacteristic] /// The Bluetooth UUID of the service. - public var uuid: BTUUID { + public var id: BTUUID { BTUUID(data: underlyingService.uuid.data) } @@ -66,9 +66,7 @@ public final class GATTService { /// - Parameter id: The Bluetooth characteristic id. /// - Returns: The characteristic instance if present. public func getCharacteristic(id: BTUUID) -> GATTCharacteristic? { - characteristics.first { characteristics in - characteristics.uuid == id - } + _characteristics[id] } /// Signal from the BluetoothManager to update your stored representations. @@ -104,6 +102,20 @@ public final class GATTService { } +extension GATTService: Identifiable {} + + +extension GATTService: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { + "Service(id: \(id), isPrimary: \(isPrimary))" + } + + public var debugDescription: String { + description + } +} + + extension GATTService: Hashable { public static func == (lhs: GATTService, rhs: GATTService) -> Bool { lhs.underlyingService == rhs.underlyingService diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/ManagedAsynchronousAccess.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/ManagedAsynchronousAccess.swift new file mode 100644 index 00000000..89719975 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/ManagedAsynchronousAccess.swift @@ -0,0 +1,114 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziFoundation + + +@SpeziBluetooth +final class ManagedAsynchronousAccess { + private let access: AsyncSemaphore + private var continuation: CheckedContinuation? + + var isRunning: Bool { + continuation != nil + } + + init(_ value: Int = 1) { + self.access = AsyncSemaphore(value: value) + } + +#if compiler(>=6) + @discardableResult + func resume(with result: sending Result) -> Bool { + if let continuation { + self.continuation = nil + let didSignalAnyone = access.signal() + continuation.resume(with: result) + return didSignalAnyone + } + + return false + } + + @discardableResult + func resume(returning value: sending Value) -> Bool { + resume(with: .success(value)) + } +#else + // sending keyword is new with Swift 6 + @discardableResult + func resume(with result: Result) -> Bool { + if let continuation { + self.continuation = nil + let didSignalAnyone = access.signal() + continuation.resume(with: result) + return didSignalAnyone + } + + return false + } + + @discardableResult + func resume(returning value: Value) -> Bool { + resume(with: .success(value)) + } +#endif + + func resume(throwing error: E) { + resume(with: .failure(error)) + } +} + + +extension ManagedAsynchronousAccess where Value == Void { + func resume() { + self.resume(returning: ()) + } +} + + +extension ManagedAsynchronousAccess where E == Error { + func perform(action: () -> Void) async throws -> Value { + try await access.waitCheckingCancellation() + + return try await withCheckedThrowingContinuation { continuation in + assert(self.continuation == nil, "continuation was unexpectedly not nil") + self.continuation = continuation + action() + } + } + + func cancelAll(error: E? = nil) { + if let continuation { + self.continuation = nil + continuation.resume(throwing: error ?? CancellationError()) + } + access.cancelAll() + } +} + + +extension ManagedAsynchronousAccess where Value == Void, E == Never { + func perform(action: () -> Void) async throws { + try await access.waitCheckingCancellation() + + await withCheckedContinuation { continuation in + assert(self.continuation == nil, "continuation was unexpectedly not nil") + self.continuation = continuation + action() + } + } + + func cancelAll() { + if let continuation { + self.continuation = nil + continuation.resume() + } + access.cancelAll() + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift index d727e14c..4fa8392e 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift @@ -43,7 +43,7 @@ public final class OnChangeRegistration { let locator = locator let handlerId = handlerId - Task.detached { @SpeziBluetooth in + Task.detached { @Sendable @SpeziBluetooth in peripheral?.deregisterOnChange(locator: locator, handlerId: handlerId) } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift index 01f29eb2..d96d8192 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift @@ -36,7 +36,7 @@ final class PeripheralStorage: ValueObservable, Sendable { } - @SpeziBluetooth var services: [GATTService]? { // swiftlint:disable:this discouraged_optional_collection + @SpeziBluetooth var services: [BTUUID: GATTService]? { // swiftlint:disable:this discouraged_optional_collection didSet { _$simpleRegistrar.triggerDidChange(for: \.services, on: self) } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/SpeziBluetoothActor.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/SpeziBluetoothActor.swift index 474e9a69..2efb035b 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/SpeziBluetoothActor.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/SpeziBluetoothActor.swift @@ -11,7 +11,7 @@ import Foundation private struct SpeziBluetoothDispatchQueueKey: Sendable, Hashable { static let shared = SpeziBluetoothDispatchQueueKey() - static nonisolated(unsafe) let key = DispatchSpecificKey() + static let key = DispatchSpecificKey() private init() {} } diff --git a/Sources/SpeziBluetooth/Model/Actions/DeviceActions.swift b/Sources/SpeziBluetooth/Model/Actions/DeviceActions.swift index 2cc7cee3..44fed2a6 100644 --- a/Sources/SpeziBluetooth/Model/Actions/DeviceActions.swift +++ b/Sources/SpeziBluetooth/Model/Actions/DeviceActions.swift @@ -28,6 +28,9 @@ public struct DeviceActions { /// Connect to the Bluetooth peripheral. /// + /// Make a connection to the peripheral. The method returns once the device is connected and fully discovered. + /// If service or characteristic discovery fails, this action will throw the respective error and automatically disconnect the device. + /// /// This action makes a call to ``BluetoothPeripheral/connect()``. public var connect: BluetoothConnectAction.Type { BluetoothConnectAction.self diff --git a/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift b/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift index bd436e48..f1de5a05 100644 --- a/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift +++ b/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift @@ -162,8 +162,41 @@ import Foundation public struct Characteristic: Sendable { /// Storage unit for the property wrapper. final class Storage: Sendable { + enum DefaultNotifyState: UInt8, AtomicValue { + case disabled + case enabled + case collectedDisabled + case collectedEnabled + + var defaultNotify: Bool { + switch self { + case .disabled, .collectedDisabled: + return false + case .enabled, .collectedEnabled: + return true + } + } + + var completed: Bool { + switch self { + case .disabled, .enabled: + false + case .collectedDisabled, .collectedEnabled: + true + } + } + + init(from defaultNotify: Bool) { + self = defaultNotify ? .enabled : .disabled + } + + static func collected(notify: Bool) -> DefaultNotifyState { + notify ? .collectedEnabled : .collectedDisabled + } + } + let id: BTUUID - let defaultNotify: ManagedAtomic + let defaultNotify: ManagedAtomic let autoRead: ManagedAtomic let injection = ManagedAtomicLazyReference>() @@ -173,7 +206,7 @@ public struct Characteristic: Sendable { init(id: BTUUID, defaultNotify: Bool, autoRead: Bool, initialValue: Value?) { self.id = id - self.defaultNotify = ManagedAtomic(defaultNotify) + self.defaultNotify = ManagedAtomic(DefaultNotifyState(from: defaultNotify)) self.autoRead = ManagedAtomic(autoRead) self.state = State(initialValue: initialValue) } @@ -288,7 +321,27 @@ public struct Characteristic: Sendable { storage.state.characteristic = service?.getCharacteristic(id: storage.id) - injection.setup(defaultNotify: storage.defaultNotify.load(ordering: .acquiring)) +#if compiler(<6) + var defaultNotify: Bool = false +#else + let defaultNotify: Bool +#endif + while true { + let notifyState = storage.defaultNotify.load(ordering: .acquiring) + let notify = notifyState.defaultNotify + + let (exchanged, _) = storage.defaultNotify.compareExchange( + expected: notifyState, + desired: .collected(notify: notify), + ordering: .acquiringAndReleasing + ) + if exchanged { + defaultNotify = notify + break + } + } + + injection.setup(defaultNotify: defaultNotify) } } diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift index 2e5e8528..a0fca730 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift @@ -110,22 +110,20 @@ extension CharacteristicAccessor where Value: ByteDecodable { /// /// Register a change handler with the characteristic that is called every time the value changes. /// - /// - Note: `onChange` handlers are bound to the lifetime of the device. If you need to control the lifetime yourself refer to using ``subscription``. + /// - Note: An `onChange` handler is bound to the lifetime of the device. If you need to control the lifetime yourself refer to using ``subscription``. /// /// Note that you cannot set up onChange handlers within the initializers. /// Use the [`configure()`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module/configure()-5pa83) to set up /// all your handlers. - /// - Important: You must capture `self` weakly only. Capturing `self` strongly causes a memory leak. - /// - /// - Note: This closure is called from the ``SpeziBluetooth/SpeziBluetooth`` global actor, if you don't pass in an async method - /// that has an annotated actor isolation (e.g., `@MainActor` or actor isolated methods). + /// - Warning: You must capture `self` weakly only. Capturing `self` strongly causes a memory leak. /// /// - Parameters: /// - initial: Whether the action should be run with the initial characteristic value. - /// Otherwise, the action will only run strictly if the value changes. + /// Otherwise, the action will only run strictly if the value changes. + /// > Important: This parameter has no effect for notify-only characteristics. A initial value will only be read if the characteristic supports read accesses. /// - action: The change handler to register. public func onChange(initial: Bool = false, perform action: @escaping @Sendable (_ value: Value) async -> Void) { - onChange(initial: initial) { _, newValue in + onChange(initial: initial) { @SpeziBluetooth _, newValue in await action(newValue) } } @@ -134,19 +132,17 @@ extension CharacteristicAccessor where Value: ByteDecodable { /// /// Register a change handler with the characteristic that is called every time the value changes. /// - /// - Note: `onChange` handlers are bound to the lifetime of the device. If you need to control the lifetime yourself refer to using ``subscription``. + /// - Note: An `onChange` handler is bound to the lifetime of the device. If you need to control the lifetime yourself refer to using ``subscription``. /// /// Note that you cannot set up onChange handlers within the initializers. /// Use the [`configure()`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module/configure()-5pa83) to set up /// all your handlers. - /// - Important: You must capture `self` weakly only. Capturing `self` strongly causes a memory leak. - /// - /// - Note: This closure is called from the ``SpeziBluetooth/SpeziBluetooth`` global actor, if you don't pass in an async method - /// that has an annotated actor isolation (e.g., `@MainActor` or actor isolated methods). + /// - Warning: You must capture `self` weakly only. Capturing `self` strongly causes a memory leak. /// /// - Parameters: /// - initial: Whether the action should be run with the initial characteristic value. - /// Otherwise, the action will only run strictly if the value changes. + /// Otherwise, the action will only run strictly if the value changes. + /// > Important: This parameter has no effect for notify-only characteristics. A initial value will only be read if the characteristic supports read accesses. /// - action: The change handler to register, receiving both the old and new value. public func onChange(initial: Bool = false, perform action: @escaping @Sendable (_ oldValue: Value, _ newValue: Value) async -> Void) { if let subscriptions = storage.testInjections.load()?.subscriptions { @@ -182,13 +178,30 @@ extension CharacteristicAccessor where Value: ByteDecodable { /// Enable or disable characteristic notifications. /// - Parameter enabled: Flag indicating if notifications should be enabled. public func enableNotifications(_ enabled: Bool = true) async { - guard let injection = storage.injection.load() else { // load always reads with acquire order - // this value will be populated to the injection once it is set up - storage.defaultNotify.store(enabled, ordering: .releasing) - return - } + while true { + guard let injection = storage.injection.load() else { // load always reads with acquire order + // We use the `defaultNotify` storage to temporary store the enableNotifications value. + // This value will be populated to the injection once it is set up + + let state = storage.defaultNotify.load(ordering: .acquiring) + if state.completed { + continue // retry loading the injection, it should be present now + } - await injection.enableNotifications(enabled) + let (exchanged, _) = storage.defaultNotify.compareExchange( + expected: state, + desired: .init(from: enabled), + ordering: .acquiringAndReleasing + ) + if !exchanged { + continue // retry, value changed while we were setting it + } + return + } + + await injection.enableNotifications(enabled) + break + } } diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift index 481220e0..1eeac52a 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift @@ -103,9 +103,8 @@ class CharacteristicPeripheralInjection: Sendable { ) { let id = subscriptions.newOnChangeSubscription(perform: action) - // Must be called detached, otherwise it might inherit TaskLocal values which includes Spezi moduleInitContext - // which would create a strong reference to the device. - Task.detached { @SpeziBluetooth in + // avoid accidentally inheriting any task local values + Task.detached { @Sendable @SpeziBluetooth in await self.handleInitialCall(id: id, initial: initial, action: action) } } @@ -138,9 +137,7 @@ class CharacteristicPeripheralInjection: Sendable { private func registerCharacteristicValueChanges() { self.valueRegistration = peripheral.registerOnChangeHandler(service: serviceId, characteristic: characteristicId) { [weak self] data in - Task {@SpeziBluetooth [weak self] in - self?.handleUpdatedValue(data) - } + self?.handleUpdatedValue(data) } } @@ -199,8 +196,14 @@ extension CharacteristicPeripheralInjection: DecodableCharacteristic where Value state.value = value self.fullFillControlPointRequest(value) - self.subscriptions.notifySubscribers(with: value, ignoring: nonInitialChangeHandlers ?? []) - nonInitialChangeHandlers = nil + // The first value is not always the initial value + // e.g., error retrieving the initial value, or notify characteristics that don't support read! + if let nonInitialChangeHandlers, peripheral.isReadingInitialValue(for: characteristicId, on: serviceId) { + self.nonInitialChangeHandlers = nil + self.subscriptions.notifySubscribers(with: value, ignoring: nonInitialChangeHandlers) + } else { + self.subscriptions.notifySubscribers(with: value) + } } else { state.value = nil } diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift index 56ad710f..fb4b3e16 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift @@ -84,7 +84,10 @@ extension DeviceStateAccessor { /// - initial: Whether the action should be run with the initial state value. Otherwise, the action will only run /// strictly if the value changes. /// - action: The change handler to register, receiving both the old and new value. - public func onChange(initial: Bool = false, perform action: @escaping @Sendable (_ oldValue: Value, _ newValue: Value) async -> Void) { + public func onChange( + initial: Bool = false, + perform action: @escaping @Sendable (_ oldValue: Value, _ newValue: Value) async -> Void + ) { if let testInjections = storage.testInjections.load(), let subscriptions = testInjections.subscriptions { let id = subscriptions.newOnChangeSubscription(perform: action) diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift index d56b7c07..962e0054 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift @@ -55,7 +55,7 @@ class DeviceStatePeripheralInjection: Sendable { nonisolated func newOnChangeSubscription( initial: Bool, - perform action: @escaping @Sendable (_ oldValue: Value, _ newValue: Value) async -> Void + perform action: @escaping @Sendable @SpeziBluetooth (_ oldValue: Value, _ newValue: Value) async -> Void ) { let id = subscriptions.newOnChangeSubscription(perform: action) diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/ServicePeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/ServicePeripheralInjection.swift index 06ec5ea2..c6757b5c 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/ServicePeripheralInjection.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/ServicePeripheralInjection.swift @@ -36,7 +36,7 @@ class ServicePeripheralInjection: Sendable { private func trackServicesUpdate() { peripheral.onChange(of: \.services) { [weak self] services in guard let self = self, - let service = services?.first(where: { $0.uuid == self.serviceId }) else { + let service = services?[self.serviceId] else { return } diff --git a/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift b/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift index f6bc050a..379ae5c3 100644 --- a/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift +++ b/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift @@ -63,13 +63,13 @@ final class ChangeSubscriptions: Sendable { } @discardableResult - nonisolated func newOnChangeSubscription(perform action: @escaping @Sendable (_ oldValue: Value, _ newValue: Value) async -> Void) -> UUID { + nonisolated func newOnChangeSubscription( + perform action: @escaping @Sendable (_ oldValue: Value, _ newValue: Value) async -> Void + ) -> UUID { let registration = _newSubscription() - // It's important to use a detached Task here. - // Otherwise it might inherit TaskLocal values which might include Spezi moduleInitContext - // which would create a strong reference to the device. - Task.detached { @SpeziBluetooth [weak self] in + // avoid accidentally inheriting any task local values + Task.detached { @Sendable @SpeziBluetooth [weak self] in var currentValue: Value? for await element in registration.subscription { diff --git a/Sources/SpeziBluetoothServices/Characteristics/BloodPressureFeature.swift b/Sources/SpeziBluetoothServices/Characteristics/BloodPressureFeature.swift index 136d7008..8591de97 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/BloodPressureFeature.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/BloodPressureFeature.swift @@ -67,6 +67,45 @@ public struct BloodPressureFeature: OptionSet { extension BloodPressureFeature: Hashable, Sendable {} +extension BloodPressureFeature: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { + var components: [String] = [] + if contains(.bodyMovementDetectionSupported) { + components.append("bodyMovementDetectionSupported") + } + if contains(.cuffFitDetectionSupported) { + components.append("cuffFitDetectionSupported") + } + if contains(.irregularPulseDetectionSupported) { + components.append("irregularPulseDetectionSupported") + } + if contains(.pulseRateRangeDetectionSupported) { + components.append("pulseRateRangeDetectionSupported") + } + if contains(.measurementPositionDetectionSupported) { + components.append("measurementPositionDetectionSupported") + } + if contains(.multipleBondsSupported) { + components.append("multipleBondsSupported") + } + if contains(.e2eCrcSupported) { + components.append("e2eCrcSupported") + } + if contains(.userDataServiceSupported) { + components.append("userDataServiceSupported") + } + if contains(.userFacingTimeSupported) { + components.append("userFacingTimeSupported") + } + return "[\(components.joined(separator: ", "))]" + } + + public var debugDescription: String { + "\(Self.self)(rawValue: 0x\(String(format: "%02X", rawValue)))" + } +} + + extension BloodPressureFeature: ByteCodable { public init?(from byteBuffer: inout ByteBuffer) { guard let rawValue = UInt16(from: &byteBuffer) else { diff --git a/Sources/SpeziBluetoothServices/Characteristics/BloodPressureMeasurement.swift b/Sources/SpeziBluetoothServices/Characteristics/BloodPressureMeasurement.swift index 3e364b61..8e4f544a 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/BloodPressureMeasurement.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/BloodPressureMeasurement.swift @@ -144,6 +144,36 @@ extension BloodPressureMeasurement.Status: Sendable, Hashable {} extension BloodPressureMeasurement: Sendable, Hashable {} +extension BloodPressureMeasurement.Status: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { + var components: [String] = [] + if contains(.bodyMovementDetected) { + components.append("bodyMovementDetected") + } + if contains(.looseCuffFit) { + components.append("looseCuffFit") + } + if contains(.irregularPulse) { + components.append("irregularPulse") + } + if contains(.pulseRateExceedsUpperLimit) { + components.append("pulseRateExceedsUpperLimit") + } + if contains(.pulseRateBelowLowerLimit) { + components.append("pulseRateBelowLowerLimit") + } + if contains(.improperMeasurementPosition) { + components.append("improperMeasurementPosition") + } + return "[\(components.joined(separator: ", "))]" + } + + public var debugDescription: String { + "\(Self.self)(rawValue: 0x\(String(format: "%02X", rawValue)))" + } +} + + extension BloodPressureMeasurement.Flags: ByteCodable { init?(from byteBuffer: inout ByteBuffer) { guard let rawValue = UInt8(from: &byteBuffer) else { diff --git a/Sources/SpeziBluetoothServices/Characteristics/TemperatureType.swift b/Sources/SpeziBluetoothServices/Characteristics/TemperatureType.swift index 2ca84c3e..7ea7e7fe 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/TemperatureType.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/TemperatureType.swift @@ -52,6 +52,36 @@ extension TemperatureType: RawRepresentable {} extension TemperatureType: Hashable, Sendable {} +extension TemperatureType: CustomStringConvertible { + public var description: String { + switch self { + case .reserved: + "reserved" + case .armpit: + "armpit" + case .body: + "body" + case .ear: + "ear" + case .finger: + "finger" + case .gastrointestinalTract: + "gastrointestinalTract" + case .mouth: + "mouth" + case .rectum: + "rectum" + case .toe: + "toe" + case .tympanum: + "tympanum" + default: + "\(Self.self)(rawValue: \(rawValue))" + } + } +} + + extension TemperatureType: ByteCodable { public init?(from byteBuffer: inout ByteBuffer) { guard let value = UInt8(from: &byteBuffer) else { diff --git a/Sources/SpeziBluetoothServices/Characteristics/Time/CurrentTime.swift b/Sources/SpeziBluetoothServices/Characteristics/Time/CurrentTime.swift index fff223b4..fe417484 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/Time/CurrentTime.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/Time/CurrentTime.swift @@ -55,6 +55,30 @@ public struct CurrentTime { extension CurrentTime.AdjustReason: Hashable, Sendable {} +extension CurrentTime.AdjustReason: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { + var components: [String] = [] + if contains(.manualTimeUpdate) { + components.append("manualTimeUpdate") + } + if contains(.externalReferenceTimeUpdate) { + components.append("externalReferenceTimeUpdate") + } + if contains(.changeOfTimeZone) { + components.append("changeOfTimeZone") + } + if contains(.changeOfDST) { + components.append("changeOfDST") + } + return "[\(components.joined(separator: ", "))]" + } + + public var debugDescription: String { + "\(Self.self)(rawValue: 0x\(String(format: "%02X", rawValue)))" + } +} + + extension CurrentTime: Hashable, Sendable {} diff --git a/Sources/SpeziBluetoothServices/Characteristics/Time/DateTime.swift b/Sources/SpeziBluetoothServices/Characteristics/Time/DateTime.swift index 5a280338..08034595 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/Time/DateTime.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/Time/DateTime.swift @@ -173,6 +173,42 @@ extension DateTime.Month: RawRepresentable {} extension DateTime.Month: Hashable, Sendable {} +extension DateTime.Month: CustomStringConvertible { + public var description: String { + switch self { + case .unknown: + "unknown" + case .january: + "january" + case .february: + "february" + case .march: + "march" + case .april: + "april" + case .mai: + "mai" + case .june: + "june" + case .july: + "july" + case .august: + "august" + case .september: + "september" + case .october: + "october" + case .november: + "november" + case .december: + "december" + default: + "\(Self.self)(rawValue: \(rawValue))" + } + } +} + + extension DateTime: Hashable, Sendable {} diff --git a/Sources/SpeziBluetoothServices/Characteristics/Time/DayOfWeek.swift b/Sources/SpeziBluetoothServices/Characteristics/Time/DayOfWeek.swift index 097ed992..29ab8642 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/Time/DayOfWeek.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/Time/DayOfWeek.swift @@ -53,6 +53,32 @@ extension DayOfWeek: RawRepresentable {} extension DayOfWeek: Hashable, Sendable {} +extension DayOfWeek: CustomStringConvertible { + public var description: String { + switch self { + case .unknown: + "unknown" + case .monday: + "monday" + case .tuesday: + "tuesday" + case .wednesday: + "wednesday" + case .thursday: + "thursday" + case .friday: + "friday" + case .saturday: + "saturday" + case .sunday: + "sunday" + default: + "\(Self.self)(rawValue: \(rawValue))" + } + } +} + + extension DayOfWeek: ByteCodable { public init?(from byteBuffer: inout ByteBuffer) { guard let rawValue = UInt8(from: &byteBuffer) else { diff --git a/Sources/SpeziBluetoothServices/Characteristics/WeightScaleFeature.swift b/Sources/SpeziBluetoothServices/Characteristics/WeightScaleFeature.swift index 048c4b97..75e11e5b 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/WeightScaleFeature.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/WeightScaleFeature.swift @@ -219,6 +219,27 @@ extension WeightScaleFeature.HeightResolution: Hashable, Sendable {} extension WeightScaleFeature: Hashable, Sendable {} +extension WeightScaleFeature: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { + var options: [String] = [] + if contains(.timeStampSupported) { + options.append("timeStampSupported") + } + if contains(.multipleUsersSupported) { + options.append("multipleUsersSupported") + } + if contains(.bmiSupported) { + options.append("bmiSupported") + } + return "\(Self.self)(weightResolution: \(weightResolution), heightResolution: \(heightResolution), options: \(options.joined(separator: ", ")))" + } + + public var debugDescription: String { + "\(Self.self)(rawValue: 0x\(String(format: "%02X", rawValue)))" + } +} + + extension WeightScaleFeature: ByteCodable { public init?(from byteBuffer: inout ByteBuffer) { guard let rawValue = UInt32(from: &byteBuffer) else { diff --git a/Sources/SpeziBluetoothServices/Services/CurrentTimeService.swift b/Sources/SpeziBluetoothServices/Services/CurrentTimeService.swift index 80c070eb..00276545 100644 --- a/Sources/SpeziBluetoothServices/Services/CurrentTimeService.swift +++ b/Sources/SpeziBluetoothServices/Services/CurrentTimeService.swift @@ -49,42 +49,41 @@ extension CurrentTimeService { /// This method checks the current time of the connected peripheral. If the current time was never set or the time difference /// is larger than the specified `threshold`, the peripheral time is updated to `now`. /// - /// - Note: This method expects that the ``currentTime`` characteristic is current. + /// - Note: This method expects that the ``currentTime`` characteristic to be present and current. /// - Parameters: /// - now: The `Date` which is perceived as now. /// - threshold: The threshold used to decide if peripheral time should be updated. /// A time difference smaller than the threshold is considered current. - public func synchronizeDeviceTime(now: Date = .now, threshold: Duration = .seconds(1)) { // we consider 1 second difference accurate enough + /// - Throws: Throws the respective Bluetooth error if the write to the `currentTime` characteristic failed. + @SpeziBluetooth + public func synchronizeDeviceTime(now: Date = .now, threshold: Duration = .seconds(1)) async throws { // check if time update is necessary if let currentTime = currentTime, let deviceTime = currentTime.time.date { let difference = abs(deviceTime.timeIntervalSinceReferenceDate - now.timeIntervalSinceReferenceDate) if difference < threshold.timeInterval { - return // we consider 1 second difference accurate enough + return } Self.logger.debug("Current time difference is \(difference)s. Device time: \(String(describing: currentTime)). Updating time ...") } else { Self.logger.debug("Unknown current time (\(String(describing: self.currentTime))). Updating time ...") } - - + // update time if it isn't present or if it is outdated - Task { - let exactTime = ExactTime256(from: now) - do { - try await $currentTime.write(CurrentTime(time: exactTime)) - Self.logger.debug("Updated device time to \(String(describing: exactTime))") - } catch let error as NSError { - if error.domain == CBATTError.errorDomain { - let attError = CBATTError(_nsError: error) - if attError.code == CBATTError.Code(rawValue: 0x80) { - Self.logger.debug("Device ignored some date fields. Updated device time to \(String(describing: exactTime)).") - return - } + let exactTime = ExactTime256(from: now) + do { + try await $currentTime.write(CurrentTime(time: exactTime)) + Self.logger.debug("Updated device time to \(String(describing: exactTime))") + } catch let error as NSError { + if error.domain == CBATTError.errorDomain { + let attError = CBATTError(_nsError: error) + if attError.code == CBATTError.Code(rawValue: 0x80) { + Self.logger.debug("Device ignored some date fields. Updated device time to \(String(describing: exactTime)).") + return } - Self.logger.warning("Failed to update current time: \(error)") } + throw error } } } diff --git a/Sources/TestPeripheral/TestPeripheral.swift b/Sources/TestPeripheral/TestPeripheral.swift index 03e8b3ad..9d3ed965 100644 --- a/Sources/TestPeripheral/TestPeripheral.swift +++ b/Sources/TestPeripheral/TestPeripheral.swift @@ -45,18 +45,20 @@ final class TestPeripheral: NSObject, CBPeripheralManagerDelegate { private let queuedUpdates = QueueUpdates() - override init() { - super.init() - peripheralManager = CBPeripheralManager(delegate: self, queue: DispatchQueue.main) - } + override private init() {} static func main() { let peripheral = TestPeripheral() + peripheral.performInit() peripheral.logger.info("Initialized") RunLoop.main.run() } + private func performInit() { + peripheralManager = CBPeripheralManager(delegate: self, queue: DispatchQueue.main) + } + func startAdvertising() { guard let testService else { logger.error("Service was not available after starting advertising!") diff --git a/Tests/SpeziBluetoothServicesTests/BloodPressureTests.swift b/Tests/SpeziBluetoothServicesTests/BloodPressureTests.swift index 5e549f03..738d8842 100644 --- a/Tests/SpeziBluetoothServicesTests/BloodPressureTests.swift +++ b/Tests/SpeziBluetoothServicesTests/BloodPressureTests.swift @@ -81,4 +81,42 @@ final class BloodPressureTests: XCTestCase { try testIdentity(from: features2) try testIdentity(from: features3) } + + func testBloodPressureFeatureStrings() { + let features: BloodPressureFeature = [ + .bodyMovementDetectionSupported, + .cuffFitDetectionSupported, + .irregularPulseDetectionSupported, + .pulseRateRangeDetectionSupported, + .measurementPositionDetectionSupported, + .multipleBondsSupported, + .e2eCrcSupported, + .userDataServiceSupported, + .userFacingTimeSupported + ] + + XCTAssertEqual( + features.description, + "[bodyMovementDetectionSupported, cuffFitDetectionSupported, irregularPulseDetectionSupported, pulseRateRangeDetectionSupported, measurementPositionDetectionSupported, multipleBondsSupported, e2eCrcSupported, userDataServiceSupported, userFacingTimeSupported]" + ) // swiftlint:disable:previous line_length + + XCTAssertEqual(features.debugDescription, "BloodPressureFeature(rawValue: 0x1FF)") + } + + func testBloodPressureStatusStrings() { + let status: BloodPressureMeasurement.Status = [ + .bodyMovementDetected, + .looseCuffFit, + .irregularPulse, + .pulseRateExceedsUpperLimit, + .pulseRateBelowLowerLimit, + .improperMeasurementPosition + ] + + XCTAssertEqual( + status.description, + "[bodyMovementDetected, looseCuffFit, irregularPulse, pulseRateExceedsUpperLimit, pulseRateBelowLowerLimit, improperMeasurementPosition]" + ) + XCTAssertEqual(status.debugDescription, "Status(rawValue: 0x3F)") + } } diff --git a/Tests/SpeziBluetoothServicesTests/CurrentTimeTests.swift b/Tests/SpeziBluetoothServicesTests/CurrentTimeTests.swift index df4334e5..ead5addf 100644 --- a/Tests/SpeziBluetoothServicesTests/CurrentTimeTests.swift +++ b/Tests/SpeziBluetoothServicesTests/CurrentTimeTests.swift @@ -36,7 +36,7 @@ final class CurrentTimeTests: XCTestCase { } let date = try XCTUnwrap(now.date) - service.synchronizeDeviceTime(now: date) + try await service.synchronizeDeviceTime(now: date) await fulfillment(of: [writeExpectation]) try await Task.sleep(for: .milliseconds(500)) // let task complete @@ -56,7 +56,7 @@ final class CurrentTimeTests: XCTestCase { } let date = try XCTUnwrap(now.date) - service.synchronizeDeviceTime(now: date, threshold: .seconds(8)) + try await service.synchronizeDeviceTime(now: date, threshold: .seconds(8)) await fulfillment(of: [writeExpectation]) try await Task.sleep(for: .milliseconds(500)) // let task complete @@ -77,7 +77,7 @@ final class CurrentTimeTests: XCTestCase { } let date = try XCTUnwrap(now.date) - service.synchronizeDeviceTime(now: date) + try await service.synchronizeDeviceTime(now: date) await fulfillment(of: [writeExpectation], timeout: 1) try await Task.sleep(for: .milliseconds(500)) // let task complete @@ -100,6 +100,20 @@ final class CurrentTimeTests: XCTestCase { try testIdentity(from: DayOfWeek(rawValue: 26)) // test a reserved value } + func testDayOfWeekStrings() throws { + let expected = ["unknown", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", "DayOfWeek(rawValue: 26)"] + let values = [DayOfWeek.unknown, .monday, .tuesday, .wednesday, .thursday, .friday, .saturday, .sunday, DayOfWeek(rawValue: 26)] + XCTAssertEqual(values.map { $0.description }, expected) + } + + func testMonthStrings() throws { + // swiftlint:disable line_length + let expected = ["unknown", "january", "february", "march", "april", "mai", "june", "july", "august", "september", "october", "november", "december", "Month(rawValue: 23)"] + let values = [DateTime.Month.unknown, .january, .february, .march, .april, .mai, .june, .july, .august, .september, .october, .november, .december, .init(rawValue: 23)] + // swiftlint:enable line_length + XCTAssertEqual(values.map { $0.description }, expected) + } + func testDayDateTime() throws { let dateTime = DateTime(year: 2005, month: .december, day: 27, hours: 12, minutes: 31, seconds: 40) try testIdentity(from: DayDateTime(dateTime: dateTime, dayOfWeek: .tuesday)) @@ -177,4 +191,10 @@ final class CurrentTimeTests: XCTestCase { XCTAssertEqual(exactTime.seconds, 27) XCTAssertEqual(exactTime.fractions256, 17) } + + func testAdjustReasonStrings() { + let reasons: CurrentTime.AdjustReason = [.manualTimeUpdate, .externalReferenceTimeUpdate, .changeOfTimeZone, .changeOfDST] + XCTAssertEqual(reasons.description, "[manualTimeUpdate, externalReferenceTimeUpdate, changeOfTimeZone, changeOfDST]") + XCTAssertEqual(reasons.debugDescription, "AdjustReason(rawValue: 0x0F)") + } } diff --git a/Tests/SpeziBluetoothServicesTests/HealthThermometerTests.swift b/Tests/SpeziBluetoothServicesTests/HealthThermometerTests.swift index 72b5bf1d..6df4f3bc 100644 --- a/Tests/SpeziBluetoothServicesTests/HealthThermometerTests.swift +++ b/Tests/SpeziBluetoothServicesTests/HealthThermometerTests.swift @@ -46,4 +46,12 @@ final class HealthThermometerTests: XCTestCase { try testIdentity(from: TemperatureType.toe) try testIdentity(from: TemperatureType.tympanum) } + + func testTemperatureTypeStrings() { + // swiftlint:disable line_length + let expected = ["reserved", "armpit", "body", "ear", "finger", "gastrointestinalTract", "mouth", "rectum", "toe", "tympanum", "TemperatureType(rawValue: 23)"] + let values = [TemperatureType.reserved, .armpit, .body, .ear, .finger, .gastrointestinalTract, .mouth, .rectum, .toe, .tympanum, .init(rawValue: 23)] + // swiftlint:enable line_length + XCTAssertEqual(values.map { $0.description }, expected) + } } diff --git a/Tests/SpeziBluetoothServicesTests/WeightScaleTests.swift b/Tests/SpeziBluetoothServicesTests/WeightScaleTests.swift index cc221d6f..fda74900 100644 --- a/Tests/SpeziBluetoothServicesTests/WeightScaleTests.swift +++ b/Tests/SpeziBluetoothServicesTests/WeightScaleTests.swift @@ -93,4 +93,18 @@ final class WeightMeasurementTests: XCTestCase { XCTAssertFalse(features2.contains(.multipleUsersSupported)) XCTAssertFalse(features2.contains(.timeStampSupported)) } + + func testWeightScaleFeatureStrings() { + let features: WeightScaleFeature = [ + .bmiSupported, + .multipleUsersSupported, + .timeStampSupported + ] + + XCTAssertEqual( + features.description, + "WeightScaleFeature(weightResolution: WeightResolution(rawValue: 0), heightResolution: HeightResolution(rawValue: 0), options: timeStampSupported, multipleUsersSupported, bmiSupported)" + ) // swiftlint:disable:previous line_length + XCTAssertEqual(features.debugDescription, "WeightScaleFeature(rawValue: 0x07)") + } } diff --git a/Tests/UITests/TestApp/RetrievePairedDevicesView.swift b/Tests/UITests/TestApp/RetrievePairedDevicesView.swift index 7f8e3532..5faa8dec 100644 --- a/Tests/UITests/TestApp/RetrievePairedDevicesView.swift +++ b/Tests/UITests/TestApp/RetrievePairedDevicesView.swift @@ -21,7 +21,7 @@ struct RetrievePairedDevicesView: View { @State private var viewState: ViewState = .idle var body: some View { - Group { // swiftlint:disable:this closure_body_length + Group { if let pairedDeviceId { List { Section { @@ -33,30 +33,8 @@ struct RetrievePairedDevicesView: View { Text(retrievedDevice.state.description) } } - AsyncButton("Unpair Device") { - await retrievedDevice?.disconnect() - retrievedDevice = nil - self.pairedDeviceId = nil - } - if let retrievedDevice { - let state = retrievedDevice.state - if state == .disconnected || state == .connecting { - AsyncButton("Connect Device", state: $viewState) { - try await retrievedDevice.connect() - } - } - - if state == .connecting || state == .connected || state == .disconnecting { - AsyncButton("Disconnect Device") { - await retrievedDevice.disconnect() - } - } - } else { - AsyncButton("Retrieve Device") { - retrievedDevice = await bluetooth.retrieveDevice(for: pairedDeviceId) - } - } + deviceButtons(for: pairedDeviceId) } if let retrievedDevice, case .connected = retrievedDevice.state { @@ -79,6 +57,37 @@ struct RetrievePairedDevicesView: View { self._pairedDeviceId = pairedDeviceId self._retrievedDevice = retrievedDevice } + + + @ViewBuilder + @MainActor + private func deviceButtons(for pairedDeviceId: UUID) -> some View { + AsyncButton("Unpair Device") { + await retrievedDevice?.disconnect() + retrievedDevice = nil + self.pairedDeviceId = nil + } + if let retrievedDevice { + let state = retrievedDevice.state + + if state == .disconnected || state == .connecting { + AsyncButton("Connect Device", state: $viewState) { + try await retrievedDevice.connect() + } + } + + if state == .connecting || state == .connected || state == .disconnecting { + AsyncButton("Disconnect Device") { + await retrievedDevice.disconnect() + } + } + } else { + AsyncButton("Retrieve Device") { + let bluetooth = bluetooth + retrievedDevice = await bluetooth.retrieveDevice(for: pairedDeviceId) + } + } + } } diff --git a/Tests/UITests/TestApp/TestApp.swift b/Tests/UITests/TestApp/TestApp.swift index df01ab16..845eba92 100644 --- a/Tests/UITests/TestApp/TestApp.swift +++ b/Tests/UITests/TestApp/TestApp.swift @@ -27,6 +27,7 @@ struct DeviceCountButton: View { var body: some View { Section { AsyncButton("Query Count") { + let bluetooth = bluetooth lastReadCount = await bluetooth._initializedDevicesCount() } .onDisappear { diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 1dd7629a..58e65f06 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -373,6 +373,7 @@ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; + OTHER_SWIFT_FLAGS = "-enable-upcoming-feature InferSendableFromCaptures"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -431,6 +432,7 @@ MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "-enable-upcoming-feature InferSendableFromCaptures"; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; @@ -632,6 +634,7 @@ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; + OTHER_SWIFT_FLAGS = "-enable-upcoming-feature InferSendableFromCaptures"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = TEST; SWIFT_OPTIMIZATION_LEVEL = "-Onone";