diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 7972628b..bef1dd2f 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -23,6 +23,17 @@ jobs: runsonlabels: '["macOS", "self-hosted", "spezi"]' scheme: SpeziBluetooth-Package artifactname: SpeziBluetooth-Package.xcresult + resultBundle: SpeziBluetooth-Package.xcresult + packageios_latest: + name: Build and Test Swift Package iOS Latest + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 + with: + runsonlabels: '["macOS", "self-hosted", "spezi"]' + scheme: SpeziBluetooth-Package + xcodeversion: latest + swiftVersion: 6 + artifactname: SpeziBluetooth-Package-Latest.xcresult + resultBundle: SpeziBluetooth-Package-Latest.xcresult ios: name: Build and Test iOS uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 @@ -32,6 +43,17 @@ jobs: scheme: TestApp artifactname: TestApp-iOS.xcresult resultBundle: TestApp-iOS.xcresult + ios_latest: + name: Build and Test iOS Latest + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 + with: + runsonlabels: '["macOS", "self-hosted", "spezi"]' + path: 'Tests/UITests' + scheme: TestApp + xcodeversion: latest + swiftVersion: 6 + artifactname: TestApp-iOS-Latest.xcresult + resultBundle: TestApp-iOS-Latest.xcresult macos: name: Build and Test macOS uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 @@ -43,7 +65,7 @@ jobs: path: 'Tests/UITests' artifactname: TestApp-macOS.xcresult resultBundle: TestApp-macOS.xcresult - customcommand: "set -o pipefail && xcodebuild test -scheme 'TestApp' -configuration 'Test' -destination 'platform=macOS,arch=arm64,variant=Mac Catalyst' -derivedDataPath '.derivedData' -resultBundlePath 'TestApp-macOS.xcresult' -skipPackagePluginValidation -skipMacroValidation | xcpretty" + customcommand: "set -o pipefail && xcodebuild test -scheme 'TestApp' -configuration 'Test' -destination 'platform=macOS,arch=arm64,variant=Mac Catalyst' -derivedDataPath '.derivedData' -resultBundlePath 'TestApp-macOS.xcresult' -skipPackagePluginValidation -skipMacroValidation | xcbeautify" secrets: inherit uploadcoveragereport: name: Upload Coverage Report diff --git a/Package.swift b/Package.swift index ac34f9c4..3248a8b6 100644 --- a/Package.swift +++ b/Package.swift @@ -13,9 +13,9 @@ import PackageDescription #if swift(<6) -let swiftConcurrency: SwiftSetting = .enableExperimentalFeature("SwiftConcurrency") +let swiftConcurrency: SwiftSetting = .enableExperimentalFeature("StrictConcurrency") #else -let swiftConcurrency: SwiftSetting = .enableUpcomingFeature("SwiftConcurrency") +let swiftConcurrency: SwiftSetting = .enableUpcomingFeature("StrictConcurrency") #endif @@ -37,6 +37,7 @@ let package = Package( .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"), + .package(url: "https://github.com/apple/swift-atomics.git", from: "1.2.0"), .package(url: "https://github.com/StanfordBDHG/XCTestExtensions.git", from: "0.4.11") ] + swiftLintPackage(), targets: [ @@ -47,7 +48,8 @@ let package = Package( .product(name: "NIO", package: "swift-nio"), .product(name: "OrderedCollections", package: "swift-collections"), .product(name: "SpeziFoundation", package: "SpeziFoundation"), - .product(name: "ByteCoding", package: "SpeziNetworking") + .product(name: "ByteCoding", package: "SpeziNetworking"), + .product(name: "Atomics", package: "swift-atomics") ], resources: [ .process("Resources") @@ -82,10 +84,21 @@ let package = Package( plugins: [] + swiftLintPlugin() ), .testTarget( - name: "BluetoothServicesTests", + name: "SpeziBluetoothTests", dependencies: [ - .target(name: "SpeziBluetoothServices"), .target(name: "SpeziBluetooth"), + .target(name: "SpeziBluetoothServices") + ], + swiftSettings: [ + swiftConcurrency + ], + plugins: [] + swiftLintPlugin() + ), + .testTarget( + name: "SpeziBluetoothServicesTests", + dependencies: [ + .target(name: "SpeziBluetooth"), + .target(name: "SpeziBluetoothServices"), .product(name: "XCTByteCoding", package: "SpeziNetworking"), .product(name: "NIO", package: "swift-nio"), .product(name: "XCTestExtensions", package: "XCTestExtensions") diff --git a/README.md b/README.md index 2c4cd49e..6b6cafae 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,9 @@ Note that the value types needs to be optional and conform to [`ByteCodable`](https://swiftpackageindex.com/stanfordspezi/spezifileformats/documentation/bytecoding/bytecodable) respectively. ```swift -class DeviceInformationService: BluetoothService { +struct DeviceInformationService: BluetoothService { + static let id: BTUUID = "180A" + @Characteristic(id: "2A29") var manufacturer: String? @Characteristic(id: "2A26") @@ -249,8 +251,12 @@ class MyDevice: BluetoothDevice { // declare dependency to a configured Spezi Module @Dependency var measurements: Measurements - required init() { - weightScale.$weightMeasurement.onChange(perform: handleNewMeasurement) + required init() {} + + func configure() { + weightScale.$weightMeasurement.onChange { [weak self] value in + self?.handleNewMeasurement(value) + } } private func handleNewMeasurement(_ measurement: WeightMeasurement) { diff --git a/Sources/SpeziBluetooth/Bluetooth.swift b/Sources/SpeziBluetooth/Bluetooth.swift index 012522ce..6e28f055 100644 --- a/Sources/SpeziBluetooth/Bluetooth.swift +++ b/Sources/SpeziBluetooth/Bluetooth.swift @@ -32,8 +32,8 @@ import Spezi /// [`ByteCodable`](https://swiftpackageindex.com/stanfordspezi/spezifileformats/documentation/bytecoding/bytecodable) respectively. /// /// ```swift -/// class DeviceInformationService: BluetoothService { -/// static let id = CBUUID(string: "180A") +/// struct DeviceInformationService: BluetoothService { +/// static let id: BTUUID = "180A" /// /// @Characteristic(id: "2A29") /// var manufacturer: String? @@ -194,8 +194,12 @@ import Spezi /// // declare dependency to a configured Spezi Module /// @Dependency var measurements: Measurements /// -/// required init() { -/// weightScale.$weightMeasurement.onChange(perform: handleNewMeasurement) +/// required init() {} +/// +/// func configure() { +/// weightScale.$weightMeasurement.onChange { [weak self] value in +/// self?.handleNewMeasurement(value) +/// } /// } /// /// private func handleNewMeasurement(_ measurement: WeightMeasurement) { @@ -226,59 +230,49 @@ import Spezi /// ### Manually Manage Powered State /// - ``powerOn()`` /// - ``powerOff()`` -public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { +@SpeziBluetooth +public final class Bluetooth: Module, EnvironmentAccessible, Sendable { @Observable class Storage { var nearbyDevices: OrderedDictionary = [:] } - static let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "Bluetooth") + nonisolated static let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "Bluetooth") - /// The Bluetooth Executor from the underlying BluetoothManager. - let bluetoothQueue: DispatchSerialQueue - - private let bluetoothManager: BluetoothManager + @SpeziBluetooth private let bluetoothManager = BluetoothManager() /// The Bluetooth device configuration. /// /// Set of configured ``BluetoothDevice`` with their corresponding ``DiscoveryCriteria``. public nonisolated let configuration: Set - private let discoveryConfiguration: Set - - private let _storage = Storage() - private var logger: Logger { - Self.logger + // sadly Swifts "lazy var" won't work here with strict concurrency as it doesn't isolate the underlying lazy storage + @SpeziBluetooth private var _lazy_discoveryConfiguration: Set? + // swiftlint:disable:previous discouraged_optional_collection identifier_name + @SpeziBluetooth private var discoveryConfiguration: Set { + guard let discoveryConfiguration = _lazy_discoveryConfiguration else { + let discovery = configuration.parseDiscoveryDescription() + self._lazy_discoveryConfiguration = discovery + return discovery + } + return discoveryConfiguration } + @MainActor private let _storage = Storage() // storage for observability + /// Represents the current state of Bluetooth. - nonisolated public var state: BluetoothState { + public nonisolated var state: BluetoothState { bluetoothManager.state } - /// Subscribe to changes of the `state` property. - /// - /// Creates an `AsyncStream` that yields all **future** changes to the ``state`` property. - public var stateSubscription: AsyncStream { - bluetoothManager.assumeIsolated { manager in - manager.stateSubscription - } - } - /// Whether or not we are currently scanning for nearby devices. - nonisolated public var isScanning: Bool { + public nonisolated var isScanning: Bool { bluetoothManager.isScanning } - /// Support for the auto connect modifier. - @_documentation(visibility: internal) - nonisolated public var hasConnectedDevices: Bool { - bluetoothManager.hasConnectedDevices - } - - private var nearbyDevices: OrderedDictionary { + @MainActor private var nearbyDevices: OrderedDictionary { get { _storage.nearbyDevices } @@ -287,20 +281,31 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { } } + /// Subscribe to changes of the `state` property. + /// + /// Creates an `AsyncStream` that yields all **future** changes to the ``state`` property. + public var stateSubscription: AsyncStream { + bluetoothManager.stateSubscription + } + /// Dictionary of all initialized devices. /// /// 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. - private var initializedDevices: OrderedDictionary = [:] + @SpeziBluetooth private var initializedDevices: OrderedDictionary = [:] @Application(\.spezi) - private var spezi + @MainActor private var spezi + + private nonisolated var logger: Logger { + Self.logger + } /// Stores the connected device instance for every configured ``BluetoothDevice`` type. - @Model private var connectedDevicesModel = ConnectedDevicesModel() + @Model @MainActor private var connectedDevicesModel = ConnectedDevicesModel() /// Injects the ``BluetoothDevice`` instances from the `ConnectedDevices` model into the SwiftUI environment. - @Modifier private var devicesInjector: ConnectedDevicesEnvironmentModifier + @Modifier @MainActor private var devicesInjector: ConnectedDevicesEnvironmentModifier /// Configure the Bluetooth Module. @@ -315,24 +320,18 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { /// ``` /// /// - Parameter devices: The set of configured devices. + @MainActor public init( @DiscoveryDescriptorBuilder _ devices: @Sendable () -> Set ) { let configuration = devices() - let deviceTypes = configuration.deviceTypes - let discovery = configuration.parseDiscoveryDescription() - let bluetoothManager = BluetoothManager() - - self.bluetoothQueue = bluetoothManager.bluetoothQueue - self.bluetoothManager = bluetoothManager self.configuration = configuration - self.discoveryConfiguration = discovery - self._devicesInjector = Modifier(wrappedValue: ConnectedDevicesEnvironmentModifier(configuredDeviceTypes: deviceTypes)) + self.devicesInjector = ConnectedDevicesEnvironmentModifier(configuredDeviceTypes: deviceTypes) - Task { - await self.observeDiscoveredDevices() + Task { @SpeziBluetooth in + self.observeDiscoveredDevices() } } @@ -343,10 +342,9 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { /// /// - Note : The underlying `CBCentralManager` is lazily allocated and deallocated once it isn't needed anymore. /// This is used to delay Bluetooth permission and power prompts to the latest possible moment avoiding unexpected interruptions. + @SpeziBluetooth public func powerOn() { - bluetoothManager.assumeIsolated { manager in - manager.powerOn() - } + bluetoothManager.powerOn() } /// Request to power down the Bluetooth Central. @@ -355,111 +353,125 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { /// /// - Note : The underlying `CBCentralManager` is lazily allocated and deallocated once it isn't needed anymore. /// This is used to delay Bluetooth permission and power prompts to the latest possible moment avoiding unexpected interruptions. + @SpeziBluetooth public func powerOff() { - bluetoothManager.assumeIsolated { manager in - manager.powerOff() - } + bluetoothManager.powerOff() } + @SpeziBluetooth private func observeDiscoveredDevices() { - self.assertIsolated("This didn't move to the actor even if it should.") - bluetoothManager.assumeIsolated { manager in - manager.onChange(of: \.discoveredPeripherals) { [weak self] discoveredDevices in - guard let self = self else { - return - } - - self.assertIsolated("BluetoothManager peripherals change closure was unexpectedly not called on the Bluetooth SerialExecutor.") - self.assumeIsolated { bluetooth in - bluetooth.observeDiscoveredDevices() - bluetooth.handleUpdatedNearbyDevicesChange(discoveredDevices) - } + bluetoothManager.onChange(of: \.discoveredPeripherals) { [weak self] discoveredDevices in + guard let self = self else { + return } - // we currently do not track the `retrievedPeripherals` collection of the BluetoothManager. The assumption is that - // `retrievePeripheral` is always called through the `Bluetooth` module so we are aware of everything anyways. - // And we don't care about the rest. + self.observeDiscoveredDevices() + self.handleUpdatedNearbyDevicesChange(discoveredDevices) } + + // we currently do not track the `retrievedPeripherals` collection of the BluetoothManager. The assumption is that + // `retrievePeripheral` is always called through the `Bluetooth` module so we are aware of everything anyways. + // And we don't care about the rest. } + @SpeziBluetooth private func handleUpdatedNearbyDevicesChange(_ discoveredDevices: OrderedDictionary) { - var checkForConnected = false - - // remove all delete keys - for key in nearbyDevices.keys where discoveredDevices[key] == nil { - checkForConnected = true + var newlyPreparedDevices: Set = [] // track for which device instances we need to call Spezi/loadModule(...) - nearbyDevices.removeValue(forKey: key) - - // device instances will be automatically deallocated via `notifyDeviceDeinit` - } - - // add devices for new keys - for (uuid, peripheral) in discoveredDevices where nearbyDevices[uuid] == nil { + let discoveredDeviceInstances: [UUID: any BluetoothDevice] = discoveredDevices.reduce(into: [:]) { partialResult, entry in let device: any BluetoothDevice - // check if we already now the device! - if let persistentDevice = initializedDevices[uuid]?.anyValue { - device = persistentDevice + // The union of initializedDevices.keys and discoveredDevices.keys are devices that are connected. + // Initialized devices might contain additional devices that were removed and discoveredDevices might contain additional + // that are new. + if let persistedDevice = initializedDevices[entry.key]?.anyValue { + device = persistedDevice } else { - let advertisementData = peripheral.advertisementData + let advertisementData = entry.value.advertisementData guard let configuration = configuration.find(for: advertisementData, logger: logger) else { - logger.warning("Ignoring peripheral \(peripheral.debugDescription) that cannot be mapped to a device class.") - continue + logger.warning("Ignoring peripheral \(entry.value.debugDescription) that cannot be mapped to a device class.") + return } - device = prepareDevice(id: uuid, configuration.deviceType, peripheral: peripheral) - Task { @MainActor in - await loadDevice(device, using: spezi) - } + // prepareDevice will insert into initializedDevices + device = prepareDevice(id: entry.key, configuration.deviceType, peripheral: entry.value) + newlyPreparedDevices.insert(entry.key) } - nearbyDevices[uuid] = device - - checkForConnected = true + partialResult[entry.key] = device } - if checkForConnected { - // ensure that we get notified about, e.g., a connected peripheral that is instantly removed - handlePeripheralStateChange() + + Task { @MainActor [newlyPreparedDevices] in + var checkForConnected = false + + // remove all delete keys + for key in nearbyDevices.keys where discoveredDeviceInstances[key] == nil { + checkForConnected = true + + nearbyDevices.removeValue(forKey: key) + + // device instances will be automatically deallocated via `notifyDeviceDeinit` + } + + // add devices for new keys + for (uuid, device) in discoveredDeviceInstances where nearbyDevices[uuid] == nil { + checkForConnected = true + + nearbyDevices[uuid] = device + + if newlyPreparedDevices.contains(uuid) { + // 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. + spezi.loadModule(device, ownership: .external) + } + } + + if checkForConnected { + // ensure that we get notified about, e.g., a connected peripheral that is instantly removed + await handlePeripheralStateChange() + } } } @_spi(Internal) + @SpeziBluetooth public func _initializedDevicesCount() -> Int { // swiftlint:disable:this identifier_name initializedDevices.count } + @SpeziBluetooth private func observePeripheralState(of uuid: UUID) { // We must make sure that we don't capture the `peripheral` within the `onChange` closure as otherwise // this would require a reference cycle within the `BluetoothPeripheral` class. // Therefore, we have this indirection via the uuid here. - guard let peripheral = bluetoothManager.assumeIsolated({ $0.knownPeripherals[uuid] }) else { + guard let peripheral = bluetoothManager.knownPeripherals[uuid] else { return } - peripheral.assumeIsolated { peripheral in - peripheral.onChange(of: \.state) { [weak self] _ in - guard let self = self else { - return - } - - self.assumeIsolated { bluetooth in - bluetooth.observePeripheralState(of: uuid) - bluetooth.handlePeripheralStateChange() - } + peripheral.onChange(of: \.state) { [weak self] _ in + guard let self = self else { + return } + + self.observePeripheralState(of: uuid) + self.handlePeripheralStateChange() } } + @SpeziBluetooth private func handlePeripheralStateChange() { // check for active connected device - let connectedDevices = bluetoothManager.assumeIsolated { $0.knownPeripherals } + let connectedDevices = bluetoothManager.knownPeripherals .filter { _, value in - value.assumeIsolated { $0.state } == .connected + value.state == .connected } .compactMap { key, _ -> (UUID, any BluetoothDevice)? in + // initializedDevices might contain devices that are not loaded as a module yet. + // However, a Task that will load the module will always be scheduled before the @MainActor task below that injects it + // into the SwiftUI environment. + // map them to their devices class guard let device = initializedDevices[key]?.anyValue else { return nil @@ -470,8 +482,8 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { result[tuple.0] = tuple.1 } - let connectedDevicesModel = connectedDevicesModel Task { @MainActor in + let connectedDevicesModel = self.connectedDevicesModel connectedDevicesModel.update(with: connectedDevices) } } @@ -482,7 +494,8 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { /// return nearby devices that are of the provided ``BluetoothDevice`` type. /// - Parameter device: The device type to filter for. /// - Returns: A list of nearby devices of a given ``BluetoothDevice`` type. - public nonisolated func nearbyDevices(for device: Device.Type = Device.self) -> [Device] { + @MainActor + public func nearbyDevices(for device: Device.Type = Device.self) -> [Device] { _storage.nearbyDevices.values.compactMap { device in device as? Device } @@ -503,6 +516,7 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { /// - uuid: The Bluetooth peripheral identifier. /// - device: The device type to use for the peripheral. /// - Returns: The retrieved device. Returns nil if the Bluetooth Central could not be powered on (e.g., not authorized) or if no peripheral with the requested identifier was found. + @SpeziBluetooth public func retrieveDevice( for uuid: UUID, as device: Device.Type = Device.self @@ -532,7 +546,9 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { let device = prepareDevice(id: uuid, Device.self, peripheral: peripheral) - await loadDevice(device, using: spezi) + // 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. + await spezi.loadModule(device, ownership: .external) // The semantics of retrievePeripheral is as follows: it returns a BluetoothPeripheral that is weakly allocated by the BluetoothManager.´ // Therefore, the BluetoothPeripheral is owned by the caller and is automatically deallocated if the caller decides to not require the instance anymore. @@ -560,31 +576,36 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { /// if we don't hear back from the device. Minimum is 1 second. /// - autoConnect: If enabled, the bluetooth manager will automatically connect to /// the nearby device if only one is found for a given time threshold. + @SpeziBluetooth public func scanNearbyDevices( minimumRSSI: Int? = nil, advertisementStaleInterval: TimeInterval? = nil, autoConnect: Bool = false ) { - bluetoothManager.assumeIsolated { manager in - manager.scanNearbyDevices( - discovery: discoveryConfiguration, - minimumRSSI: minimumRSSI, - advertisementStaleInterval: advertisementStaleInterval, - autoConnect: autoConnect - ) - } + bluetoothManager.scanNearbyDevices( + discovery: discoveryConfiguration, + minimumRSSI: minimumRSSI, + advertisementStaleInterval: advertisementStaleInterval, + autoConnect: autoConnect + ) } /// Stop scanning for nearby bluetooth devices. + @SpeziBluetooth public func stopScanning() { - bluetoothManager.assumeIsolated { manager in - manager.stopScanning() - } + bluetoothManager.stopScanning() } } extension Bluetooth: BluetoothScanner { + /// Support for the auto connect modifier. + @_documentation(visibility: internal) + public var hasConnectedDevices: Bool { + bluetoothManager.hasConnectedDevices + } + + @SpeziBluetooth func scanNearbyDevices(_ state: BluetoothModuleDiscoveryState) { scanNearbyDevices( minimumRSSI: state.minimumRSSI, @@ -593,6 +614,7 @@ extension Bluetooth: BluetoothScanner { ) } + @SpeziBluetooth func updateScanningState(_ state: BluetoothModuleDiscoveryState) { let managerState = BluetoothManagerDiscoveryState( configuredDevices: discoveryConfiguration, @@ -601,15 +623,14 @@ extension Bluetooth: BluetoothScanner { autoConnect: state.autoConnect ) - bluetoothManager.assumeIsolated { manager in - manager.updateScanningState(managerState) - } + bluetoothManager.updateScanningState(managerState) } } // MARK: - Device Handling extension Bluetooth { + @SpeziBluetooth func prepareDevice(id uuid: UUID, _ device: Device.Type, peripheral: BluetoothPeripheral) -> Device { let device = device.init() @@ -635,23 +656,21 @@ extension Bluetooth { return device } - @MainActor - func loadDevice(_ device: some BluetoothDevice, using spezi: Spezi) { - // 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. - spezi.loadModule(device, ownership: .external) // implicitly calls the configure() method once everything is injected - } - nonisolated func notifyDeviceDeinit(for uuid: UUID) { Task { @SpeziBluetooth in - await _notifyDeviceDeinit(for: uuid) + _notifyDeviceDeinit(for: uuid) } } + @SpeziBluetooth private func _notifyDeviceDeinit(for uuid: UUID) { - precondition(nearbyDevices[uuid] == nil, "\(#function) was wrongfully called for a device that is still referenced: \(uuid)") + #if DEBUG || TEST + Task { @MainActor in + assert(nearbyDevices[uuid] == nil, "\(#function) was wrongfully called for a device that is still referenced: \(uuid)") + } + #endif // this clears our weak reference that we use to reuse already created device class once they connect let removedEntry = initializedDevices.removeValue(forKey: uuid) diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift index 3177cf7e..216b2c92 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT // - +@preconcurrency import class CoreBluetooth.CBCentralManager // swiftlint:disable:this duplicate_imports import CoreBluetooth import NIO import Observation @@ -80,20 +80,31 @@ import OSLog /// ### Manually Manage Powered State /// - ``powerOn()`` /// - ``powerOff()`` -public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable:this type_body_length +@SpeziBluetooth +public class BluetoothManager: Observable, Sendable, Identifiable { // swiftlint:disable:this type_body_length private let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "BluetoothManager") - /// The serial executor for all Bluetooth related functionality. - let bluetoothQueue: DispatchSerialQueue - @Lazy private var centralManager: CBCentralManager - private var centralDelegate: Delegate? - private var isScanningObserver: KVOStateObserver? + private var _centralManager: CBCentralManager? - private let _storage: ObservableStorage - private var isolatedStorage: ObservableStorage { - _storage + private var centralManager: CBCentralManager { + guard let centralManager = _centralManager else { + let centralManager = supplyCBCentral() + self._centralManager = centralManager + return centralManager + } + return centralManager } + private lazy var centralDelegate: Delegate = { // swiftlint:disable:this weak_delegate + let delegate = Delegate() + delegate.initManager(self) + return delegate + }() + + private var isScanningObserver: KVOStateDidChangeObserver? + + private let storage = BluetoothManagerStorage() + /// Flag indicating that we want the CBCentral to stay allocated. private var keepPoweredOn = false @@ -103,61 +114,35 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable /// The list of nearby bluetooth devices. /// /// This array contains all discovered bluetooth peripherals and those with which we are currently connected. - nonisolated public var nearbyPeripherals: [BluetoothPeripheral] { - Array(_storage.discoveredPeripherals.values) + public nonisolated var nearbyPeripherals: [BluetoothPeripheral] { + Array(storage.readOnlyDiscoveredPeripherals.values) } /// Represents the current state of the Bluetooth Manager. - nonisolated public var state: BluetoothState { - _storage.state + public nonisolated var state: BluetoothState { + storage.readOnlyState } /// Subscribe to changes of the `state` property. /// /// Creates an `AsyncStream` that yields all **future** changes to the ``state`` property. - public var stateSubscription: AsyncStream { - AsyncStream(BluetoothState.self) { continuation in - let id = isolatedStorage.subscribe(continuation) - continuation.onTermination = { @Sendable [weak self] _ in - guard let self = self else { - return - } - Task.detached { @SpeziBluetooth in - await self.isolatedStorage.unsubscribe(for: id) - } - } - } + public nonisolated var stateSubscription: AsyncStream { + storage.stateSubscription } /// Whether or not we are currently scanning for nearby devices. - nonisolated public var isScanning: Bool { - _storage.isScanning + public nonisolated var isScanning: Bool { + storage.readOnlyIsScanning } /// The list of discovered and connected bluetooth devices indexed by their identifier UUID. /// The state is isolated to our `dispatchQueue`. - private(set) var discoveredPeripherals: OrderedDictionary { - get { - isolatedStorage.discoveredPeripherals - } - _modify { - yield &isolatedStorage.discoveredPeripherals - } - set { - isolatedStorage.discoveredPeripherals = newValue - } + var discoveredPeripherals: OrderedDictionary { + storage.discoveredPeripherals } - private(set) var retrievedPeripherals: OrderedDictionary> { - get { - isolatedStorage.retrievedPeripherals - } - _modify { - yield &isolatedStorage.retrievedPeripherals - } - set { - isolatedStorage.retrievedPeripherals = newValue - } + var retrievedPeripherals: OrderedDictionary> { + storage.retrievedPeripherals } /// The combined collection of `discoveredPeripherals` and `retrievedPeripherals`. @@ -181,50 +166,39 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable /// Initialize a new Bluetooth Manager with provided device description and optional configuration options. - public init() { - let dispatchQueue = DispatchQueue(label: "edu.stanford.spezi.bluetooth", qos: .userInitiated) - guard let serialQueue = dispatchQueue as? DispatchSerialQueue else { - preconditionFailure("Dispatch queue \(dispatchQueue.label) was not initialized to be serial!") - } - - self.bluetoothQueue = serialQueue - - self._storage = ObservableStorage() - - let delegate = Delegate() - self.centralDelegate = delegate - self._centralManager = Lazy() + public nonisolated init() {} + func supplyCBCentral() -> CBCentralManager { // The Bluetooth permission alert shows every time when a CBCentralManager is initialized. // If we already have permissions the a power alert will be shown if the user has Bluetooth disabled. // To have those alerts shown at the right time (and repeatedly), we lazily initialize the CBCentralManager and also deinit it // once we don't use it anymore (we are not scanning and no device is currently connected). // All this state handling happens here within the closures passed to the `Lazy` property wrapper. - _centralManager.supply { [weak self] in - // As `centralManager` is actor isolated, the initializer closure and the onCleanup closure - // can both be assumed to be isolated to the BluetoothManager. - let centralDelegate = self?.assumeIsolated { $0.centralDelegate } - let central = CBCentralManager( - delegate: centralDelegate, - queue: serialQueue, - options: [CBCentralManagerOptionShowPowerAlertKey: true] - ) - - self?.assumeIsolated { manager in - manager.isScanningObserver = KVOStateObserver(receiver: manager, entity: central, property: \.isScanning) - } - self?.logger.debug("Initialized the underlying CBCentralManager.") - return central - } onCleanup: { [weak self] in - self?.logger.debug("Destroyed the underlying CBCentralManager.") - self?.assumeIsolated { manager in - manager.isScanningObserver = nil + let central = CBCentralManager( + delegate: centralDelegate, + queue: SpeziBluetooth.shared.dispatchQueue, + options: [CBCentralManagerOptionShowPowerAlertKey: true] + ) + + isScanningObserver = KVOStateDidChangeObserver(entity: central, property: \.isScanning) { [weak self] value in + guard let self else { + return + } + storage.isScanning = value + if !isScanning { + handleStoppedScanning() } } - // delay using self so we don't leave isolation - delegate.initManager(self) + logger.debug("Initialized the underlying CBCentralManager.") + return central + } + + func cleanupCBCentral() { + _centralManager = nil + isScanningObserver = nil + logger.debug("Destroyed the underlying CBCentralManager.") } /// Request to power up the Bluetooth Central. @@ -306,10 +280,10 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable logger.debug("Starting scanning for nearby devices ...") centralManager.scanForPeripherals( - withServices: session.assumeIsolated { $0.serviceDiscoveryIds }, + withServices: session.serviceDiscoveryIds?.map { $0.cbuuid }, options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] ) - isolatedStorage.isScanning = centralManager.isScanning // ensure this is propagated instantly + storage.isScanning = centralManager.isScanning // ensure this is propagated instantly } private func _restartScanning(using session: DiscoverySession) { @@ -319,17 +293,17 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable centralManager.stopScan() centralManager.scanForPeripherals( - withServices: session.assumeIsolated { $0.serviceDiscoveryIds }, + withServices: session.serviceDiscoveryIds?.map { $0.cbuuid }, options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] ) - isolatedStorage.isScanning = centralManager.isScanning // ensure this is propagated instantly + storage.isScanning = centralManager.isScanning // ensure this is propagated instantly } /// Stop scanning for nearby bluetooth devices. public func stopScanning() { if isScanning { // transitively checks for state == .poweredOn centralManager.stopScan() - isolatedStorage.isScanning = centralManager.isScanning // ensure this is synced + storage.isScanning = centralManager.isScanning // ensure this is synced logger.debug("Scanning stopped") } @@ -378,7 +352,7 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable /// - description: The expected device configuration of the peripheral. This is used to discover service and characteristics if you connect to the peripheral- /// - Returns: The retrieved Peripheral. Returns nil if the Bluetooth Central could not be powered on (e.g., not authorized) or if no peripheral with the requested identifier was found. public func retrievePeripheral(for uuid: UUID, with description: DeviceDescription) async -> BluetoothPeripheral? { - if !_centralManager.isInitialized { + if _centralManager == nil { _ = centralManager // make sure central is initialized! // we are waiting for the next state transition, ideally to poweredOn state! @@ -413,31 +387,27 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable rssi: 127 // value of 127 signifies unavailability of RSSI value ) - retrievedPeripherals.updateValue(WeakReference(device), forKey: peripheral.identifier) + storage.retrievedPeripherals.updateValue(WeakReference(device), forKey: peripheral.identifier) return device } - func onChange(of keyPath: KeyPath, perform closure: @escaping (Value) -> Void) { - _storage.onChange(of: keyPath, perform: closure) + func onChange(of keyPath: KeyPath, perform closure: @escaping (Value) -> Void) { + storage.onChange(of: keyPath, perform: closure) } func clearDiscoveredPeripheral(forKey id: UUID) { if let peripheral = discoveredPeripherals[id] { // `handleDiscarded` must be called before actually removing it from the dictionary to make sure peripherals can react to this event - peripheral.assumeIsolated { device in - device.handleDiscarded() - } + peripheral.handleDiscarded() // Users might keep reference to Peripheral object. Therefore, we keep it as a weak reference so we can forward delegate calls. - retrievedPeripherals[id] = WeakReference(peripheral) + storage.retrievedPeripherals[id] = WeakReference(peripheral) } - discoveredPeripherals.removeValue(forKey: id) + storage.discoveredPeripherals.removeValue(forKey: id) - discoverySession?.assumeIsolated { session in - session.clearManuallyDisconnectedDevice(for: id) - } + discoverySession?.clearManuallyDisconnectedDevice(for: id) checkForCentralDeinit() } @@ -452,7 +422,7 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable } guard let peripheral = reference.value else { - retrievedPeripherals.removeValue(forKey: uuid) + storage.retrievedPeripherals.removeValue(forKey: uuid) return nil } return peripheral @@ -463,19 +433,17 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable return // is not weakly referenced } - retrievedPeripherals[peripheral.id] = nil - discoveredPeripherals[peripheral.id] = peripheral + storage.retrievedPeripherals[peripheral.id] = nil + storage.discoveredPeripherals[peripheral.id] = peripheral } /// The peripheral was finally deallocated. /// /// This method makes sure that all (weak) references to the de-initialized peripheral are fully cleared. func handlePeripheralDeinit(id uuid: UUID) { - retrievedPeripherals.removeValue(forKey: uuid) + storage.retrievedPeripherals.removeValue(forKey: uuid) - discoverySession?.assumeIsolated { session in - session.clearManuallyDisconnectedDevice(for: uuid) - } + discoverySession?.clearManuallyDisconnectedDevice(for: uuid) checkForCentralDeinit() } @@ -491,46 +459,40 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable } guard discoveredPeripherals.isEmpty && retrievedPeripherals.isEmpty else { - let discoveredCount = discoveredPeripherals.count - let retrievedCount = retrievedPeripherals.count - logger.debug("Not deallocating central. Devices are still associated: discovered: \(discoveredCount), retrieved: \(retrievedCount)") + logger.debug(""" + Not deallocating central. Devices are still associated: \ + discovered: \(self.discoveredPeripherals.count), \ + retrieved: \(self.retrievedPeripherals.count) + """) return // there are still associated devices } - _centralManager.destroy() - isolatedStorage.state = .unknown + cleanupCBCentral() + storage.update(state: .unknown) } func connect(peripheral: BluetoothPeripheral) { - logger.debug("Trying to connect to \(peripheral.cbPeripheral.debugIdentifier) ...") + logger.debug("Trying to connect to \(peripheral.debugDescription) ...") - let cancelled = discoverySession?.assumeIsolated { session in - session.cancelStaleTask(for: peripheral) - } + let cancelled = discoverySession?.cancelStaleTask(for: peripheral) self.centralManager.connect(peripheral.cbPeripheral, options: nil) if cancelled == true { - discoverySession?.assumeIsolated { session in - session.scheduleStaleTaskForOldestActivityDevice(ignore: peripheral) - } + discoverySession?.scheduleStaleTaskForOldestActivityDevice(ignore: peripheral) } } func disconnect(peripheral: BluetoothPeripheral) { - logger.debug("Disconnecting peripheral \(peripheral.cbPeripheral.debugIdentifier) ...") + logger.debug("Disconnecting peripheral \(peripheral.debugDescription) ...") // stale timer is handled in the delegate method centralManager.cancelPeripheralConnection(peripheral.cbPeripheral) - discoverySession?.assumeIsolated { session in - session.deviceManuallyDisconnected(id: peripheral.id) - } + discoverySession?.deviceManuallyDisconnected(id: peripheral.id) } private func handledConnected(device: BluetoothPeripheral) { - device.assumeIsolated { device in - device.handleConnect() - } + device.handleConnect() // we might have connected a bluetooth peripheral that was weakly referenced ensurePeripheralReference(device) @@ -539,116 +501,35 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable private func discardDevice(device: BluetoothPeripheral) { if let discoverySession, isScanning { // we will keep discarded devices for max 2s before the stale timer kicks off - let backdateInterval = max(0, discoverySession.assumeIsolated { $0.advertisementStaleInterval } - 2) + let backdateInterval = max(0, discoverySession.advertisementStaleInterval - 2) - device.assumeIsolated { device in - device.markLastActivity(.now - backdateInterval) - device.handleDisconnect() - } + device.markLastActivity(.now - backdateInterval) + device.handleDisconnect() // We just schedule the new timer if there is a device to schedule one for. - discoverySession.assumeIsolated { session in - session.scheduleStaleTaskForOldestActivityDevice() - } + discoverySession.scheduleStaleTaskForOldestActivityDevice() } else { - device.assumeIsolated { device in - device.markLastActivity() - device.handleDisconnect() - } + device.markLastActivity() + device.handleDisconnect() clearDiscoveredPeripheral(forKey: device.id) } } + deinit { discoverySession = nil - // non-isolated workaround for calling stopScanning() - if isScanning { - _storage.isScanning = false - _centralManager.wrappedValue.stopScan() - logger.debug("Scanning stopped") - } - - _storage.state = .unknown - _storage.discoveredPeripherals = [:] - _storage.retrievedPeripherals = [:] - centralDelegate = nil - - logger.debug("BluetoothManager destroyed") - } -} - - -extension BluetoothManager { - @Observable - final class ObservableStorage: ValueObservable { - var state: BluetoothState = .unknown { - didSet { - _$simpleRegistrar.triggerDidChange(for: \.state, on: self) - - for continuation in subscribedContinuations.values { - continuation.yield(state) - } + Task { @SpeziBluetooth [storage, _centralManager, isScanning, logger] in + if isScanning { + storage.isScanning = false + _centralManager?.stopScan() + logger.debug("Scanning stopped") } - } - - var isScanning = false { - didSet { - _$simpleRegistrar.triggerDidChange(for: \.isScanning, on: self) - } - } - var discoveredPeripherals: OrderedDictionary = [:] { - didSet { - _$simpleRegistrar.triggerDidChange(for: \.discoveredPeripherals, on: self) - } - } - - var retrievedPeripherals: OrderedDictionary> = [:] { - didSet { - _$simpleRegistrar.triggerDidChange(for: \.retrievedPeripherals, on: self) - } - } - - // swiftlint:disable:next identifier_name - @ObservationIgnored var _$simpleRegistrar = ValueObservationRegistrar() - - - private var subscribedContinuations: [UUID: AsyncStream.Continuation] = [:] - - init() {} - - - func subscribe(_ continuation: AsyncStream.Continuation) -> UUID { - let id = UUID() - subscribedContinuations[id] = continuation - return id - } - - func unsubscribe(for id: UUID) { - subscribedContinuations[id] = nil - } - - - deinit { - for continuation in subscribedContinuations.values { - continuation.finish() - } - subscribedContinuations.removeAll() - } - } -} - -extension BluetoothManager: KVOReceiver { - func observeChange(of keyPath: KeyPath, value: V) { - switch keyPath { - case \CBCentralManager.isScanning: - isolatedStorage.isScanning = value as! Bool // swiftlint:disable:this force_cast - if !self.isScanning { - self.handleStoppedScanning() - } - default: - break + storage.update(state: .unknown) + storage.discoveredPeripherals = [:] + storage.retrievedPeripherals = [:] + logger.debug("BluetoothManager destroyed") } } } @@ -662,17 +543,12 @@ extension BluetoothManager: BluetoothScanner { /// Support for the auto connect modifier. @_documentation(visibility: internal) - public nonisolated var hasConnectedDevices: Bool { - // We make sure to loop over all peripherals here. This ensures observability subscribes to all changing states. - // swiftlint:disable:next reduce_boolean - _storage.discoveredPeripherals.values.reduce(into: false) { partialResult, peripheral in - partialResult = partialResult || (peripheral.unsafeState.state != .disconnected) - } || _storage.retrievedPeripherals.values.reduce(into: false, { partialResult, reference in - // swiftlint:disable:previous reduce_boolean - if let peripheral = reference.value { - partialResult = partialResult || (peripheral.unsafeState.state != .disconnected) - } - }) + public var hasConnectedDevices: Bool { + storage.maHasConnectedDevices + } + + @SpeziBluetooth var sbHasConnectedDevices: Bool { + storage.hasConnectedDevices // support for DiscoverySession } func updateScanningState(_ state: BluetoothManagerDiscoveryState) { @@ -680,9 +556,7 @@ extension BluetoothManager: BluetoothScanner { return } - let discoveryItemsChanged = discoverySession.assumeIsolated { session in - session.updateConfigurationReportingDiscoveryItemsChanged(state) - } + let discoveryItemsChanged = discoverySession.updateConfigurationReportingDiscoveryItemsChanged(state) if discoveryItemsChanged == true { _restartScanning(using: discoverySession) @@ -740,22 +614,20 @@ extension BluetoothManager { // same Runtime state as an executing Task that is actor isolated. // So whats the solution? We schedule onto a background SerialExecutor (@SpeziBluetooth) so we maintain execution // order and make sure to capture all important state before that. - Task { @SpeziBluetooth in - await manager.isolated { manager in - manager.isolatedStorage.state = state - logger.info("BluetoothManager central state is now \(manager.state)") - - if case .poweredOn = state { - manager.handlePoweredOn() - } else if case .unauthorized = state { - switch CBCentralManager.authorization { - case .denied: - logger.log("Unauthorized reason: Access to Bluetooth was denied.") - case .restricted: - logger.log("Unauthorized reason: Bluetooth is restricted.") - default: - break - } + Task { @SpeziBluetooth [logger] in + manager.storage.update(state: state) + logger.info("BluetoothManager central state is now \(manager.state)") + + if case .poweredOn = state { + manager.handlePoweredOn() + } else if case .unauthorized = state { + switch CBCentralManager.authorization { + case .denied: + logger.log("Unauthorized reason: Access to Bluetooth was denied.") + case .restricted: + logger.log("Unauthorized reason: Bluetooth is restricted.") + default: + break } } } @@ -773,56 +645,50 @@ extension BluetoothManager { return } - Task { @SpeziBluetooth in - await manager.isolated { manager in - guard let session = manager.discoverySession, - manager.isScanning else { - return - } + let data = AdvertisementData(advertisementData) - // ensure the signal strength is not too low - guard session.assumeIsolated({ $0.isInRange(rssi: rssi) }) else { - return // logging this would just be to verbose, so we don't. - } + let peripheral = CBInstance(instantiatedOnDispatchQueue: peripheral) - let data = AdvertisementData(advertisementData) + Task { @SpeziBluetooth [logger, data] in + guard let session = manager.discoverySession, + manager.isScanning else { + return + } + // ensure the signal strength is not too low + guard session.isInRange(rssi: rssi) else { + return // logging this would just be to verbose, so we don't. + } - // check if we already seen this device! - if let device = manager.knownPeripheral(for: peripheral.identifier) { - device.assumeIsolated { device in - device.markLastActivity() - device.update(advertisement: data, rssi: rssi.intValue) - } - // we might have discovered a previously "retrieved" peripheral that must be strongly referenced now - manager.ensurePeripheralReference(device) + // check if we already seen this device! + if let device = manager.knownPeripheral(for: peripheral.identifier) { + device.markLastActivity() + device.update(advertisement: data, rssi: rssi.intValue) - session.assumeIsolated { session in - session.deviceDiscoveryPostAction(device: device, newlyDiscovered: false) - } - return - } + // we might have discovered a previously "retrieved" peripheral that must be strongly referenced now + manager.ensurePeripheralReference(device) - logger.debug("Discovered peripheral \(peripheral.debugIdentifier) at \(rssi.intValue) dB (data: \(advertisementData))") + session.deviceDiscoveryPostAction(device: device, newlyDiscovered: false) + return + } - let descriptor = session.assumeIsolated { $0.configuredDevices } - .find(for: data, logger: logger) - let device = BluetoothPeripheral( - manager: manager, - peripheral: peripheral, - configuration: descriptor?.device ?? DeviceDescription(), - advertisementData: data, - rssi: rssi.intValue - ) - // save local-copy, such CB doesn't deallocate it - manager.discoveredPeripherals.updateValue(device, forKey: peripheral.identifier) + logger.debug("Discovered peripheral \(peripheral.debugIdentifier) at \(rssi.intValue) dB (data: \(String(describing: data))") + let descriptor = session.configuredDevices.find(for: data, logger: logger) - session.assumeIsolated { session in - session.deviceDiscoveryPostAction(device: device, newlyDiscovered: true) - } - } + let device = BluetoothPeripheral( + manager: manager, + peripheral: peripheral.cbObject, + configuration: descriptor?.device ?? DeviceDescription(), + advertisementData: data, + rssi: rssi.intValue + ) + // save local-copy, such CB doesn't deallocate it + manager.storage.discoveredPeripherals.updateValue(device, forKey: peripheral.identifier) + + + session.deviceDiscoveryPostAction(device: device, newlyDiscovered: true) } } @@ -831,17 +697,18 @@ extension BluetoothManager { return } - Task { @SpeziBluetooth in - await manager.isolated { manager in - guard let device = manager.knownPeripheral(for: peripheral.identifier) else { - logger.error("Received didConnect for unknown peripheral \(peripheral.debugIdentifier). Cancelling connection ...") - manager.centralManager.cancelPeripheralConnection(peripheral) - return - } - - logger.debug("Peripheral \(peripheral.debugIdentifier) connected.") - manager.handledConnected(device: device) + let peripheral = CBInstance(instantiatedOnDispatchQueue: peripheral) + Task { @SpeziBluetooth [logger] in + guard let device = manager.knownPeripheral(for: peripheral.identifier) else { + logger.error("Received didConnect for unknown peripheral \(peripheral.debugIdentifier). Cancelling connection ...") + manager.centralManager.cancelPeripheralConnection(peripheral.cbObject) + return } + + logger.debug("Peripheral \(peripheral.debugIdentifier) connected.") + manager.handledConnected(device: device) + + await manager.storage.cbDelegateSignal(connected: true, for: peripheral.identifier) } } @@ -853,25 +720,25 @@ extension BluetoothManager { // Documentation reads: "Because connection attempts don’t time out, a failed connection usually indicates a transient issue, // in which case you may attempt connecting to the peripheral again." - Task { @SpeziBluetooth in - await manager.isolated { manager in - guard let device = manager.knownPeripheral(for: peripheral.identifier) else { - logger.warning("Unknown peripheral \(peripheral.debugIdentifier) failed with error: \(String(describing: error))") - manager.centralManager.cancelPeripheralConnection(peripheral) - return - } - - if let error { - logger.error("Failed to connect to \(peripheral): \(error)") - } else { - logger.error("Failed to connect to \(peripheral)") - } + let peripheral = CBInstance(instantiatedOnDispatchQueue: peripheral) - // just to make sure - manager.centralManager.cancelPeripheralConnection(device.cbPeripheral) + Task { @SpeziBluetooth [logger] in + guard let device = manager.knownPeripheral(for: peripheral.identifier) else { + logger.warning("Unknown peripheral \(peripheral.debugIdentifier) failed with error: \(String(describing: error))") + manager.centralManager.cancelPeripheralConnection(peripheral.cbObject) + return + } - manager.discardDevice(device: device) + if let error { + logger.error("Failed to connect to \(peripheral.debugDescription): \(error)") + } else { + logger.error("Failed to connect to \(peripheral.debugDescription)") } + + // just to make sure + manager.centralManager.cancelPeripheralConnection(device.cbPeripheral) + + manager.discardDevice(device: device) } } @@ -881,21 +748,21 @@ extension BluetoothManager { return } - Task { @SpeziBluetooth in - await manager.isolated { manager in - guard let device = manager.knownPeripheral(for: peripheral.identifier) else { - logger.error("Received didDisconnect for unknown peripheral \(peripheral.debugIdentifier).") - return - } - - if let error { - logger.debug("Peripheral \(peripheral.debugIdentifier) disconnected due to an error: \(error)") - } else { - logger.debug("Peripheral \(peripheral.debugIdentifier) disconnected.") - } + let peripheral = CBInstance(instantiatedOnDispatchQueue: peripheral) + Task { @SpeziBluetooth [logger] in + guard let device = manager.knownPeripheral(for: peripheral.identifier) else { + logger.error("Received didDisconnect for unknown peripheral \(peripheral.debugIdentifier).") + return + } - manager.discardDevice(device: device) + if let error { + logger.debug("Peripheral \(peripheral.debugIdentifier) disconnected due to an error: \(error)") + } else { + logger.debug("Peripheral \(peripheral.debugIdentifier) disconnected.") } + + manager.discardDevice(device: device) + await manager.storage.cbDelegateSignal(connected: false, for: peripheral.identifier) } } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift index 96857c54..b1264781 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift @@ -12,12 +12,6 @@ import OSLog import SpeziFoundation -enum CharacteristicOnChangeHandler { - case value(_ closure: (Data) -> Void) - case instance(_ closure: (GATTCharacteristic?) -> Void) -} - - /// A nearby Bluetooth peripheral. /// /// This class represents a nearby Bluetooth peripheral. @@ -58,20 +52,19 @@ enum CharacteristicOnChangeHandler { /// /// ### Retrieving the latest signal strength /// - ``readRSSI()`` -public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this type_body_length +@SpeziBluetooth +public class BluetoothPeripheral { // swiftlint:disable:this type_body_length private let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "BluetoothDevice") - /// The serial DispatchQueue shared by the Bluetooth Manager. - let bluetoothQueue: DispatchSerialQueue private weak var manager: BluetoothManager? - private let peripheral: CBPeripheral + let cbPeripheral: CBPeripheral private let configuration: DeviceDescription - private let delegate: Delegate - private let stateObserver: KVOStateObserver + private let delegate: Delegate // swiftlint:disable:this weak_delegate + private var stateObserver: KVOStateDidChangeObserver? /// Observable state container for local state. - private let _storage: PeripheralStorage + private let storage: PeripheralStorage /// Ongoing accessed per characteristic. @@ -92,118 +85,61 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ /// A set of service ids we are currently awaiting characteristics discovery for - private var servicesAwaitingCharacteristicsDiscovery: Set = [] - - nonisolated var cbPeripheral: CBPeripheral { - peripheral - } + private var servicesAwaitingCharacteristicsDiscovery: Set = [] - nonisolated var unsafeState: PeripheralStorage { - _storage - } + /// The internally managed identifier for the peripheral. + public nonisolated let id: UUID /// The name of the peripheral. /// /// Returns the name reported through the Generic Access Profile, otherwise falls back to the local name. nonisolated public var name: String? { - _storage.name - } - - - /// The local name included in the advertisement. - nonisolated public private(set) var localName: String? { - get { - _storage.localName - } - set { - _storage.update(localName: newValue) - } - } - - nonisolated private(set) var peripheralName: String? { - get { - _storage.peripheralName - } - set { - _storage.update(peripheralName: newValue) - } + storage.name } /// The current signal strength. /// /// This value is automatically updated when the device is advertising. /// Once the device establishes a connection this has to be manually updated. - nonisolated public private(set) var rssi: Int { - get { - _storage.rssi - } - set { - _storage.update(rssi: newValue) - } + public nonisolated var rssi: Int { + storage.readOnlyRssi } /// The advertisement data of the last bluetooth advertisement. - nonisolated public private(set) var advertisementData: AdvertisementData { - get { - _storage.advertisementData - } - set { - _storage.update(advertisementData: newValue) - } + public nonisolated var advertisementData: AdvertisementData { + storage.readOnlyAdvertisementData } /// The current peripheral device state. - nonisolated public internal(set) var state: PeripheralState { - get { - _storage.state - } - set { - _storage.update(state: newValue) - } + public nonisolated var state: PeripheralState { + storage.readOnlyState } /// The list of discovered services. /// /// Services are discovered automatically upon connection - nonisolated public private(set) var services: [GATTService]? { // swiftlint:disable:this discouraged_optional_collection - get { - _storage.services - } - set { - if let newValue { - _storage.assign(services: newValue) - } - } + public var services: [GATTService]? { // swiftlint:disable:this discouraged_optional_collection + storage.services } /// The last device activity. /// /// Returns the date of the last advertisement received from the device or the point in time the device disconnected. /// Returns `now` if the device is currently connected. - nonisolated public private(set) var lastActivity: Date { - get { - if case .connected = state { - // we are currently connected or connecting/disconnecting, therefore last activity is defined as "now" - .now - } else { - _storage.lastActivity - } - } - set { - _storage.update(lastActivity: newValue) + nonisolated public var lastActivity: Date { + if case .connected = state { + // we are currently connected or connecting/disconnecting, therefore last activity is defined as "now" + .now + } else { + storage.readOnlyLastActivity } } /// Indicates that the peripheral is nearby. /// /// A device is nearby if either we consider it discovered because we are currently scanning or the device is connected. - nonisolated public private(set) var nearby: Bool { - get { - _storage.nearby - } - set { - _storage.update(nearby: newValue) - } + nonisolated public var nearby: Bool { + storage.readOnlyNearby } @@ -214,29 +150,30 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ advertisementData: AdvertisementData, rssi: Int ) { - self.bluetoothQueue = manager.bluetoothQueue - self.manager = manager - self.peripheral = peripheral + self.cbPeripheral = peripheral self.configuration = configuration - self._storage = PeripheralStorage( + self.id = peripheral.identifier + + self.storage = PeripheralStorage( peripheralName: peripheral.name, rssi: rssi, advertisementData: advertisementData, - state: peripheral.state + state: .init(from: peripheral.state) ) let delegate = Delegate() - let observer = KVOStateObserver(entity: peripheral, property: \.state) self.delegate = delegate - self.stateObserver = observer - // we have this separate initDevice methods as otherwise above access to `delegate` and `stateObserver` properties + self.stateObserver = KVOStateDidChangeObserver(entity: peripheral, property: \.state) { [weak self] value in + self?.storage.update(state: PeripheralState(from: value)) + } + + // we have this separate initDevice method as otherwise above access to `delegate` // would become non-isolated accesses (due to usage of self beforehand). delegate.initDevice(self) - observer.initReceiver(self) peripheral.delegate = delegate } @@ -255,9 +192,7 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ return } - manager.assumeIsolated { manager in - manager.connect(peripheral: self) - } + manager.connect(peripheral: self) } /// Disconnect the ongoing connection to the peripheral. @@ -271,17 +206,15 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ removeAllNotifications() - manager.assumeIsolated { manager in - manager.disconnect(peripheral: self) - } + manager.disconnect(peripheral: self) // ensure that it is updated instantly. - self.isolatedUpdate(of: \.state, PeripheralState(from: peripheral.state)) + storage.update(state: PeripheralState(from: cbPeripheral.state)) } /// Retrieve a service. /// - Parameter id: The Bluetooth service id. /// - Returns: The service instance if present. - public func getService(id: CBUUID) -> GATTService? { + public func getService(id: BTUUID) -> GATTService? { services?.first { service in service.uuid == id } @@ -292,34 +225,34 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ /// - characteristicId: The Bluetooth characteristic id. /// - serviceId: The Bluetooth service id. /// - Returns: The characteristic instance if present. - public func getCharacteristic(id characteristicId: CBUUID, on serviceId: CBUUID) -> GATTCharacteristic? { + public func getCharacteristic(id characteristicId: BTUUID, on serviceId: BTUUID) -> GATTCharacteristic? { getService(id: serviceId)?.getCharacteristic(id: characteristicId) } func onChange(of keyPath: KeyPath, perform closure: @escaping (Value) -> Void) { - _storage.onChange(of: keyPath, perform: closure) + storage.onChange(of: keyPath, perform: closure) } func handleConnect() { // ensure that it is updated instantly. - self.isolatedUpdate(of: \.state, PeripheralState(from: peripheral.state)) + storage.update(state: PeripheralState(from: cbPeripheral.state)) - logger.debug("Discovering services for \(self.peripheral.debugIdentifier) ...") - let services = configuration.services?.reduce(into: Set()) { result, description in - result.insert(description.serviceId) + logger.debug("Discovering services for \(self.cbPeripheral.debugIdentifier) ...") + let serviceIds = configuration.services?.reduce(into: Set()) { result, description in + result.insert(description.serviceId.cbuuid) } - if let services, services.isEmpty { - _storage.signalFullyDiscovered() + if let serviceIds, serviceIds.isEmpty { + storage.signalFullyDiscovered() } else { - peripheral.discoverServices(services.map { Array($0) }) + cbPeripheral.discoverServices(serviceIds.map { Array($0) }) } } /// Handles a disconnect or failed connection attempt. func handleDisconnect() { // ensure that it is updated instantly. - self.isolatedUpdate(of: \.state, PeripheralState(from: peripheral.state)) + storage.update(state: PeripheralState(from: cbPeripheral.state)) // clear all the ongoing access @@ -344,18 +277,17 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ } func handleDiscarded() { - isolatedUpdate(of: \.nearby, false) + storage.nearby = false } func markLastActivity(_ lastActivity: Date = .now) { - self.lastActivity = lastActivity + storage.lastActivity = lastActivity } func update(advertisement: AdvertisementData, rssi: Int) { - self.isolatedUpdate(of: \.localName, advertisement.localName) - self.isolatedUpdate(of: \.advertisementData, advertisement) - self.isolatedUpdate(of: \.rssi, rssi) - self.isolatedUpdate(of: \.nearby, true) + storage.advertisementData = advertisement + storage.rssi = rssi + storage.nearby = true } /// Determines if the device is considered stale. @@ -365,7 +297,7 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ /// - Parameter interval: The time interval after which the device is considered stale. /// - Returns: True if the device is considered stale given the above criteria. func isConsideredStale(interval: TimeInterval) -> Bool { - peripheral.state == .disconnected && lastActivity.addingTimeInterval(interval) < .now + cbPeripheral.state == .disconnected && lastActivity.addingTimeInterval(interval) < .now } /// Register a on-change handler for a characteristic. @@ -401,24 +333,24 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ /// - onChange: The on-change handler. /// - @Returns: Returns the ``OnChangeRegistration`` that can be used to cancel and deregister the on-change handler. public func registerOnChangeHandler( - service: CBUUID, - characteristic: CBUUID, + service: BTUUID, + characteristic: BTUUID, _ onChange: @escaping (Data) -> Void ) -> OnChangeRegistration { registerCharacteristicOnChange(service: service, characteristic: characteristic, .value(onChange)) } func registerOnChangeCharacteristicHandler( - service: CBUUID, - characteristic: CBUUID, + service: BTUUID, + characteristic: BTUUID, _ onChange: @escaping (GATTCharacteristic?) -> Void ) -> OnChangeRegistration { registerCharacteristicOnChange(service: service, characteristic: characteristic, .instance(onChange)) } private func registerCharacteristicOnChange( - service: CBUUID, - characteristic: CBUUID, + service: BTUUID, + characteristic: BTUUID, _ onChange: CharacteristicOnChangeHandler ) -> OnChangeRegistration { let locator = CharacteristicLocator(serviceId: service, characteristicId: characteristic) @@ -440,7 +372,7 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ /// - enabled: Enable or disable notifications. /// - serviceId: The service the characteristic lives on. /// - characteristicId: The characteristic to notify about. - public func enableNotifications(_ enabled: Bool = true, serviceId: CBUUID, characteristicId: CBUUID) { + public func enableNotifications(_ enabled: Bool = true, serviceId: BTUUID, characteristicId: BTUUID) { // swiftlint:disable:previous function_default_parameter_at_end let id = CharacteristicLocator(serviceId: serviceId, characteristicId: characteristicId) @@ -454,7 +386,7 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ trySettingNotifyValue(enabled, serviceId: serviceId, characteristicId: characteristicId) } - func didRequestNotifications(serviceId: CBUUID, characteristicId: CBUUID) -> Bool { + func didRequestNotifications(serviceId: BTUUID, characteristicId: BTUUID) -> Bool { let id = CharacteristicLocator(serviceId: serviceId, characteristicId: characteristicId) return notifyRequested.contains(id) } @@ -467,13 +399,13 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ onChangeHandlers[locator]?.removeValue(forKey: handlerId) } - private func trySettingNotifyValue(_ notify: Bool, serviceId: CBUUID, characteristicId: CBUUID) { + private func trySettingNotifyValue(_ notify: Bool, serviceId: BTUUID, characteristicId: BTUUID) { guard let characteristic = getCharacteristic(id: characteristicId, on: serviceId) else { return } if characteristic.properties.supportsNotifications { - peripheral.setNotifyValue(notify, for: characteristic.underlyingCharacteristic) + cbPeripheral.setNotifyValue(notify, for: characteristic.underlyingCharacteristic) } } @@ -481,14 +413,14 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ /// This cancels any subscriptions if there are any, or straight disconnects if not. /// (didUpdateNotificationStateForCharacteristic will cancel the connection if a subscription is involved) private func removeAllNotifications() { - guard case .connected = peripheral.state else { + guard case .connected = cbPeripheral.state else { return } // we need to unsubscribe before we cancel the connection - for service in peripheral.services ?? [] { + for service in cbPeripheral.services ?? [] { for characteristic in service.characteristics ?? [] where characteristic.isNotifying { - peripheral.setNotifyValue(false, for: characteristic) + cbPeripheral.setNotifyValue(false, for: characteristic) } } } @@ -512,7 +444,7 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ try await withCheckedThrowingContinuation { continuation in access.store(.write(continuation)) - peripheral.writeValue(data, for: characteristic, type: .withResponse) + cbPeripheral.writeValue(data, for: characteristic, type: .withResponse) } } @@ -537,7 +469,7 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ await withCheckedContinuation { continuation in assert(writeWithoutResponseContinuation == nil, "writeWithoutResponseAccess was unexpectedly not nil") writeWithoutResponseContinuation = continuation - peripheral.writeValue(data, for: characteristic.underlyingCharacteristic, type: .withoutResponse) + cbPeripheral.writeValue(data, for: characteristic.underlyingCharacteristic, type: .withoutResponse) } } @@ -556,7 +488,7 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ return try await withCheckedThrowingContinuation { continuation in access.store(.read(continuation)) - peripheral.readValue(for: characteristic) + cbPeripheral.readValue(for: characteristic) } } @@ -571,12 +503,13 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ return try await withCheckedThrowingContinuation { continuation in assert(rssiContinuation == nil, "rssiAccess was unexpectedly not nil") rssiContinuation = continuation - peripheral.readRSSI() + cbPeripheral.readRSSI() } } private func synchronizeModel(for service: CBService) { - guard let gattService = getService(id: service.uuid) else { + let uuid = BTUUID(from: service.uuid) + guard let gattService = getService(id: uuid) else { logger.error("Failed to retrieve service \(service.uuid) of discovered characteristics!") return } @@ -585,7 +518,7 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ let changeProtocol = gattService.synchronizeModel() for uuid in changeProtocol.removedCharacteristics { - let locator = CharacteristicLocator(serviceId: service.uuid, characteristicId: uuid) + let locator = CharacteristicLocator(serviceId: uuid, characteristicId: uuid) for handler in onChangeHandlers[locator, default: [:]].values { if case let .instance(onChange) = handler { onChange(nil) // signal removed characteristic! @@ -594,7 +527,7 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ } for characteristic in changeProtocol.updatedCharacteristics { - let locator = CharacteristicLocator(serviceId: service.uuid, characteristicId: characteristic.uuid) + let locator = CharacteristicLocator(serviceId: uuid, characteristicId: characteristic.uuid) for handler in onChangeHandlers[locator, default: [:]].values { if case let .instance(onChange) = handler { onChange(characteristic) @@ -603,9 +536,9 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ } } - private func synchronizeModel(for characteristic: CBCharacteristic, capture: CBCharacteristicCapture) { + private func synchronizeModel(for characteristic: CBCharacteristic, capture: GATTCharacteristicCapture) { guard let service = characteristic.service, - let gattCharacteristic = getCharacteristic(id: characteristic.uuid, on: service.uuid) else { + let gattCharacteristic = getCharacteristic(id: BTUUID(from: characteristic.uuid), on: BTUUID(from: service.uuid)) else { logger.error("Failed to locate GATTCharacteristic for provided one \(characteristic.uuid)") return } @@ -613,7 +546,7 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ gattCharacteristic.synchronizeModel(capture: capture) } - private func invalidateServices(_ ids: Set) { + private func invalidateServices(_ ids: Set) { guard let services else { return } @@ -626,7 +559,9 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ // Note: we iterate over the zipped array in reverse such that the indices stay valid if remove elements // the service was invalidated! - self.services?.remove(at: index) + 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 { @@ -646,29 +581,20 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ // 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($0.uuid) } + .filter { !existingServices.contains(BTUUID(from: $0.uuid)) } .map { // we will discover characteristics for all services after that. GATTService(service: $0) } if let services = self.services { - isolatedUpdate(of: \.services, services + addedServices) + storage.services = services + addedServices } else { - isolatedUpdate(of: \.services, addedServices) + storage.services = addedServices } } - private func isolatedUpdate(of keyPath: WritableKeyPath, _ value: Value) { - var peripheral = self - peripheral[keyPath: keyPath] = value - } - deinit { - if !_storage.nearby { // make sure signal is sent - _storage.update(nearby: false) - } - guard let manager else { self.logger.warning("Orphaned device \(self.id), \(self.name ?? "unnamed") was de-initialized") return @@ -678,28 +604,13 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ let name = name self.logger.debug("Device \(id), \(name ?? "unnamed") was de-initialized...") - Task.detached { @SpeziBluetooth in - await manager.handlePeripheralDeinit(id: id) - } - } -} - -extension BluetoothPeripheral: Identifiable { - /// The internally managed identifier for the peripheral. - public nonisolated var id: UUID { - peripheral.identifier - } -} + Task.detached { @SpeziBluetooth [storage, nearby] in + if nearby { // make sure signal is sent + storage.nearby = false + } -extension BluetoothPeripheral: KVOReceiver { - func observeChange(of keyPath: KeyPath, value: V) { - switch keyPath { - case \CBPeripheral.state: - // force cast is okay as we implicitly verify the type using the KeyPath in the case statement. - self.isolatedUpdate(of: \.state, PeripheralState(from: value as! CBPeripheralState)) // swiftlint:disable:this force_cast - default: - break + manager.handlePeripheralDeinit(id: id) } } } @@ -717,26 +628,29 @@ extension BluetoothPeripheral { // automatically subscribe to discovered characteristics for which we have a handler subscribed! for characteristic in characteristics { - let description = configuration.description(for: service.uuid)?.description(for: characteristic.uuid) + 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) { - peripheral.readValue(for: characteristic) + cbPeripheral.readValue(for: characteristic) } // enable notifications if registered if characteristic.properties.supportsNotifications { - let locator = CharacteristicLocator(serviceId: service.uuid, characteristicId: characteristic.uuid) + let locator = CharacteristicLocator(serviceId: serviceId, characteristicId: characteristicId) if notifyRequested.contains(locator) { logger.debug("Automatically subscribing to discovered characteristic \(locator)...") - peripheral.setNotifyValue(true, for: characteristic) + cbPeripheral.setNotifyValue(true, for: characteristic) } } if description?.discoverDescriptors == true { logger.debug("Discovering descriptors for \(characteristic.debugIdentifier)...") - peripheral.discoverDescriptors(for: characteristic) + cbPeripheral.discoverDescriptors(for: characteristic) } } } @@ -764,7 +678,7 @@ extension BluetoothPeripheral { return } - let locator = CharacteristicLocator(serviceId: service.uuid, characteristicId: characteristic.uuid) + 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 @@ -795,22 +709,29 @@ extension BluetoothPeripheral { } +extension BluetoothPeripheral: Identifiable, Sendable {} + + extension BluetoothPeripheral: CustomDebugStringConvertible { public nonisolated var debugDescription: String { - cbPeripheral.debugIdentifier + if let name { + "'\(name)' @ \(id)" + } else { + "\(id)" + } } } // MARK: Hashable extension BluetoothPeripheral: Hashable { - public static func == (lhs: BluetoothPeripheral, rhs: BluetoothPeripheral) -> Bool { - lhs.peripheral == rhs.peripheral + public nonisolated static func == (lhs: BluetoothPeripheral, rhs: BluetoothPeripheral) -> Bool { + lhs.id == rhs.id } public nonisolated func hash(into hasher: inout Hasher) { - hasher.combine(peripheral) + hasher.combine(id) } } @@ -839,9 +760,7 @@ extension BluetoothPeripheral { let name = peripheral.name Task { @SpeziBluetooth in - await device.isolated { device in - device.isolatedUpdate(of: \.peripheralName, name) - } + device.storage.peripheralName = name } } @@ -851,20 +770,18 @@ extension BluetoothPeripheral { } Task { @SpeziBluetooth in - await device.isolated { device in - let rssi = RSSI.intValue - device.isolatedUpdate(of: \.rssi, rssi) - - let result: Result = error.map { .failure($0) } ?? .success(rssi) + let rssi = RSSI.intValue + device.storage.rssi = rssi - guard let rssiContinuation = device.rssiContinuation else { - return - } + let result: Result = error.map { .failure($0) } ?? .success(rssi) - device.rssiContinuation = nil - rssiContinuation.resume(with: result) - assert(device.rssiAccess.signal(), "Signaled rssiAccess though no one was waiting") + 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") } } @@ -881,17 +798,16 @@ extension BluetoothPeripheral { // so a service we requested might be gone now. Or might just have changed location. // So, discover them to check if they moved location? - let serviceIds = invalidatedServices.map { $0.uuid } + let serviceIds = invalidatedServices.map { BTUUID(from: $0.uuid) } logger.debug("Services modified, invalidating \(serviceIds)") + let peripheral = CBInstance(instantiatedOnDispatchQueue: peripheral) Task { @SpeziBluetooth in - await device.isolated { device in - // update our local model! - device.invalidateServices(Set(serviceIds)) + // update our local model! + device.invalidateServices(Set(serviceIds)) - // make sure we try to rediscover those services though - peripheral.discoverServices(serviceIds) - } + // make sure we try to rediscover those services though + peripheral.cbObject.discoverServices(serviceIds.map { $0.cbuuid }) } } @@ -910,32 +826,35 @@ extension BluetoothPeripheral { return } - Task { @SpeziBluetooth in - await device.isolated { device in - device.discovered(services: services) + let peripheral = CBInstance(instantiatedOnDispatchQueue: peripheral) + let cbServices = CBInstance(instantiatedOnDispatchQueue: services) - logger.debug("Discovered \(services) services for peripheral \(device.peripheral.debugIdentifier)") + Task { @SpeziBluetooth [logger] in + device.discovered(services: cbServices.cbObject) - for service in services { - guard let serviceDescription = device.configuration.description(for: service.uuid) else { - continue - } + logger.debug("Discovered \(cbServices.cbObject) services for peripheral \(device.debugDescription)") - let characteristicIds = serviceDescription.characteristics?.reduce(into: Set()) { partialResult, description in - partialResult.insert(description.characteristicId) - } + for service in cbServices.cbObject { + let serviceId = BTUUID(from: service.uuid) - if let characteristicIds, characteristicIds.isEmpty { - continue - } + guard let serviceDescription = device.configuration.description(for: serviceId) else { + continue + } - device.servicesAwaitingCharacteristicsDiscovery.insert(service.uuid) - peripheral.discoverCharacteristics(characteristicIds.map { Array($0) }, for: service) + let characteristicIds = serviceDescription.characteristics?.reduce(into: Set()) { partialResult, description in + partialResult.insert(description.characteristicId) } - if device.servicesAwaitingCharacteristicsDiscovery.isEmpty { - device._storage.signalFullyDiscovered() + if let characteristicIds, characteristicIds.isEmpty { + continue } + + device.servicesAwaitingCharacteristicsDiscovery.insert(serviceId) + peripheral.cbObject.discoverCharacteristics(characteristicIds.map { Array($0.map { $0.cbuuid }) }, for: service) + } + + if device.servicesAwaitingCharacteristicsDiscovery.isEmpty { + device.storage.signalFullyDiscovered() } } } @@ -945,25 +864,24 @@ extension BluetoothPeripheral { return } - Task { @SpeziBluetooth in - await device.isolated { device in - // update our model with latest characteristics! - device.synchronizeModel(for: service) - - // ensure we keep track of all discoveries, set .connected state - device.servicesAwaitingCharacteristicsDiscovery.remove(service.uuid) - if device.servicesAwaitingCharacteristicsDiscovery.isEmpty { - device._storage.signalFullyDiscovered() - } + let service = CBInstance(instantiatedOnDispatchQueue: service) + Task { @SpeziBluetooth [logger] in + // update our model with latest characteristics! + device.synchronizeModel(for: service.cbObject) - if let error { - logger.error("Error discovering characteristics: \(error.localizedDescription)") - return - } + // ensure we keep track of all discoveries, set .connected state + device.servicesAwaitingCharacteristicsDiscovery.remove(BTUUID(from: service.uuid)) + if device.servicesAwaitingCharacteristicsDiscovery.isEmpty { + device.storage.signalFullyDiscovered() + } - // handle auto-subscribe and discover descriptors - device.discovered(service: service) + if let error { + logger.error("Error discovering characteristics: \(error.localizedDescription)") + return } + + // handle auto-subscribe and discover descriptors + device.discovered(service: service.cbObject) } } @@ -978,12 +896,11 @@ extension BluetoothPeripheral { logger.debug("Discovered descriptors for characteristic \(characteristic.debugIdentifier): \(descriptors)") - let capture = CBCharacteristicCapture(from: characteristic) + let capture = GATTCharacteristicCapture(from: characteristic) + let characteristic = CBInstance(instantiatedOnDispatchQueue: characteristic) Task { @SpeziBluetooth in - await device.isolated { device in - device.synchronizeModel(for: characteristic, capture: capture) - } + device.synchronizeModel(for: characteristic.cbObject, capture: capture) } } @@ -992,18 +909,17 @@ extension BluetoothPeripheral { return } - let capture = CBCharacteristicCapture(from: characteristic) + let capture = GATTCharacteristicCapture(from: characteristic) + let characteristic = CBInstance(instantiatedOnDispatchQueue: characteristic) Task { @SpeziBluetooth in - await device.isolated { device in - // make sure value is propagated beforehand - device.synchronizeModel(for: characteristic, capture: capture) - - if let error { - device.receivedUpdatedValue(for: characteristic, result: .failure(error)) - } else if let value = capture.value { - device.receivedUpdatedValue(for: characteristic, result: .success(value)) - } + // make sure value is propagated beforehand + device.synchronizeModel(for: characteristic.cbObject, capture: capture) + + if let error { + device.receivedUpdatedValue(for: characteristic.cbObject, result: .failure(error)) + } else if let value = capture.value { + device.receivedUpdatedValue(for: characteristic.cbObject, result: .success(value)) } } } @@ -1013,15 +929,14 @@ extension BluetoothPeripheral { return } - let capture = CBCharacteristicCapture(from: characteristic) + let capture = GATTCharacteristicCapture(from: characteristic) + let characteristic = CBInstance(instantiatedOnDispatchQueue: characteristic) Task { @SpeziBluetooth in - await device.isolated { device in - device.synchronizeModel(for: characteristic, capture: capture) + device.synchronizeModel(for: characteristic.cbObject, capture: capture) - let result: Result = error.map { .failure($0) } ?? .success(()) - device.receivedWriteResponse(for: characteristic, result: result) - } + let result: Result = error.map { .failure($0) } ?? .success(()) + device.receivedWriteResponse(for: characteristic.cbObject, result: result) } } @@ -1031,15 +946,13 @@ extension BluetoothPeripheral { } Task { @SpeziBluetooth in - await device.isolated { device in - guard let writeWithoutResponseContinuation = device.writeWithoutResponseContinuation else { - return - } - - device.writeWithoutResponseContinuation = nil - writeWithoutResponseContinuation.resume() - assert(device.writeWithoutResponseAccess.signal(), "Signaled writeWithoutResponseAccess though no one was waiting") + guard let writeWithoutResponseContinuation = device.writeWithoutResponseContinuation else { + return } + + device.writeWithoutResponseContinuation = nil + writeWithoutResponseContinuation.resume() + assert(device.writeWithoutResponseAccess.signal(), "Signaled writeWithoutResponseAccess though no one was waiting") } } @@ -1053,17 +966,16 @@ extension BluetoothPeripheral { return } - let capture = CBCharacteristicCapture(from: characteristic) + let capture = GATTCharacteristicCapture(from: characteristic) + let characteristic = CBInstance(instantiatedOnDispatchQueue: characteristic) - Task { @SpeziBluetooth in - await device.isolated { device in - device.synchronizeModel(for: characteristic, capture: capture) + 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 capture.isNotifying { + logger.log("Notification began on \(characteristic.debugIdentifier)") + } else { + logger.log("Notification stopped on \(characteristic.debugIdentifier).") } } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift index 0d566e07..a891f431 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift @@ -6,13 +6,11 @@ // SPDX-License-Identifier: MIT // -@preconcurrency import class CoreBluetooth.CBUUID - /// A characteristic description. public struct CharacteristicDescription: Sendable { /// The characteristic id. - public let characteristicId: CBUUID + public let characteristicId: BTUUID /// Flag indicating if descriptors should be discovered for this characteristic. public let discoverDescriptors: Bool /// Flag indicating if SpeziBluetooth should automatically read the initial value from the peripheral. @@ -24,7 +22,7 @@ public struct CharacteristicDescription: Sendable { /// - id: The bluetooth characteristic id. /// - discoverDescriptors: Optional flag to specify that descriptors of this characteristic should be discovered. /// - autoRead: Flag indicating if SpeziBluetooth should automatically read the initial value from the peripheral. - public init(id: CBUUID, discoverDescriptors: Bool = false, autoRead: Bool = true) { + public init(id: BTUUID, discoverDescriptors: Bool = false, autoRead: Bool = true) { self.characteristicId = id self.discoverDescriptors = discoverDescriptors self.autoRead = autoRead @@ -34,7 +32,7 @@ public struct CharacteristicDescription: Sendable { extension CharacteristicDescription: ExpressibleByStringLiteral { public init(stringLiteral value: StringLiteralType) { - self.init(id: CBUUID(string: value)) + self.init(id: BTUUID(stringLiteral: value)) } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift index 1edfe627..79e86568 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift @@ -6,7 +6,6 @@ // SPDX-License-Identifier: MIT // -@preconcurrency import class CoreBluetooth.CBUUID import OSLog @@ -19,11 +18,11 @@ public struct DeviceDescription { /// /// This will be the list of services we are interested in and we try to discover. public var services: Set? { // swiftlint:disable:this discouraged_optional_collection - let values: Dictionary.Values? = _services?.values + let values: Dictionary.Values? = _services?.values return values.map { Set($0) } } - private let _services: [CBUUID: ServiceDescription]? // swiftlint:disable:this discouraged_optional_collection + private let _services: [BTUUID: ServiceDescription]? // swiftlint:disable:this discouraged_optional_collection /// Create a new device description. /// - Parameter services: The set of service descriptions specifying the expected services. @@ -38,7 +37,7 @@ public struct DeviceDescription { /// Retrieve the service description for a given service id. /// - Parameter serviceId: The Bluetooth service id. /// - Returns: Returns the service description if present. - public func description(for serviceId: CBUUID) -> ServiceDescription? { + public func description(for serviceId: BTUUID) -> ServiceDescription? { _services?[serviceId] } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift index 81d7512d..f760a4e8 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift @@ -6,25 +6,24 @@ // SPDX-License-Identifier: MIT // -@preconcurrency import CoreBluetooth - /// The criteria by which we identify a discovered device. /// /// ## Topics /// /// ### Criteria -/// - ``advertisedService(_:)-5o92s`` -/// - ``advertisedService(_:)-3pnr6`` +/// - ``advertisedService(_:)-swift.type.method`` /// - ``advertisedService(_:)-swift.enum.case`` +/// - ``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: CBUUID) + case advertisedService(_ uuid: BTUUID) /// Identify a device by its manufacturer and advertised service. - case accessory(manufacturer: ManufacturerIdentifier, advertising: CBUUID) + case accessory(manufacturer: ManufacturerIdentifier, advertising: BTUUID) - var discoveryId: CBUUID { + var discoveryId: BTUUID { switch self { case let .advertisedService(uuid): uuid @@ -37,7 +36,7 @@ public enum DiscoveryCriteria: Sendable { func matches(_ advertisementData: AdvertisementData) -> Bool { switch self { case let .advertisedService(uuid): - return advertisementData.serviceUUIDs?.contains(uuid) ?? false + return advertisementData.serviceUUIDs?.contains(uuid) ?? advertisementData.overflowServiceUUIDs?.contains(uuid) ?? false case let .accessory(manufacturer, service): guard let manufacturerData = advertisementData.manufacturerData, let identifier = ManufacturerIdentifier(data: manufacturerData) else { @@ -56,13 +55,6 @@ public enum DiscoveryCriteria: Sendable { extension DiscoveryCriteria { - /// Identify a device by their advertised service. - /// - Parameter uuid: The Bluetooth service id in string format. - /// - Returns: A ``DiscoveryCriteria/advertisedService(_:)-swift.enum.case`` criteria. - public static func advertisedService(_ uuid: String) -> DiscoveryCriteria { - .advertisedService(CBUUID(string: uuid)) - } - /// Identify a device by their advertised service. /// - Parameter service: The service type. /// - Returns: A ``DiscoveryCriteria/advertisedService(_:)-swift.enum.case`` criteria. @@ -73,15 +65,6 @@ extension DiscoveryCriteria { extension DiscoveryCriteria { - /// Identify a device by its manufacturer and advertised service. - /// - Parameters: - /// - manufacturer: The Bluetooth SIG-assigned manufacturer identifier. - /// - service: The Bluetooth service id in string format. - /// - Returns: A ``DiscoveryCriteria/accessory(manufacturer:advertising:)-swift.enum.case`` criteria. - public static func accessory(manufacturer: ManufacturerIdentifier, advertising service: String) -> DiscoveryCriteria { - .accessory(manufacturer: manufacturer, advertising: CBUUID(string: service)) - } - /// Identify a device by its manufacturer and advertised service. /// - Parameters: /// - manufacturer: The Bluetooth SIG-assigned manufacturer identifier. diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryDescription.swift index 5db3be03..536255c6 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryDescription.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryDescription.swift @@ -23,8 +23,7 @@ public struct DiscoveryDescription { /// Create a new discovery configuration for a given type of device. /// - Parameters: /// - discoveryCriteria: The criteria by which we identify a discovered device. - /// - services: The set of service configurations we expect from the device. - /// Use `nil` to discover all services. + /// - device: The description of the device. public init(discoverBy discoveryCriteria: DiscoveryCriteria, device: DeviceDescription) { self.discoveryCriteria = discoveryCriteria self.device = device diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift index d0a97c32..14bb54ae 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift @@ -6,52 +6,41 @@ // SPDX-License-Identifier: MIT // -@preconcurrency import class CoreBluetooth.CBUUID - /// A service description for a certain device. /// /// Describes what characteristics we expect to be present for a certain service. public struct ServiceDescription: Sendable { /// The service id. - public let serviceId: CBUUID + 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. public var characteristics: Set? { // swiftlint:disable:this discouraged_optional_collection - let values: Dictionary.Values? = _characteristics?.values + let values: Dictionary.Values? = _characteristics?.values return values.map { Set($0) } } - private let _characteristics: [CBUUID: CharacteristicDescription]? // swiftlint:disable:this discouraged_optional_collection + private let _characteristics: [BTUUID: CharacteristicDescription]? // swiftlint:disable:this discouraged_optional_collection /// Create a new service description. /// - Parameters: /// - serviceId: The bluetooth service id. /// - characteristics: The description of characteristics we expect to be present on the service. /// Use `nil` to discover all characteristics. - public init(serviceId: CBUUID, characteristics: Set?) { // swiftlint:disable:this discouraged_optional_collection + public init(serviceId: BTUUID, characteristics: Set?) { // swiftlint:disable:this discouraged_optional_collection self.serviceId = serviceId self._characteristics = characteristics?.reduce(into: [:]) { partialResult, description in partialResult[description.characteristicId] = description } } - /// Create a new service description. - /// - Parameters: - /// - serviceId: The bluetooth service id. - /// - characteristics: The description of characteristics we expect to be present on the service. - /// Use `nil` to discover all characteristics. - public init(serviceId: String, characteristics: Set?) { // swiftlint:disable:this discouraged_optional_collection - self.init(serviceId: CBUUID(string: serviceId), characteristics: characteristics) - } - /// Retrieve the characteristic description for a given service id. - /// - Parameter serviceId: The Bluetooth characteristic id. + /// - Parameter characteristicsId: The Bluetooth characteristic id. /// - Returns: Returns the characteristic description if present. - public func description(for characteristicsId: CBUUID) -> CharacteristicDescription? { + public func description(for characteristicsId: BTUUID) -> CharacteristicDescription? { _characteristics?[characteristicsId] } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBError+LocalizedError.swift b/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBError+LocalizedError.swift index 364877f8..6d6e7407 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBError+LocalizedError.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBError+LocalizedError.swift @@ -7,24 +7,37 @@ // import CoreBluetooth +import Foundation -extension CBError: LocalizedError { +#if compiler(>=6) +extension CBError: @retroactive LocalizedError {} +extension CBATTError: @retroactive LocalizedError {} +#else +extension CBError: LocalizedError {} +extension CBATTError: LocalizedError {} +#endif + +extension CBError { + /// The error description. public var errorDescription: String? { "CoreBluetooth Error" } + /// The localized failure reason. public var failureReason: String? { localizedDescription } } -extension CBATTError: LocalizedError { +extension CBATTError { + /// The error description. public var errorDescription: String? { "CoreBluetooth ATT Error" } + /// The localized failure reason. public var failureReason: String? { localizedDescription } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/AdvertisementData.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/AdvertisementData.swift index 4cd499e3..a5d89b85 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/AdvertisementData.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/AdvertisementData.swift @@ -9,61 +9,90 @@ import CoreBluetooth -/// All advertised information of a peripheral. +/// Advertisement information of a peripheral. public struct AdvertisementData { - /// The raw advertisement data dictionary provided by CoreBluetooth. - public let rawAdvertisementData: [String: Any] - /// The local name of a peripheral. - public var localName: String? { - rawAdvertisementData[CBAdvertisementDataLocalNameKey] as? String - } - + public let localName: String? /// The manufacturer data of a peripheral. - public var manufacturerData: Data? { - rawAdvertisementData[CBAdvertisementDataManufacturerDataKey] as? Data - } - + public let manufacturerData: Data? /// Service-specific advertisement data. /// /// The keys are CBService UUIDs. The values are Data objects, representing service-specific data. - public var serviceData: [CBUUID: Data]? { // swiftlint:disable:this discouraged_optional_collection - rawAdvertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data] - } - + public let serviceData: [BTUUID: Data]? // swiftlint:disable:this discouraged_optional_collection /// The advertised service UUIDs. - public var serviceUUIDs: [CBUUID]? { // swiftlint:disable:this discouraged_optional_collection - rawAdvertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] - } - - /// An array of one or more CBUUID objects, representing CBService UUIDs that were found in the “overflow” + public let serviceUUIDs: [BTUUID]? // swiftlint:disable:this discouraged_optional_collection + /// An array of one or additional service UUIDs, representing CBService UUIDs that were found in the “overflow” /// area of the advertisement data. - public var overflowServiceUUIDs: [CBUUID]? { // swiftlint:disable:this discouraged_optional_collection - rawAdvertisementData[CBAdvertisementDataOverflowServiceUUIDsKey] as? [CBUUID] - } + public let overflowServiceUUIDs: [BTUUID]? // swiftlint:disable:this discouraged_optional_collection /// The transmit power of a peripheral. /// /// This key and value are available if the broadcaster (peripheral) provides its Tx power level in its advertising packet. /// Using the RSSI value and the Tx power level, it is possible to calculate path loss. - public var txPowerLevel: NSNumber? { - rawAdvertisementData[CBAdvertisementDataTxPowerLevelKey] as? NSNumber - } - + public let txPowerLevel: NSNumber? /// Determine if the advertising event type is connectable. - public var isConnectable: Bool? { // swiftlint:disable:this discouraged_optional_boolean - rawAdvertisementData[CBAdvertisementDataIsConnectable] as? Bool // bridge cast - } - + public let isConnectable: Bool? // swiftlint:disable:this discouraged_optional_boolean /// An array solicited CBService UUIDs. - public var solicitedServiceUUIDs: [CBUUID]? { // swiftlint:disable:this discouraged_optional_collection - rawAdvertisementData[CBAdvertisementDataSolicitedServiceUUIDsKey] as? [CBUUID] + public let solicitedServiceUUIDs: [BTUUID]? // swiftlint:disable:this discouraged_optional_collection + + + /// Create new advertisement data. + /// + /// This might be helpful to inject advertisement data into a peripheral for testing purposes. + /// + /// - Parameters: + /// - localName: The local name of a peripheral. + /// - manufacturerData: The manufacturer data of a peripheral. + /// - serviceData: Service-specific advertisement data. + /// - serviceUUIDs: The advertised service UUIDs. + /// - overflowServiceUUIDs: Advertised service UUIDs found in the "overflow" area of the advertisement data. + /// - txPowerLevel: + /// - isConnectable: + /// - solicitedServiceUUIDs: + public init( + localName: String? = nil, + manufacturerData: Data? = nil, + serviceData: [BTUUID: Data]? = nil, // swiftlint:disable:this discouraged_optional_collection + serviceUUIDs: [BTUUID]? = nil, // swiftlint:disable:this discouraged_optional_collection + overflowServiceUUIDs: [BTUUID]? = nil, // swiftlint:disable:this discouraged_optional_collection + txPowerLevel: NSNumber? = nil, + isConnectable: Bool? = nil, // swiftlint:disable:this discouraged_optional_boolean + solicitedServiceUUIDs: [BTUUID]? = nil // swiftlint:disable:this discouraged_optional_collection + ) { + self.localName = localName + self.manufacturerData = manufacturerData + self.serviceData = serviceData + self.serviceUUIDs = serviceUUIDs + self.overflowServiceUUIDs = overflowServiceUUIDs + self.txPowerLevel = txPowerLevel + self.isConnectable = isConnectable + self.solicitedServiceUUIDs = solicitedServiceUUIDs } +} +extension AdvertisementData { /// Creates advertisement data based on CoreBluetooth's dictionary. /// - Parameter advertisementData: Core Bluetooth's advertisement data - public init(_ advertisementData: [String: Any]) { - self.rawAdvertisementData = advertisementData + init(_ advertisementData: [String: Any]) { + self.init( + localName: advertisementData[CBAdvertisementDataLocalNameKey] as? String, + manufacturerData: advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data, + serviceData: (advertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data])? + .reduce(into: [:]) { partialResult, entry in + partialResult[BTUUID(from: entry.key)] = entry.value + }, + serviceUUIDs: (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID])? + .map { BTUUID(from: $0) }, + overflowServiceUUIDs: (advertisementData[CBAdvertisementDataOverflowServiceUUIDsKey] as? [CBUUID])? + .map { BTUUID(from: $0) }, + txPowerLevel: advertisementData[CBAdvertisementDataTxPowerLevelKey] as? NSNumber, + isConnectable: advertisementData[CBAdvertisementDataIsConnectable] as? Bool, // bridge cast + solicitedServiceUUIDs: (advertisementData[CBAdvertisementDataSolicitedServiceUUIDsKey] as? [CBUUID])? + .map { BTUUID(data: $0.data) } + ) } } + + +extension AdvertisementData: Sendable, Hashable {} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothError.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothError.swift index 32e153c6..bd92d964 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothError.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothError.swift @@ -6,7 +6,6 @@ // SPDX-License-Identifier: MIT // -import CoreBluetooth import Foundation @@ -16,13 +15,13 @@ public enum BluetoothError: Error, CustomStringConvertible, LocalizedError { case incompatibleDataFormat /// Thrown when accessing a ``Characteristic`` that was not present. /// Either because the device wasn't connected or the characteristic is not present on the connected device. - case notPresent(service: CBUUID? = nil, characteristic: CBUUID) + case notPresent(service: BTUUID? = nil, characteristic: BTUUID) /// Control Point command requires notifications to be enabled. /// This error is thrown if one tries to send a request to a ``ControlPointCharacteristic`` but notifications haven't been enabled for that characteristic. - case controlPointRequiresNotifying(service: CBUUID, characteristic: CBUUID) + case controlPointRequiresNotifying(service: BTUUID, characteristic: BTUUID) /// Request is in progress. /// Request was sent to a control point characteristic while a different request is waiting for a response. - case controlPointInProgress(service: CBUUID, characteristic: CBUUID) + case controlPointInProgress(service: BTUUID, characteristic: BTUUID) /// Provides a human-readable description of the error. @@ -49,11 +48,11 @@ public enum BluetoothError: Error, CustomStringConvertible, LocalizedError { case .incompatibleDataFormat: String(localized: "Could not decode byte representation into provided format.", bundle: .module) case let .notPresent(service, characteristic): - String(localized: "The requested characteristic \(characteristic) on \(service?.uuidString ?? "?") was not present on the device.", bundle: .module) + String(localized: "The requested characteristic \(characteristic.description) on \(service?.description ?? "?") was not present on the device.", bundle: .module) case let .controlPointRequiresNotifying(service, characteristic): - String(localized: "Control point request was sent to \(characteristic) on \(service) but notifications weren't enabled for that characteristic.", bundle: .module) + String(localized: "Control point request was sent to \(characteristic.description) on \(service.description) but notifications weren't enabled for that characteristic.", bundle: .module) case let .controlPointInProgress(service, characteristic): - String(localized: "Control point request was sent to \(characteristic) on \(service) while waiting for a response to a previous request.", bundle: .module) + String(localized: "Control point request was sent to \(characteristic.description) on \(service.description) while waiting for a response to a previous request.", bundle: .module) } } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift new file mode 100644 index 00000000..1f207144 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift @@ -0,0 +1,161 @@ +// +// 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 Atomics +import Foundation +import OrderedCollections + + +@Observable +final class BluetoothManagerStorage: ValueObservable, Sendable { + private let _isScanning = ManagedAtomic(false) + private let _state = ManagedAtomic(.unknown) + + @ObservationIgnored private nonisolated(unsafe) var _discoveredPeripherals: OrderedDictionary = [:] + private let rwLock = RWLock() + + @SpeziBluetooth var retrievedPeripherals: OrderedDictionary> = [:] { + didSet { + _$simpleRegistrar.triggerDidChange(for: \.retrievedPeripherals, on: self) + } + } + @SpeziBluetooth @ObservationIgnored private var subscribedContinuations: [UUID: AsyncStream.Continuation] = [:] + + /// Note: we track, based on the CoreBluetooth reported connected state. + @SpeziBluetooth var connectedDevices: Set = [] + @MainActor private(set) var maHasConnectedDevices: Bool = false // we need a main actor isolated one for efficient SwiftUI support. + + @SpeziBluetooth var hasConnectedDevices: Bool { + !connectedDevices.isEmpty + } + + @inlinable var readOnlyState: BluetoothState { + access(keyPath: \._state) + return _state.load(ordering: .relaxed) + } + + @inlinable var readOnlyIsScanning: Bool { + access(keyPath: \._isScanning) + return _isScanning.load(ordering: .relaxed) + } + + @inlinable var readOnlyDiscoveredPeripherals: OrderedDictionary { + access(keyPath: \._discoveredPeripherals) + return rwLock.withReadLock { + _discoveredPeripherals + } + } + + @SpeziBluetooth var state: BluetoothState { + get { + readOnlyState + } + set { + withMutation(keyPath: \._state) { + _state.store(newValue, ordering: .relaxed) + } + _$simpleRegistrar.triggerDidChange(for: \.state, on: self) + + for continuation in subscribedContinuations.values { + continuation.yield(state) + } + } + } + + @SpeziBluetooth var isScanning: Bool { + get { + readOnlyIsScanning + } + set { + withMutation(keyPath: \._isScanning) { + _isScanning.store(newValue, ordering: .relaxed) + } + _$simpleRegistrar.triggerDidChange(for: \.isScanning, on: self) // didSet + } + } + + @SpeziBluetooth var discoveredPeripherals: OrderedDictionary { + get { + readOnlyDiscoveredPeripherals + } + set { + withMutation(keyPath: \._discoveredPeripherals) { + rwLock.withWriteLock { + _discoveredPeripherals = newValue + } + } + _$simpleRegistrar.triggerDidChange(for: \.discoveredPeripherals, on: self) // didSet + } + } + + // swiftlint:disable:next identifier_name + @ObservationIgnored let _$simpleRegistrar = ValueObservationRegistrar() + + init() {} + + + @SpeziBluetooth + func update(state: BluetoothState) { + self.state = state + } + + @SpeziBluetooth + func subscribe(_ continuation: AsyncStream.Continuation) -> UUID { + let id = UUID() + subscribedContinuations[id] = continuation + return id + } + + @SpeziBluetooth + func unsubscribe(for id: UUID) { + subscribedContinuations[id] = nil + } + + @SpeziBluetooth + func cbDelegateSignal(connected: Bool, for id: UUID) async { + if connected { + connectedDevices.insert(id) + } else { + connectedDevices.remove(id) + } + await updateMainActorConnectedDevices(hasConnectedDevices: !connectedDevices.isEmpty) + } + + @MainActor + private func updateMainActorConnectedDevices(hasConnectedDevices: Bool) { + maHasConnectedDevices = hasConnectedDevices + } + + + deinit { + Task { @SpeziBluetooth [subscribedContinuations] in + for continuation in subscribedContinuations.values { + continuation.finish() + } + } + } +} + + +extension BluetoothManagerStorage { + var stateSubscription: AsyncStream { + AsyncStream(BluetoothState.self) { continuation in + Task { @SpeziBluetooth in + let id = subscribe(continuation) + continuation.onTermination = { @Sendable [weak self] _ in + guard let self = self else { + return + } + Task.detached { @SpeziBluetooth in + self.unsubscribe(for: id) + } + } + } + } + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothState.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothState.swift index 8661b68f..0ce3fb97 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothState.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothState.swift @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // +import Atomics import CoreBluetooth @@ -26,7 +27,13 @@ public enum BluetoothState: UInt8 { } -extension BluetoothState: CustomStringConvertible, Sendable { +extension BluetoothState: RawRepresentable, AtomicValue {} + + +extension BluetoothState: Hashable, Sendable {} + + +extension BluetoothState: CustomStringConvertible { public var description: String { switch self { case .unknown: diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift index 100a23af..d34bd052 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift @@ -11,19 +11,20 @@ import Foundation import SpeziFoundation -class CharacteristicAccess { +@SpeziBluetooth +class CharacteristicAccess: Sendable { enum Access { case read(CheckedContinuation) case write(CheckedContinuation) } - private let id: CBUUID + private let id: BTUUID private let semaphore = AsyncSemaphore() private(set) var value: Access? - fileprivate init(id: CBUUID) { + fileprivate init(id: BTUUID) { self.id = id } @@ -59,7 +60,8 @@ class CharacteristicAccess { } -struct CharacteristicAccesses { +@SpeziBluetooth +struct CharacteristicAccesses: Sendable { private var ongoingAccesses: [CBCharacteristic: CharacteristicAccess] = [:] mutating func makeAccess(for characteristic: CBCharacteristic) -> CharacteristicAccess { @@ -67,7 +69,7 @@ struct CharacteristicAccesses { if let existing = ongoingAccesses[characteristic] { access = existing } else { - access = CharacteristicAccess(id: characteristic.uuid) + access = CharacteristicAccess(id: BTUUID(from: characteristic.uuid)) self.ongoingAccesses[characteristic] = access } return access diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicLocator.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicLocator.swift index aef0c92c..234b16ee 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicLocator.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicLocator.swift @@ -6,16 +6,14 @@ // SPDX-License-Identifier: MIT // -import CoreBluetooth - struct CharacteristicLocator { - let serviceId: CBUUID - let characteristicId: CBUUID + let serviceId: BTUUID + let characteristicId: BTUUID } -extension CharacteristicLocator: Hashable {} +extension CharacteristicLocator: Hashable, Sendable {} extension CharacteristicLocator: CustomStringConvertible, CustomDebugStringConvertible { public var description: String { diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift index 98e8f7c9..d0dacb14 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift @@ -6,7 +6,6 @@ // SPDX-License-Identifier: MIT // -import class CoreBluetooth.CBUUID import Foundation import OSLog @@ -90,9 +89,9 @@ struct BluetoothModuleDiscoveryState: BluetoothScanningState { } -actor DiscoverySession: BluetoothActor { +@SpeziBluetooth +class DiscoverySession: Sendable { private let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "DiscoverySession") - let bluetoothQueue: DispatchSerialQueue fileprivate weak var manager: BluetoothManager? @@ -120,7 +119,7 @@ actor DiscoverySession: BluetoothActor { /// The set of serviceIds we request to discover upon scanning. /// Returning nil means scanning for all peripherals. - var serviceDiscoveryIds: [CBUUID]? { // swiftlint:disable:this discouraged_optional_collection + var serviceDiscoveryIds: [BTUUID]? { // swiftlint:disable:this discouraged_optional_collection let discoveryIds = configuration.configuredDevices.compactMap { configuration in configuration.discoveryCriteria.discoveryId } @@ -133,7 +132,6 @@ actor DiscoverySession: BluetoothActor { boundTo manager: BluetoothManager, configuration: BluetoothManagerDiscoveryState ) { - self.bluetoothQueue = manager.bluetoothQueue self.manager = manager self.configuration = configuration } @@ -174,11 +172,6 @@ actor DiscoverySession: BluetoothActor { self.configuration = configuration return discoveryItemsChanged } - - deinit { - staleTimer?.cancel() - autoConnectItem?.cancel() - } } @@ -203,16 +196,15 @@ extension DiscoverySession { return nil // auto-connect is disabled } - guard lastManuallyDisconnectedDevice == nil && !manager.hasConnectedDevices else { + guard lastManuallyDisconnectedDevice == nil && !manager.sbHasConnectedDevices else { return nil } - manager.assertIsolated("\(#function) was not called from within isolation.") - let sortedCandidates = manager.assumeIsolated { $0.discoveredPeripherals } + let sortedCandidates = manager.discoveredPeripherals .values .filter { $0.cbPeripheral.state == .disconnected } .sorted { lhs, rhs in - lhs.assumeIsolated { $0.rssi } < rhs.assumeIsolated { $0.rssi } + lhs.rssi < rhs.rssi } return sortedCandidates.first @@ -224,20 +216,22 @@ extension DiscoverySession { return } - let item = BluetoothWorkItem(boundTo: self) { session in - session.autoConnectItem = nil - - guard let candidate = session.autoConnectDeviceCandidate else { + let item = BluetoothWorkItem { [weak self] in + guard let self else { return } - candidate.assumeIsolated { peripheral in - peripheral.connect() + self.autoConnectItem = nil + + guard let candidate = self.autoConnectDeviceCandidate else { + return } + + candidate.connect() } + item.schedule(for: .now() + .seconds(BluetoothManager.Defaults.defaultAutoConnectDebounce)) autoConnectItem = item - self.bluetoothQueue.schedule(for: .now() + .seconds(BluetoothManager.Defaults.defaultAutoConnectDebounce), execute: item) } } @@ -249,17 +243,17 @@ extension DiscoverySession { /// - device: The device for which the timer is scheduled for. /// - timeout: The timeout for which the timer is scheduled for. func scheduleStaleTask(for device: BluetoothPeripheral, withTimeout timeout: TimeInterval) { - let timer = DiscoveryStaleTimer(device: device.id, boundTo: self) { session in - session.handleStaleTask() + let timer = DiscoveryStaleTimer(device: device.id) { [weak self] in + self?.handleStaleTask() } self.staleTimer = timer - timer.schedule(for: timeout, in: self.bluetoothQueue) + timer.schedule(for: timeout, in: SpeziBluetooth.shared.dispatchQueue) } func scheduleStaleTaskForOldestActivityDevice(ignore device: BluetoothPeripheral? = nil) { if let oldestActivityDevice = oldestActivityDevice(ignore: device) { - let lastActivity = oldestActivityDevice.assumeIsolated { $0.lastActivity } + let lastActivity = oldestActivityDevice.lastActivity let intervalSinceLastActivity = Date.now.timeIntervalSince(lastActivity) let nextTimeout = max(0, advertisementStaleInterval - intervalSinceLastActivity) @@ -286,18 +280,14 @@ extension DiscoverySession { } // when we are just interested in the min device, this operation is a bit cheaper then sorting the whole list - return manager.assumeIsolated { $0.discoveredPeripherals } + return manager.discoveredPeripherals .values .filter { // it's important to access the underlying state here $0.cbPeripheral.state == .disconnected && $0.id != device?.id } .min { lhs, rhs in - lhs.assumeIsolated { - $0.lastActivity - } < rhs.assumeIsolated { - $0.lastActivity - } + lhs.lastActivity < rhs.lastActivity } } @@ -309,20 +299,16 @@ extension DiscoverySession { staleTimer = nil // reset the timer let staleInternal = advertisementStaleInterval - let staleDevices = manager.assumeIsolated { $0.discoveredPeripherals } + let staleDevices = manager.discoveredPeripherals .values .filter { device in - device.assumeIsolated { isolated in - isolated.isConsideredStale(interval: staleInternal) - } + device.isConsideredStale(interval: staleInternal) } for device in staleDevices { - logger.debug("Removing stale peripheral \(device.cbPeripheral.debugIdentifier)") + logger.debug("Removing stale peripheral \(device.debugDescription)") // we know it won't be connected, therefore we just need to remove it - manager.assumeIsolated { manager in - manager.clearDiscoveredPeripheral(forKey: device.id) - } + manager.clearDiscoveredPeripheral(forKey: device.id) } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTCharacteristic.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTCharacteristic.swift index 216c275e..9128cc59 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTCharacteristic.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTCharacteristic.swift @@ -6,19 +6,28 @@ // SPDX-License-Identifier: MIT // -@preconcurrency import CoreBluetooth +import CoreBluetooth import Foundation -struct CBCharacteristicCapture { +struct GATTCharacteristicCapture: Sendable { let isNotifying: Bool let value: Data? - let descriptors: [CBDescriptor]? // swiftlint:disable:this discouraged_optional_collection + let properties: CBCharacteristicProperties + let descriptors: CBInstance<[CBDescriptor]>? init(from characteristic: CBCharacteristic) { self.isNotifying = characteristic.isNotifying self.value = characteristic.value - self.descriptors = characteristic.descriptors + self.properties = characteristic.properties + self.descriptors = characteristic.descriptors.map { CBInstance(instantiatedOnDispatchQueue: $0) } + } + + fileprivate init(from characteristic: borrowing GATTCharacteristic) { + self.isNotifying = characteristic.isNotifying + self.value = characteristic.value + self.properties = characteristic.properties + self.descriptors = characteristic.descriptors.map { CBInstance(unsafe: $0) } } } @@ -49,8 +58,8 @@ 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: CBUUID { - underlyingCharacteristic.uuid + public var uuid: BTUUID { + BTUUID(data: underlyingCharacteristic.uuid.data) } /// The properties of the characteristic. @@ -58,6 +67,14 @@ public final class GATTCharacteristic { underlyingCharacteristic.properties } + private let captureLock = RWLock() + + var captured: GATTCharacteristicCapture { + captureLock.withReadLock { + GATTCharacteristicCapture(from: self) + } + } + init(characteristic: CBCharacteristic, service: GATTService) { self.underlyingCharacteristic = characteristic self.service = service @@ -67,21 +84,34 @@ public final class GATTCharacteristic { } - func synchronizeModel(capture: CBCharacteristicCapture) { // always called from the Bluetooth thread + @SpeziBluetooth + func synchronizeModel(capture: GATTCharacteristicCapture) { if capture.isNotifying != isNotifying { - isNotifying = capture.isNotifying + withMutation(keyPath: \.isNotifying) { + captureLock.withWriteLock { + _isNotifying = capture.isNotifying + } + } } if capture.value != value { - value = capture.value + withMutation(keyPath: \.value) { + captureLock.withWriteLock { + _value = capture.value + } + } } - if capture.descriptors != descriptors { - descriptors = capture.descriptors + if capture.descriptors?.cbObject != descriptors { + withMutation(keyPath: \.descriptors) { + captureLock.withWriteLock { + _descriptors = capture.descriptors?.cbObject + } + } } } } -extension GATTCharacteristic: @unchecked Sendable {} +extension GATTCharacteristic {} extension GATTCharacteristic: CustomDebugStringConvertible { diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTService.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTService.swift index cba37cbb..39bca035 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTService.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTService.swift @@ -6,14 +6,18 @@ // SPDX-License-Identifier: MIT // -@preconcurrency import CoreBluetooth +import CoreBluetooth import Foundation struct ServiceChangeProtocol { - let removedCharacteristics: Set + let removedCharacteristics: Set let updatedCharacteristics: [GATTCharacteristic] } +struct GATTServiceCapture: Sendable { + let isPrimary: Bool +} + /// A Bluetooth service of a device. /// @@ -27,11 +31,11 @@ struct ServiceChangeProtocol { public final class GATTService { let underlyingService: CBService /// The stored characteristics, indexed by their uuid. - private var _characteristics: [CBUUID: GATTCharacteristic] + private var _characteristics: [BTUUID: GATTCharacteristic] /// The Bluetooth UUID of the service. - public var uuid: CBUUID { - underlyingService.uuid + public var uuid: BTUUID { + BTUUID(data: underlyingService.uuid.data) } /// The type of the service (primary or secondary). @@ -44,12 +48,16 @@ public final class GATTService { Array(_characteristics.values) } + @SpeziBluetooth var captured: GATTServiceCapture { + GATTServiceCapture(isPrimary: isPrimary) + } + init(service: CBService) { self.underlyingService = service self._characteristics = [:] self._characteristics = service.characteristics?.reduce(into: [:], { result, characteristic in - result[characteristic.uuid] = GATTCharacteristic(characteristic: characteristic, service: self) + result[BTUUID(from: characteristic.uuid)] = GATTCharacteristic(characteristic: characteristic, service: self) }) ?? [:] } @@ -57,22 +65,23 @@ public final class GATTService { /// Retrieve a characteristic. /// - Parameter id: The Bluetooth characteristic id. /// - Returns: The characteristic instance if present. - public func getCharacteristic(id: CBUUID) -> GATTCharacteristic? { + public func getCharacteristic(id: BTUUID) -> GATTCharacteristic? { characteristics.first { characteristics in characteristics.uuid == id } } /// Signal from the BluetoothManager to update your stored representations. - func synchronizeModel() -> ServiceChangeProtocol { // always called from the Bluetooth thread + @SpeziBluetooth + func synchronizeModel() -> ServiceChangeProtocol { var removedCharacteristics = Set(_characteristics.keys) var updatedCharacteristics: [GATTCharacteristic] = [] for cbCharacteristic in underlyingService.characteristics ?? [] { - let characteristic = _characteristics[cbCharacteristic.uuid] + let characteristic = _characteristics[BTUUID(from: cbCharacteristic.uuid)] if characteristic != nil { // The characteristic is there. Mark it as not removed. - removedCharacteristics.remove(cbCharacteristic.uuid) + removedCharacteristics.remove(BTUUID(from: cbCharacteristic.uuid)) } @@ -81,7 +90,7 @@ public final class GATTService { // create/replace it let characteristic = GATTCharacteristic(characteristic: cbCharacteristic, service: self) updatedCharacteristics.append(characteristic) - _characteristics[cbCharacteristic.uuid] = characteristic + _characteristics[BTUUID(from: cbCharacteristic.uuid)] = characteristic } } @@ -95,9 +104,6 @@ public final class GATTService { } -extension GATTService: @unchecked Sendable {} - - extension GATTService: Hashable { public static func == (lhs: GATTService, rhs: GATTService) -> Bool { lhs.underlyingService == rhs.underlyingService diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift index ae241338..d727e14c 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift @@ -15,8 +15,9 @@ import Foundation /// track of a on-change handler and cancel the registration at a later point. /// /// - Tip: The on-change handler will be automatically unregistered when this object is deallocated. -public class OnChangeRegistration { - private weak var peripheral: BluetoothPeripheral? +public final class OnChangeRegistration { + // reference counting is atomic, so non-isolated(unsafe) is fine, we never mutate + private nonisolated(unsafe) weak var peripheral: BluetoothPeripheral? let locator: CharacteristicLocator let handlerId: UUID @@ -31,7 +32,7 @@ public class OnChangeRegistration { /// Cancel the on-change handler registration. public func cancel() { Task { @SpeziBluetooth in - await peripheral?.deregisterOnChange(self) + peripheral?.deregisterOnChange(self) } } @@ -43,7 +44,10 @@ public class OnChangeRegistration { let handlerId = handlerId Task.detached { @SpeziBluetooth in - await peripheral?.deregisterOnChange(locator: locator, handlerId: handlerId) + peripheral?.deregisterOnChange(locator: locator, handlerId: handlerId) } } } + + +extension OnChangeRegistration: Sendable {} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralState.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralState.swift index 19b72989..3a54260f 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralState.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralState.swift @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // +import Atomics import CoreBluetooth @@ -22,7 +23,13 @@ public enum PeripheralState: UInt8 { } -extension PeripheralState: CustomStringConvertible, Sendable { +extension PeripheralState: RawRepresentable, AtomicValue {} + + +extension PeripheralState: Hashable, Sendable {} + + +extension PeripheralState: CustomStringConvertible { public var description: String { switch self { case .disconnected: diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift index 1d6e10a3..01f29eb2 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT // -import CoreBluetooth +import Atomics import Foundation @@ -15,125 +15,186 @@ import Foundation /// Main motivation is to have `BluetoothPeripheral` be implemented as an actor and moving state /// into a separate state container that is `@Observable`. @Observable -final class PeripheralStorage: ValueObservable { - var name: String? { - peripheralName ?? localName - } - - private(set) var peripheralName: String? { +final class PeripheralStorage: ValueObservable, Sendable { + private let _state: ManagedAtomic + private let _rssi: ManagedAtomic + private let _nearby: ManagedAtomic + private let _lastActivityTimeIntervalSince1970BitPattern: ManagedAtomic // workaround to store store Date atomically + // swiftlint:disable:previous identifier_name + + @ObservationIgnored private nonisolated(unsafe) var _peripheralName: String? + @ObservationIgnored private nonisolated(unsafe) var _advertisementData: AdvertisementData + // Its fine to have a single lock. Readers will be isolated anyways to the SpeziBluetooth global actor. + // The only side-effect is, that readers will wait for any write to complete, which is fine as peripheralName is rarely updated. + private let lock = RWLock() + + @SpeziBluetooth var lastActivity: Date { didSet { - _$simpleRegistrar.triggerDidChange(for: \.peripheralName, on: self) + _lastActivityTimeIntervalSince1970BitPattern.store(lastActivity.timeIntervalSince1970.bitPattern, ordering: .relaxed) + _$simpleRegistrar.triggerDidChange(for: \.lastActivity, on: self) } } - private(set) var localName: String? { + + @SpeziBluetooth var services: [GATTService]? { // swiftlint:disable:this discouraged_optional_collection didSet { - _$simpleRegistrar.triggerDidChange(for: \.localName, on: self) + _$simpleRegistrar.triggerDidChange(for: \.services, on: self) } } - private(set) var rssi: Int { - didSet { - _$simpleRegistrar.triggerDidChange(for: \.rssi, on: self) + @inlinable var name: String? { + access(keyPath: \._peripheralName) + access(keyPath: \._advertisementData) + return lock.withReadLock { + _peripheralName ?? _advertisementData.localName } } - private(set) var advertisementData: AdvertisementData { - didSet { - _$simpleRegistrar.triggerDidChange(for: \.advertisementData, on: self) - } + @inlinable var readOnlyRssi: Int { + access(keyPath: \._rssi) + return _rssi.load(ordering: .relaxed) } - private(set) var state: PeripheralState { - didSet { - _$simpleRegistrar.triggerDidChange(for: \.state, on: self) - } + @inlinable var readOnlyState: PeripheralState { + access(keyPath: \._state) + return _state.load(ordering: .relaxed) } - private(set) var nearby: Bool { - didSet { - _$simpleRegistrar.triggerDidChange(for: \.nearby, on: self) + @inlinable var readOnlyNearby: Bool { + access(keyPath: \._nearby) + return _nearby.load(ordering: .relaxed) + } + + @inlinable var readOnlyAdvertisementData: AdvertisementData { + access(keyPath: \._advertisementData) + return lock.withReadLock { + _advertisementData } } - private(set) var services: [GATTService]? { // swiftlint:disable:this discouraged_optional_collection - didSet { - _$simpleRegistrar.triggerDidChange(for: \.services, on: self) + var readOnlyLastActivity: Date { + let timeIntervalSince1970 = Double(bitPattern: _lastActivityTimeIntervalSince1970BitPattern.load(ordering: .relaxed)) + return Date(timeIntervalSince1970: timeIntervalSince1970) + } + + @SpeziBluetooth var peripheralName: String? { + get { + access(keyPath: \._peripheralName) + return lock.withReadLock { + _peripheralName + } + } + set { + let didChange = newValue != _peripheralName + withMutation(keyPath: \._peripheralName) { + lock.withWriteLock { + _peripheralName = newValue + } + } + + if didChange { + _$simpleRegistrar.triggerDidChange(for: \.peripheralName, on: self) + } } } - private(set) var lastActivity: Date { - didSet { - _$simpleRegistrar.triggerDidChange(for: \.lastActivity, on: self) + @SpeziBluetooth var rssi: Int { + get { + readOnlyRssi + } + set { + let didChange = newValue != readOnlyRssi + withMutation(keyPath: \._rssi) { + _rssi.store(newValue, ordering: .relaxed) + } + if didChange { + _$simpleRegistrar.triggerDidChange(for: \.rssi, on: self) + } } } - // swiftlint:disable:next identifier_name - @ObservationIgnored var _$simpleRegistrar = ValueObservationRegistrar() + @SpeziBluetooth var advertisementData: AdvertisementData { + get { + readOnlyAdvertisementData + } + set { + let didChange = newValue != _advertisementData + withMutation(keyPath: \._advertisementData) { + lock.withWriteLock { + _advertisementData = newValue + } + } - init(peripheralName: String?, rssi: Int, advertisementData: AdvertisementData, state: CBPeripheralState, lastActivity: Date = .now) { - self.peripheralName = peripheralName - self.localName = advertisementData.localName - self.advertisementData = advertisementData - self.rssi = rssi - self.state = .init(from: state) - self.nearby = false - self.lastActivity = lastActivity + if didChange { + _$simpleRegistrar.triggerDidChange(for: \.advertisementData, on: self) + } + } } - func update(localName: String?) { - if self.localName != localName { - self.localName = localName + @SpeziBluetooth var state: PeripheralState { + get { + readOnlyState + } + set { + let didChange = newValue != readOnlyState + withMutation(keyPath: \._state) { + _state.store(newValue, ordering: .relaxed) + } + if didChange { + _$simpleRegistrar.triggerDidChange(for: \.state, on: self) + } } } - func update(peripheralName: String?) { - if self.peripheralName != peripheralName { - self.peripheralName = peripheralName + @SpeziBluetooth var nearby: Bool { + get { + readOnlyNearby } - } + set { + let didChange = newValue != readOnlyNearby + withMutation(keyPath: \._nearby) { + _nearby.store(newValue, ordering: .relaxed) + } - func update(rssi: Int) { - if self.rssi != rssi { - self.rssi = rssi + if didChange { + _$simpleRegistrar.triggerDidChange(for: \.nearby, on: self) + } } } - func update(advertisementData: AdvertisementData) { - self.advertisementData = advertisementData // not equatable + // swiftlint:disable:next identifier_name + @ObservationIgnored let _$simpleRegistrar = ValueObservationRegistrar() + + init(peripheralName: String?, rssi: Int, advertisementData: AdvertisementData, state: PeripheralState, lastActivity: Date = .now) { + self._peripheralName = peripheralName + self._advertisementData = advertisementData + self._rssi = ManagedAtomic(rssi) + self._state = ManagedAtomic(state) + self._nearby = ManagedAtomic(false) + self._lastActivity = lastActivity + self._lastActivityTimeIntervalSince1970BitPattern = ManagedAtomic(lastActivity.timeIntervalSince1970.bitPattern) } + @SpeziBluetooth func update(state: PeripheralState) { - if self.state != state { + let current = self.state + if current != state { // we set connected on our own! See `signalFullyDiscovered` - if !(self.state == .connecting && state == .connected) { + if !(current == .connecting && state == .connected) { self.state = state } } - if !nearby && (self.state == .connecting || self.state == .connected) { + if current == .connecting || current == .connected { self.nearby = true } } - func update(nearby: Bool) { - if nearby != self.nearby { - self.nearby = nearby - } - } - + @SpeziBluetooth func signalFullyDiscovered() { if state == .connecting { state = .connected update(state: .connected) // ensure other logic is called as well } } - - func update(lastActivity: Date = .now) { - self.lastActivity = lastActivity - } - - func assign(services: [GATTService]) { - self.services = services - } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothActor.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothActor.swift deleted file mode 100644 index 92f51887..00000000 --- a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothActor.swift +++ /dev/null @@ -1,32 +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 Foundation - -protocol BluetoothActor: Actor { - nonisolated var bluetoothQueue: DispatchSerialQueue { get } - - func isolated(perform: (isolated Self) throws -> Void) rethrows - - func isolated(perform: (isolated Self) async throws -> Void) async rethrows -} - -extension BluetoothActor { - /// Default implementation returning the unknown serial executor of the dispatch queue. - public nonisolated var unownedExecutor: UnownedSerialExecutor { - bluetoothQueue.asUnownedSerialExecutor() - } - - func isolated(perform: (isolated Self) throws -> Void) rethrows { - try perform(self) - } - - func isolated(perform: (isolated Self) async throws -> Void) async rethrows { - try await perform(self) - } -} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothWorkItem.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothWorkItem.swift index 60f5579f..21dc9827 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothWorkItem.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothWorkItem.swift @@ -9,32 +9,26 @@ import Foundation -class BluetoothWorkItem { - let workItem: DispatchWorkItem - - init(boundTo actor: Actor, handler: @escaping (isolated Actor) -> Void) { - self.workItem = DispatchWorkItem { [weak actor] in - guard let actor else { - return - } - - // We are running on the dispatch queue, however we are not running in the task. - // So sadly, we can't just jump into the actor isolation. But no big deal here for synchronization. +final class BluetoothWorkItem { + private let workItem: DispatchWorkItem + init(handler: @SpeziBluetooth @escaping @Sendable () -> Void) { + self.workItem = DispatchWorkItem { Task { @SpeziBluetooth in - await actor.isolated(perform: handler) + handler() } } } + func schedule(for deadline: DispatchTime) { + SpeziBluetooth.shared.dispatchQueue.asyncAfter(deadline: deadline, execute: workItem) + } + func cancel() { workItem.cancel() } -} - -extension DispatchSerialQueue { - func schedule(for deadline: DispatchTime, execute: BluetoothWorkItem) { - asyncAfter(deadline: deadline, execute: execute.workItem) + deinit { + workItem.cancel() } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/CharacteristicOnChangeHandler.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/CharacteristicOnChangeHandler.swift new file mode 100644 index 00000000..16b96dbe --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/CharacteristicOnChangeHandler.swift @@ -0,0 +1,15 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +enum CharacteristicOnChangeHandler { + case value(_ closure: (Data) -> Void) + case instance(_ closure: (GATTCharacteristic?) -> Void) +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/DiscoveryStaleTimer.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/DiscoveryStaleTimer.swift index b5907b37..56e363e7 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/DiscoveryStaleTimer.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/DiscoveryStaleTimer.swift @@ -9,16 +9,16 @@ import Foundation -class DiscoveryStaleTimer { +final class DiscoveryStaleTimer { let targetDevice: UUID /// The dispatch work item that schedules the next stale timer. private let workItem: BluetoothWorkItem - init(device: UUID, boundTo actor: Actor, handler: @escaping (isolated Actor) -> Void) { + init(device: UUID, handler: @SpeziBluetooth @escaping @Sendable () -> Void) { // make sure that you don't create a reference cycle through the closure above! self.targetDevice = device - self.workItem = BluetoothWorkItem(boundTo: actor, handler: handler) + self.workItem = BluetoothWorkItem(handler: handler) } @@ -29,7 +29,7 @@ class DiscoveryStaleTimer { func schedule(for timeout: TimeInterval, in queue: DispatchSerialQueue) { // `DispatchTime` only allows for integer time let milliSeconds = Int(timeout * 1000) - queue.schedule(for: .now() + .milliseconds(milliSeconds), execute: workItem) + workItem.schedule(for: .now() + .milliseconds(milliSeconds)) } deinit { diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/KVOStateDidChangeObserver.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/KVOStateDidChangeObserver.swift new file mode 100644 index 00000000..288f05b2 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/KVOStateDidChangeObserver.swift @@ -0,0 +1,35 @@ +// +// 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 Foundation + + +@SpeziBluetooth +final class KVOStateDidChangeObserver: NSObject, Sendable { + private var observation: NSKeyValueObservation? + + private let entity: Entity + private let keyPath: KeyPath + + @SpeziBluetooth + init(entity: Entity, property: KeyPath, perform action: @SpeziBluetooth @Sendable @escaping (Value) async -> Void) { + self.entity = entity + self.keyPath = property + super.init() + + observation = entity.observe(property) { [weak self] _, _ in + Task { @SpeziBluetooth [weak self] in + guard let self else { + return + } + let value = self.entity[keyPath: self.keyPath] + await action(value) + } + } + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/KVOStateObserver.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/KVOStateObserver.swift deleted file mode 100644 index d0ad3a18..00000000 --- a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/KVOStateObserver.swift +++ /dev/null @@ -1,42 +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 Foundation - - -protocol KVOReceiver: AnyObject { - func observeChange(of keyPath: KeyPath, value: V) async -} - - -class KVOStateObserver: NSObject { - private weak var receiver: Receiver? - - private var observation: NSKeyValueObservation? - - // swiftlint:disable:next function_default_parameter_at_end - init(receiver: Receiver? = nil, entity: Entity, property: KeyPath) { - self.receiver = receiver - super.init() - - observation = entity.observe(property) { [weak self] entity, _ in - let value = entity[keyPath: property] - self?.observeChange(of: property, value: value) - } - } - - func initReceiver(_ receiver: Receiver) { - self.receiver = receiver - } - - func observeChange(of keyPath: KeyPath, value: V) { - Task { @SpeziBluetooth in - await receiver?.observeChange(of: keyPath, value: value) - } - } -} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/RWLock.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/RWLock.swift new file mode 100644 index 00000000..485e3891 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/RWLock.swift @@ -0,0 +1,197 @@ +// +// 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 Atomics +import Foundation + + +private protocol PThreadReadWriteLock: AnyObject { + // We need the unsafe mutable pointer, as otherwise we need to pass the property as inout parameter which isn't thread safe. + var rwLock: UnsafeMutablePointer { get } +} + + +final class RecursiveRWLock: PThreadReadWriteLock, @unchecked Sendable { + fileprivate let rwLock: UnsafeMutablePointer + + private let writerThread = ManagedAtomic(nil) + private var writerCount = 0 + private var readerCount = 0 + + init() { + rwLock = Self.pthreadInit() + } + + + private func writeLock() { + let selfThread = pthread_self() + + if let writer = writerThread.load(ordering: .relaxed), + pthread_equal(writer, selfThread) != 0 { + // we know that the writerThread is us, so access to `writerCount` is synchronized (its us that holds the rwLock). + writerCount += 1 + assert(writerCount > 1, "Synchronization issue. Writer count is unexpectedly low: \(writerCount)") + return + } + + pthreadWriteLock() + + writerThread.store(selfThread, ordering: .relaxed) + writerCount = 1 + } + + private func writeUnlock() { + // we assume this is called while holding the write lock, so access to `writerCount` is safe + if writerCount > 1 { + writerCount -= 1 + return + } + + // otherwise it is the last unlock + writerThread.store(nil, ordering: .relaxed) + writerCount = 0 + + pthreadUnlock() + } + + private func readLock() { + let selfThread = pthread_self() + + if let writer = writerThread.load(ordering: .relaxed), + pthread_equal(writer, selfThread) != 0 { + // we know that the writerThread is us, so access to `readerCount` is synchronized (its us that holds the rwLock). + readerCount += 1 + assert(readerCount > 0, "Synchronization issue. Reader count is unexpectedly low: \(readerCount)") + return + } + + pthreadReadLock() + } + + private func readUnlock() { + // we assume this is called while holding the reader lock, so access to `readerCount` is safe + if readerCount > 0 { + // fine to go down to zero (we still hold the lock in write mode) + readerCount -= 1 + return + } + + pthreadUnlock() + } + + + func withWriteLock(body: () throws -> T) rethrows -> T { + writeLock() + defer { + writeUnlock() + } + return try body() + } + + func withReadLock(body: () throws -> T) rethrows -> T { + readLock() + defer { + readUnlock() + } + return try body() + } + + deinit { + pthreadDeinit() + } +} + + +/// Read-Write Lock using `pthread_rwlock`. +/// +/// Looking at https://www.vadimbulavin.com/benchmarking-locking-apis, using `pthread_rwlock` +/// is favorable over using dispatch queues. +final class RWLock: PThreadReadWriteLock, @unchecked Sendable { + fileprivate let rwLock: UnsafeMutablePointer + + init() { + rwLock = Self.pthreadInit() + } + + /// Call `body` with a reading lock. + /// + /// - parameter body: A function that reads a value while locked. + /// - returns: The value returned from the given function. + func withReadLock(body: () throws -> T) rethrows -> T { + pthreadWriteLock() + defer { + pthreadUnlock() + } + return try body() + } + + /// Call `body` with a writing lock. + /// + /// - parameter body: A function that writes a value while locked, then returns some value. + /// - returns: The value returned from the given function. + func withWriteLock(body: () throws -> T) rethrows -> T { + pthreadWriteLock() + defer { + pthreadUnlock() + } + return try body() + } + + func isWriteLocked() -> Bool { + let status = pthread_rwlock_trywrlock(rwLock) + + // see status description https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/pthread_rwlock_trywrlock.3.html + switch status { + case 0: + pthreadUnlock() + return false + case EBUSY: // The calling thread is not able to acquire the lock without blocking. + return false // means we aren't locked + case EDEADLK: // The calling thread already owns the read/write lock (for reading or writing). + return true + default: + preconditionFailure("Unexpected status from pthread_rwlock_tryrdlock: \(status)") + } + } + + deinit { + pthreadDeinit() + } +} + + +extension PThreadReadWriteLock { + static func pthreadInit() -> UnsafeMutablePointer { + let lock: UnsafeMutablePointer = .allocate(capacity: 1) + let status = pthread_rwlock_init(lock, nil) + precondition(status == 0, "pthread_rwlock_init failed with status \(status)") + return lock + } + + func pthreadWriteLock() { + let status = pthread_rwlock_wrlock(rwLock) + assert(status == 0, "pthread_rwlock_wrlock failed with status \(status)") + } + + func pthreadReadLock() { + let status = pthread_rwlock_rdlock(rwLock) + assert(status == 0, "pthread_rwlock_rdlock failed with status \(status)") + } + + func pthreadUnlock() { + let status = pthread_rwlock_unlock(rwLock) + assert(status == 0, "pthread_rwlock_unlock failed with status \(status)") + } + + func pthreadDeinit() { + let status = pthread_rwlock_destroy(rwLock) + assert(status == 0) + rwLock.deallocate() + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/SpeziBluetoothActor.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/SpeziBluetoothActor.swift index 71ebc499..474e9a69 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/SpeziBluetoothActor.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/SpeziBluetoothActor.swift @@ -9,8 +9,88 @@ import Foundation -/// Global, framework-internal actor to schedule work that is exectued serially. +private struct SpeziBluetoothDispatchQueueKey: Sendable, Hashable { + static let shared = SpeziBluetoothDispatchQueueKey() + static nonisolated(unsafe) let key = DispatchSpecificKey() + private init() {} +} + + +/// A lot of the CB objects are not sendable. This is fine. +/// However, Swift is not smart enough to know that CB delegate methods (e.g., CBCentralManagerDelete or the CBPeripheralDelegate) are called +/// on the SpeziBluetooth actor's dispatch queue and therefore are never sent over actor boundaries. +/// This type helps us to assume the sendable property to bypass Swift concurrency checking +@dynamicMemberLookup +struct CBInstance: Sendable { + private nonisolated(unsafe) let object: Value + @SpeziBluetooth var cbObject: Value { + object + } + + init(instantiatedOnDispatchQueue object: Value, file: StaticString = #fileID, line: UInt = #line) { + guard SpeziBluetooth.shared.isSync else { + fatalError("Incorrect actor executor assumption; Expected same executor as \(SpeziBluetooth.shared).", file: file, line: line) + } + + self.object = object + } + + init(unsafe object: Value) { + self.object = object + } + + @SpeziBluetooth subscript(dynamicMember keyPath: KeyPath) -> T { + cbObject[keyPath: keyPath] + } +} + + +/// Global actor to schedule Bluetooth-related work that is executed serially. +/// +/// SpeziBluetooth synchronizes all its state to the `SpeziBluetooth` global actor. +/// The `SpeziBluetooth` global actor is used to schedule all Bluetooth related tasks and synchronize all Bluetooth related state. +/// It is backed by a [`userInitiated`](https://developer.apple.com/documentation/dispatch/dispatchqos/1780759-userinitiated) serial DispatchQueue +/// shared with the `CoreBluetooth` framework. +/// +/// ### Data Safety +/// +/// Some read-only state of `SpeziBluetooth` is deliberately made non-isolated and can be accessed from any thread (e.g., ``Bluetooth/state`` or ``Bluetooth/isScanning`` properties). +/// Similar, values from ``Characteristic`` or ``DeviceState`` property wrappers are also non-isolated. +/// These values can be, on its own, safely accessed from any thread. However, due to their highly async nature you might need to consider them out of date just after your access. +/// For example, two accesses to ``Bluetooth/state`` just shortly after each other might deliver two completely different results. Or, accessing two different properties like +/// ``Bluetooth/state`` or ``Bluetooth/isScanning`` might deliver inconsistent results, like `isScanning=true` and `state=.poweredOff`. +/// - Tip: If you access a property multiple times within a section, consider making one access and saving it to a temporary variable to ensure a consistent view on the property. +/// +/// If you need a consistent view of your Bluetooth peripheral's state, especially if you access multiple properties at the same time, consider isolating to the `@SpeziBluetooth` global actor. +/// +/// All accessor bindings of SpeziBluetooth property wrappers (a call like `deviceInformation.$manufacturerName` using ``Characteristic/projectedValue``) capture the current +/// state of the represented value. For example, the ``CharacteristicAccessor`` binding will capture the current state of the characteristic when the binding was created. +/// This effectively creates a stable view onto the characteristic properties. However, the accessor binding might be invalidated as soon as the characteristic changes, so don't store it for longer than required. @globalActor -actor SpeziBluetooth { - static let shared = SpeziBluetooth() +public actor SpeziBluetooth { + /// The shared actor instance. + public static let shared = SpeziBluetooth() + + /// The underlying dispatch queue that runs the actor Jobs. + nonisolated let dispatchQueue: DispatchSerialQueue + + /// The underlying unowned serial executor. + public nonisolated var unownedExecutor: UnownedSerialExecutor { + dispatchQueue.asUnownedSerialExecutor() + } + + nonisolated var isSync: Bool { + DispatchQueue.getSpecific(key: SpeziBluetoothDispatchQueueKey.key) == SpeziBluetoothDispatchQueueKey.shared + } + + private init() { + let dispatchQueue = DispatchQueue(label: "edu.stanford.spezi.bluetooth", qos: .userInitiated) + guard let serialQueue = dispatchQueue as? DispatchSerialQueue else { + preconditionFailure("Dispatch queue \(dispatchQueue.label) was not initialized to be serial!") + } + + serialQueue.setSpecific(key: SpeziBluetoothDispatchQueueKey.key, value: SpeziBluetoothDispatchQueueKey.shared) + + self.dispatchQueue = serialQueue + } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/ValueObservable.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/ValueObservable.swift index bc634b05..b29a904e 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/ValueObservable.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/ValueObservable.swift @@ -16,16 +16,19 @@ protocol AnyValueObservation {} /// /// Holds the registered closure till the next value update happens. /// Inspired by Apple's Observation framework but with more power! -class ValueObservationRegistrar { +final class ValueObservationRegistrar: Sendable { struct ValueObservation: AnyValueObservation { let keyPath: KeyPath let handler: (Value) -> Void } - private var id: UInt64 = 0 - private var observations: [UInt64: AnyValueObservation] = [:] - private var keyPathIndex: [AnyKeyPath: Set] = [:] + @SpeziBluetooth private var id: UInt64 = 0 + @SpeziBluetooth private var observations: [UInt64: AnyValueObservation] = [:] + @SpeziBluetooth private var keyPathIndex: [AnyKeyPath: Set] = [:] + init() {} + + @SpeziBluetooth private func nextId() -> UInt64 { defer { id &+= 1 // add with overflow operator @@ -33,12 +36,14 @@ class ValueObservationRegistrar { return id } + @SpeziBluetooth func onChange(of keyPath: KeyPath, perform closure: @escaping (Value) -> Void) { let id = nextId() observations[id] = ValueObservation(keyPath: keyPath, handler: closure) keyPathIndex[keyPath, default: []].insert(id) } + @SpeziBluetooth func triggerDidChange(for keyPath: KeyPath, on observable: Observable) { guard let ids = keyPathIndex.removeValue(forKey: keyPath) else { return @@ -58,15 +63,17 @@ class ValueObservationRegistrar { /// A model with value observable properties. -protocol ValueObservable: AnyObject { +protocol ValueObservable: AnyObject, Sendable { // swiftlint:disable:next identifier_name - var _$simpleRegistrar: ValueObservationRegistrar { get set } + var _$simpleRegistrar: ValueObservationRegistrar { get } + @SpeziBluetooth func onChange(of keyPath: KeyPath, perform closure: @escaping (Value) -> Void) } extension ValueObservable { + @SpeziBluetooth func onChange(of keyPath: KeyPath, perform closure: @escaping (Value) -> Void) { _$simpleRegistrar.onChange(of: keyPath, perform: closure) } diff --git a/Sources/SpeziBluetooth/Environment/AdvertisementStaleIntervalEnvironmentKey.swift b/Sources/SpeziBluetooth/Environment/AdvertisementStaleIntervalEnvironmentKey.swift index 8f024656..04e3465d 100644 --- a/Sources/SpeziBluetooth/Environment/AdvertisementStaleIntervalEnvironmentKey.swift +++ b/Sources/SpeziBluetooth/Environment/AdvertisementStaleIntervalEnvironmentKey.swift @@ -16,7 +16,7 @@ private struct AdvertisementStaleIntervalEnvironmentKey: EnvironmentKey { extension EnvironmentValues { /// The time interval after which a peripheral advertisement is considered stale if we don't hear back from the device. Minimum is 1 second. - public var advertisementStaleInterval: TimeInterval? { + public internal(set) var advertisementStaleInterval: TimeInterval? { get { self[AdvertisementStaleIntervalEnvironmentKey.self] } diff --git a/Sources/SpeziBluetooth/Environment/MinimumRSSIEnvironmentKey.swift b/Sources/SpeziBluetooth/Environment/MinimumRSSIEnvironmentKey.swift index 22498756..a66db8a6 100644 --- a/Sources/SpeziBluetooth/Environment/MinimumRSSIEnvironmentKey.swift +++ b/Sources/SpeziBluetooth/Environment/MinimumRSSIEnvironmentKey.swift @@ -16,7 +16,7 @@ private struct MinimumRSSIEnvironmentKey: EnvironmentKey { extension EnvironmentValues { /// The minimum rssi a nearby peripheral must have to be considered nearby. - public var minimumRSSI: Int? { + public internal(set) var minimumRSSI: Int? { get { self[MinimumRSSIEnvironmentKey.self] } diff --git a/Sources/SpeziBluetooth/Environment/SurroundingScanModifiers.swift b/Sources/SpeziBluetooth/Environment/SurroundingScanModifiers.swift index b486d82b..bcc2323f 100644 --- a/Sources/SpeziBluetooth/Environment/SurroundingScanModifiers.swift +++ b/Sources/SpeziBluetooth/Environment/SurroundingScanModifiers.swift @@ -10,11 +10,13 @@ import SwiftUI @Observable -class SurroundingScanModifiers: EnvironmentKey { +final class SurroundingScanModifiers: EnvironmentKey, Sendable { static let defaultValue = SurroundingScanModifiers() @MainActor private var registeredModifiers: [AnyHashable: [UUID: any BluetoothScanningState]] = [:] + init() {} + @MainActor func setModifierScanningState(enabled: Bool, with scanner: Scanner, modifierId: UUID, state: Scanner.ScanningState) { if enabled { diff --git a/Sources/SpeziBluetooth/Model/Actions/BluetoothPeripheralAction.swift b/Sources/SpeziBluetooth/Model/Actions/BluetoothPeripheralAction.swift index 7b5ee6d4..3a70be1a 100644 --- a/Sources/SpeziBluetooth/Model/Actions/BluetoothPeripheralAction.swift +++ b/Sources/SpeziBluetooth/Model/Actions/BluetoothPeripheralAction.swift @@ -21,7 +21,7 @@ public enum _PeripheralActionContent { // swiftlint:disab /// a `callAsFunction()` method and declare the respective extension to ``DeviceActions``. public protocol _BluetoothPeripheralAction { // swiftlint:disable:this type_name /// The closure type of the action. - associatedtype ClosureType + associatedtype ClosureType: Sendable /// Create a new action for a given peripheral instance. /// - Parameter content: The action content. diff --git a/Sources/SpeziBluetooth/Model/BluetoothDevice.swift b/Sources/SpeziBluetooth/Model/BluetoothDevice.swift index 11105d03..117610d2 100644 --- a/Sources/SpeziBluetooth/Model/BluetoothDevice.swift +++ b/Sources/SpeziBluetooth/Model/BluetoothDevice.swift @@ -28,7 +28,7 @@ import Spezi /// init() {} /// } /// ``` -public protocol BluetoothDevice: AnyObject, Module, Observable { +public protocol BluetoothDevice: AnyObject, Module, Observable, Sendable { /// Initializes the Bluetooth Device. /// /// This initializer is called automatically when a peripheral of this type connects. diff --git a/Sources/SpeziBluetooth/Model/BluetoothService.swift b/Sources/SpeziBluetooth/Model/BluetoothService.swift index 98840e40..569e90c0 100644 --- a/Sources/SpeziBluetooth/Model/BluetoothService.swift +++ b/Sources/SpeziBluetooth/Model/BluetoothService.swift @@ -6,8 +6,6 @@ // SPDX-License-Identifier: MIT // -import class CoreBluetooth.CBUUID - /// A Bluetooth service implementation. /// @@ -20,8 +18,8 @@ import class CoreBluetooth.CBUUID /// Below is a short code example that implements some parts of the Device Information service. /// /// ```swift -/// class DeviceInformationService: BluetoothService { -/// static let id = CBUUID(string: "180A") +/// struct DeviceInformationService: BluetoothService { +/// static let id: BTUUID = "180A" /// /// @Characteristic(id: "2A29") /// var manufacturer: String? @@ -29,7 +27,7 @@ import class CoreBluetooth.CBUUID /// var firmwareRevision: String? /// } /// ``` -public protocol BluetoothService: AnyObject { +public protocol BluetoothService { /// The Bluetooth service id. - static var id: CBUUID { get } + static var id: BTUUID { get } } diff --git a/Sources/SpeziBluetooth/Model/Characteristic/ControlPointSupport.swift b/Sources/SpeziBluetooth/Model/Characteristic/ControlPointSupport.swift index 3fdc05f8..bb0e1e82 100644 --- a/Sources/SpeziBluetooth/Model/Characteristic/ControlPointSupport.swift +++ b/Sources/SpeziBluetooth/Model/Characteristic/ControlPointSupport.swift @@ -10,23 +10,17 @@ import Foundation import SpeziFoundation -final class ControlPointTransaction: @unchecked Sendable { +@SpeziBluetooth +final class ControlPointTransaction: Sendable { let id: UUID private(set) var continuation: CheckedContinuation? - private let lock = NSLock() init(id: UUID = UUID()) { self.id = id } func assignContinuation(_ continuation: CheckedContinuation) { - lock.lock() - - defer { - lock.unlock() - } - self.continuation = continuation } @@ -43,11 +37,6 @@ final class ControlPointTransaction: @unchecked Sendable { } private func resume(with result: Result) { - lock.lock() - defer { - lock.unlock() - } - guard let continuation else { return } diff --git a/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift b/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift index bbaaeeb7..bd436e48 100644 --- a/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift +++ b/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // +import Atomics import ByteCoding import CoreBluetooth import Foundation @@ -28,8 +29,8 @@ import Foundation /// The below code example demonstrates declaring the Firmware Revision characteristic of the Device Information service. /// /// ```swift -/// class DeviceInformationService: BluetoothService { -/// static let id = CBUUID(string: "180A") +/// struct DeviceInformationService: BluetoothService { +/// static let id: BTUUID = "180A" /// /// @Characteristic(id: "2A26") /// var firmwareRevision: String? @@ -42,24 +43,28 @@ import Foundation /// by supplying the `notify` initializer argument. /// /// - Tip: If you want to react to every change of the characteristic value, you can use -/// ``CharacteristicAccessor/onChange(initial:perform:)-6ltwk`` or -/// ``CharacteristicAccessor/onChange(initial:perform:)-5awby`` to set up your action. +/// ``CharacteristicAccessor/onChange(initial:perform:)-4ecct`` or +/// ``CharacteristicAccessor/onChange(initial:perform:)-6ahtp`` to set up your action. /// /// The below code example uses the [Bluetooth Heart Rate Service](https://www.bluetooth.com/specifications/specs/heart-rate-service-1-0) /// to demonstrate the automatic notifications feature for the Heart Rate Measurement characteristic. /// -/// - Important: This closure is called from the Bluetooth Serial Executor, if you don't pass in an async method +/// - Important: 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). /// /// ```swift -/// class HeartRateService: BluetoothService { -/// static let id = CBUUID(string: "180D") +/// struct HeartRateService: BluetoothService { +/// static let id: BTUUID = "180D" /// /// @Characteristic(id: "2A37", notify: true) /// var heartRateMeasurement: HeartRateMeasurement? /// -/// init() { -/// $heartRateMeasurement.onChange(perform: processMeasurement) +/// init() {} +/// +/// configure() { +/// $heartRateMeasurement.onChange { [weak self] value in +/// self?.processMeasurement(measurement) +/// } /// } /// /// func processMeasurement(_ measurement: HeartRateMeasurement) { @@ -79,8 +84,8 @@ import Foundation /// and inspecting other properties like `isPresent`. /// /// ```swift -/// class HeartRateService: BluetoothService { -/// static let id = CBUUID(string: "180D") +/// struct HeartRateService: BluetoothService { +/// static let id: BTUUID = "180D" /// /// @Characteristic(id: "2A37", notify: true) /// var heartRateMeasurement: HeartRateMeasurement? @@ -122,17 +127,13 @@ import Foundation /// ## Topics /// /// ### Declaring a Characteristic -/// - ``init(wrappedValue:id:notify:discoverDescriptors:)-322p2`` -/// - ``init(wrappedValue:id:notify:discoverDescriptors:)-6jfpk`` -/// - ``init(wrappedValue:id:discoverDescriptors:)-1nome`` -/// - ``init(wrappedValue:id:discoverDescriptors:)-1gflb`` -/// - ``init(wrappedValue:id:notify:discoverDescriptors:)-6c95d`` -/// - ``init(wrappedValue:id:notify:discoverDescriptors:)-6296j`` +/// - ``init(wrappedValue:id:autoRead:)`` +/// - ``init(wrappedValue:id:notify:autoRead:)-9medy`` +/// - ``init(wrappedValue:id:notify:autoRead:)-9f2nr`` /// /// ### Inspecting a Characteristic /// - ``CharacteristicAccessor/isPresent`` /// - ``CharacteristicAccessor/properties`` -/// - ``CharacteristicAccessor/descriptors`` /// /// ### Reading a value /// - ``CharacteristicAccessor/read()`` @@ -146,8 +147,8 @@ import Foundation /// - ``CharacteristicAccessor/enableNotifications(_:)`` /// /// ### Get notified about changes -/// - ``CharacteristicAccessor/onChange(initial:perform:)-6ltwk`` -/// - ``CharacteristicAccessor/onChange(initial:perform:)-5awby`` +/// - ``CharacteristicAccessor/onChange(initial:perform:)-4ecct`` +/// - ``CharacteristicAccessor/onChange(initial:perform:)-6ahtp`` /// /// ### Control Point Characteristics /// - ``ControlPointCharacteristic`` @@ -158,41 +159,102 @@ import Foundation /// - ``projectedValue`` /// - ``CharacteristicAccessor`` @propertyWrapper -public final class Characteristic: @unchecked Sendable { - class Configuration { - let description: CharacteristicDescription - var defaultNotify: Bool - - /// Memory address as an identifier for this Characteristic instance. - var objectId: ObjectIdentifier { - ObjectIdentifier(self) +public struct Characteristic: Sendable { + /// Storage unit for the property wrapper. + final class Storage: Sendable { + let id: BTUUID + let defaultNotify: ManagedAtomic + let autoRead: ManagedAtomic + + let injection = ManagedAtomicLazyReference>() + let testInjections = ManagedAtomicLazyReference>() + + let state: State + + init(id: BTUUID, defaultNotify: Bool, autoRead: Bool, initialValue: Value?) { + self.id = id + self.defaultNotify = ManagedAtomic(defaultNotify) + self.autoRead = ManagedAtomic(autoRead) + self.state = State(initialValue: initialValue) } + } - var id: CBUUID { - description.characteristicId + @Observable + final class State: Sendable { + struct CharacteristicCaptureRetrieval: Sendable { // workaround to make the retrieval of the `capture` property Sendable + private nonisolated(unsafe) let characteristic: GATTCharacteristic + + var capture: GATTCharacteristicCapture { + characteristic.captured + } + + init(_ characteristic: GATTCharacteristic) { + self.characteristic = characteristic + } + } + + @ObservationIgnored private nonisolated(unsafe) var _value: Value? + @ObservationIgnored private nonisolated(unsafe) var _capture: CharacteristicCaptureRetrieval? + // protects both properties above + private let lock = RWLock() + + @SpeziBluetooth @ObservationIgnored var characteristic: GATTCharacteristic? { + didSet { + lock.withWriteLock { + _capture = characteristic.map { CharacteristicCaptureRetrieval($0) } + } + } } - init(description: CharacteristicDescription, defaultNotify: Bool) { - self.description = description - self.defaultNotify = defaultNotify + @inlinable var readOnlyValue: Value? { + access(keyPath: \._value) + return lock.withReadLock { + _value + } } - } - let configuration: Configuration - private let _value: ObservableBox - private(set) var injection: CharacteristicPeripheralInjection? + var capture: GATTCharacteristicCapture? { + let characteristic = lock.withReadLock { + _capture + } + return characteristic?.capture + } + + @SpeziBluetooth var value: Value? { + get { + readOnlyValue + } + set { + inject(newValue) + } + } - private let _testInjections: Box?> = Box(nil) + init(initialValue: Value?) { + self._value = initialValue + } + + @inlinable + func inject(_ value: Value?) { + withMutation(keyPath: \._value) { + lock.withWriteLock { + _value = value + } + } + } + } + private let storage: Storage + + /// The characteristic description. var description: CharacteristicDescription { - configuration.description + CharacteristicDescription(id: storage.id, discoverDescriptors: false, autoRead: storage.autoRead.load(ordering: .relaxed)) } /// Access the current characteristic value. /// /// This is either the last read value or the latest notified value. public var wrappedValue: Value? { - _value.value + storage.state.readOnlyValue } /// Retrieve a temporary accessors instance. @@ -204,34 +266,29 @@ public final class Characteristic: @unchecked Sendable { /// However, if you project a new `CharacteristicAccessor` instance right after your access, /// the view on the characteristic might have changed due to the asynchronous nature of SpeziBluetooth. public var projectedValue: CharacteristicAccessor { - CharacteristicAccessor(configuration: configuration, injection: injection, value: _value, testInjections: _testInjections) + CharacteristicAccessor(storage) } - fileprivate init(wrappedValue: Value? = nil, characteristic: CBUUID, notify: Bool, autoRead: Bool = true, discoverDescriptors: Bool = false) { + fileprivate init(wrappedValue: Value? = nil, characteristic: BTUUID, notify: Bool, autoRead: Bool = true) { // swiftlint:disable:previous function_default_parameter_at_end - let description = CharacteristicDescription(id: characteristic, discoverDescriptors: discoverDescriptors, autoRead: autoRead) - self.configuration = .init(description: description, defaultNotify: notify) - self._value = ObservableBox(wrappedValue) + self.storage = Storage(id: characteristic, defaultNotify: notify, autoRead: autoRead, initialValue: wrappedValue) } - func inject(bluetooth: Bluetooth, peripheral: BluetoothPeripheral, serviceId: CBUUID, service: GATTService?) { - let characteristic = service?.getCharacteristic(id: configuration.id) - - let injection = CharacteristicPeripheralInjection( + @SpeziBluetooth + func inject(bluetooth: Bluetooth, peripheral: BluetoothPeripheral, serviceId: BTUUID, service: GATTService?) { + let injection = storage.injection.storeIfNilThenLoad(CharacteristicPeripheralInjection( bluetooth: bluetooth, peripheral: peripheral, serviceId: serviceId, - characteristicId: configuration.id, - value: _value, - characteristic: characteristic - ) - - // mutual access with `CharacteristicAccessor/enableNotifications` - self.injection = injection - injection.assumeIsolated { injection in - injection.setup(defaultNotify: configuration.defaultNotify) - } + characteristicId: storage.id, + state: storage.state + )) + assert(injection.peripheral === peripheral, "\(#function) cannot be called more than once in the lifetime of a \(Self.self) instance") + + storage.state.characteristic = service?.getCharacteristic(id: storage.id) + + injection.setup(defaultNotify: storage.defaultNotify.load(ordering: .acquiring)) } } @@ -242,21 +299,9 @@ extension Characteristic where Value: ByteEncodable { /// - wrappedValue: An optional default value. /// - id: The characteristic id. /// - autoRead: Flag indicating if the initial value should be automatically read from the peripheral. - /// - discoverDescriptors: Flag if characteristic descriptors should be discovered automatically. - public convenience init(wrappedValue: Value? = nil, id: String, autoRead: Bool = true, discoverDescriptors: Bool = false) { + public init(wrappedValue: Value? = nil, id: BTUUID, autoRead: Bool = true) { // swiftlint:disable:previous function_default_parameter_at_end - self.init(wrappedValue: wrappedValue, id: CBUUID(string: id), autoRead: autoRead, discoverDescriptors: discoverDescriptors) - } - - /// Declare a write-only characteristic. - /// - Parameters: - /// - wrappedValue: An optional default value. - /// - id: The characteristic id. - /// - autoRead: Flag indicating if the initial value should be automatically read from the peripheral. - /// - discoverDescriptors: Flag if characteristic descriptors should be discovered automatically. - public convenience init(wrappedValue: Value? = nil, id: CBUUID, autoRead: Bool = true, discoverDescriptors: Bool = false) { - // swiftlint:disable:previous function_default_parameter_at_end - self.init(wrappedValue: wrappedValue, characteristic: id, notify: false, autoRead: autoRead, discoverDescriptors: discoverDescriptors) + self.init(wrappedValue: wrappedValue, characteristic: id, notify: false, autoRead: autoRead) } } @@ -268,22 +313,9 @@ extension Characteristic where Value: ByteDecodable { /// - id: The characteristic id. /// - notify: Automatically subscribe to characteristic notifications if supported. /// - autoRead: Flag indicating if the initial value should be automatically read from the peripheral. - /// - discoverDescriptors: Flag if characteristic descriptors should be discovered automatically. - public convenience init(wrappedValue: Value? = nil, id: String, notify: Bool = false, autoRead: Bool = true, discoverDescriptors: Bool = false) { + public init(wrappedValue: Value? = nil, id: BTUUID, notify: Bool = false, autoRead: Bool = true) { // swiftlint:disable:previous function_default_parameter_at_end - self.init(wrappedValue: wrappedValue, id: CBUUID(string: id), notify: notify, autoRead: autoRead, discoverDescriptors: discoverDescriptors) - } - - /// Declare a read-only characteristic. - /// - Parameters: - /// - wrappedValue: An optional default value. - /// - id: The characteristic id. - /// - notify: Automatically subscribe to characteristic notifications if supported. - /// - autoRead: Flag indicating if the initial value should be automatically read from the peripheral. - /// - discoverDescriptors: Flag if characteristic descriptors should be discovered automatically. - public convenience init(wrappedValue: Value? = nil, id: CBUUID, notify: Bool = false, autoRead: Bool = true, discoverDescriptors: Bool = false) { - // swiftlint:disable:previous function_default_parameter_at_end - self.init(wrappedValue: wrappedValue, characteristic: id, notify: notify, autoRead: autoRead, discoverDescriptors: discoverDescriptors) + self.init(wrappedValue: wrappedValue, characteristic: id, notify: notify, autoRead: autoRead) } } @@ -295,22 +327,9 @@ extension Characteristic where Value: ByteCodable { // reduce ambiguity /// - id: The characteristic id. /// - notify: Automatically subscribe to characteristic notifications if supported. /// - autoRead: Flag indicating if the initial value should be automatically read from the peripheral. - /// - discoverDescriptors: Flag if characteristic descriptors should be discovered automatically. - public convenience init(wrappedValue: Value? = nil, id: String, notify: Bool = false, autoRead: Bool = true, discoverDescriptors: Bool = false) { - // swiftlint:disable:previous function_default_parameter_at_end - self.init(wrappedValue: wrappedValue, id: CBUUID(string: id), notify: notify, autoRead: autoRead, discoverDescriptors: discoverDescriptors) - } - - /// Declare a read and write characteristic. - /// - Parameters: - /// - wrappedValue: An optional default value. - /// - id: The characteristic id. - /// - notify: Automatically subscribe to characteristic notifications if supported. - /// - autoRead: Flag indicating if the initial value should be automatically read from the peripheral. - /// - discoverDescriptors: Flag if characteristic descriptors should be discovered automatically. - public convenience init(wrappedValue: Value? = nil, id: CBUUID, notify: Bool = false, autoRead: Bool = true, discoverDescriptors: Bool = false) { + public init(wrappedValue: Value? = nil, id: BTUUID, notify: Bool = false, autoRead: Bool = true) { // swiftlint:disable:previous function_default_parameter_at_end - self.init(wrappedValue: wrappedValue, characteristic: id, notify: notify, autoRead: autoRead, discoverDescriptors: discoverDescriptors) + self.init(wrappedValue: wrappedValue, characteristic: id, notify: notify, autoRead: autoRead) } } diff --git a/Sources/SpeziBluetooth/Model/Properties/DeviceAction.swift b/Sources/SpeziBluetooth/Model/Properties/DeviceAction.swift index 57e4460b..ae596990 100644 --- a/Sources/SpeziBluetooth/Model/Properties/DeviceAction.swift +++ b/Sources/SpeziBluetooth/Model/Properties/DeviceAction.swift @@ -6,6 +6,8 @@ // SPDX-License-Identifier: MIT // +import Atomics + /// Control an action of a Bluetooth peripheral. /// @@ -55,16 +57,21 @@ /// ### Device Actions /// - ``DeviceActions`` @propertyWrapper -public final class DeviceAction: @unchecked Sendable { - private var injection: DeviceActionPeripheralInjection? - /// Support injection of closures for testing support. - private let _injectedClosure = Box(nil) +public struct DeviceAction: Sendable { + final class Storage: Sendable { + let injection = ManagedAtomicLazyReference() + let testInjections = ManagedAtomicLazyReference>() + + init() {} + } + + private let storage = Storage() /// Access the device action. public var wrappedValue: Action { - guard let injection else { - if let injectedClosure = _injectedClosure.value { + guard let injection = storage.injection.load() else { + if let injectedClosure = storage.testInjections.load()?.injectedClosure { return Action(.injected(injectedClosure)) } @@ -79,8 +86,8 @@ public final class DeviceAction: @unchecked } /// Retrieve a temporary accessors instance. - public var projectedValue: DeviceActionAccessor { - DeviceActionAccessor(_injectedClosure) + public var projectedValue: DeviceActionAccessor { + DeviceActionAccessor(storage) } @@ -90,7 +97,8 @@ public final class DeviceAction: @unchecked func inject(bluetooth: Bluetooth, peripheral: BluetoothPeripheral) { - self.injection = DeviceActionPeripheralInjection(bluetooth: bluetooth, peripheral: peripheral) + let injection = storage.injection.storeIfNilThenLoad(DeviceActionPeripheralInjection(bluetooth: bluetooth, peripheral: peripheral)) + assert(injection.peripheral === peripheral, "\(#function) cannot be called more than once in the lifetime of a \(Self.self) instance") } } diff --git a/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift b/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift index c2a254e0..c2936100 100644 --- a/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift +++ b/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // +import Atomics import Observation @@ -50,8 +51,12 @@ import Observation /// @DeviceState(\.state) /// var state: PeripheralState /// -/// init() { -/// $state.onChange(perform: handleStateChange) +/// required init() {} +/// +/// func configure() { +/// $state.onChange { [weak self] value in +/// self?.handleStateChange(value) +/// } /// } /// /// handleStateChange(_ state: PeripheralState) { @@ -73,29 +78,37 @@ import Observation /// - ``BluetoothPeripheral/advertisementData`` /// /// ### Get notified about changes -/// - ``DeviceStateAccessor/onChange(initial:perform:)-8x9cj`` -/// - ``DeviceStateAccessor/onChange(initial:perform:)-9igc9`` +/// - ``DeviceStateAccessor/onChange(initial:perform:)-819sg`` +/// - ``DeviceStateAccessor/onChange(initial:perform:)-76kjp`` /// /// ### Property wrapper access /// - ``wrappedValue`` /// - ``projectedValue`` /// - ``DeviceStateAccessor`` -@Observable @propertyWrapper -public final class DeviceState: @unchecked Sendable { - private let keyPath: KeyPath - private(set) var injection: DeviceStatePeripheralInjection? - - private var _injectedValue = ObservableBox(nil) - private let _testInjections: Box?> = Box(nil) - - var objectId: ObjectIdentifier { - ObjectIdentifier(self) +public struct DeviceState: Sendable { +#if compiler(<6) + typealias KeyPathType = KeyPath +#else + typealias KeyPathType = KeyPath & Sendable +#endif + + final class Storage: Sendable { + let keyPath: KeyPathType + let injection = ManagedAtomicLazyReference>() + /// To support testing support. + let testInjections = ManagedAtomicLazyReference>() + + init(keyPath: KeyPathType) { + self.keyPath = keyPath + } } + private let storage: Storage + /// Access the device state. public var wrappedValue: Value { - guard let injection else { + guard let injection = storage.injection.load() else { if let defaultValue { // better support previews with some default values return defaultValue } @@ -113,24 +126,31 @@ public final class DeviceState: @unchecked Sendable { /// Retrieve a temporary accessors instance. public var projectedValue: DeviceStateAccessor { - DeviceStateAccessor(id: objectId, keyPath: keyPath, injection: injection, injectedValue: _injectedValue, testInjections: _testInjections) + DeviceStateAccessor(storage) } - + #if compiler(<6) /// Provide a `KeyPath` to the device state you want to access. /// - Parameter keyPath: The `KeyPath` to a property of the underlying ``BluetoothPeripheral`` instance. public init(_ keyPath: KeyPath) { - self.keyPath = keyPath + self.storage = Storage(keyPath: keyPath) } + #else + /// Provide a `KeyPath` to the device state you want to access. + /// - Parameter keyPath: The `KeyPath` to a property of the underlying ``BluetoothPeripheral`` instance. + public init(_ keyPath: KeyPath & Sendable) { + self.storage = Storage(keyPath: keyPath) + } + #endif + @SpeziBluetooth func inject(bluetooth: Bluetooth, peripheral: BluetoothPeripheral) { - let injection = DeviceStatePeripheralInjection(bluetooth: bluetooth, peripheral: peripheral, keyPath: keyPath) - self.injection = injection + let injection = storage.injection + .storeIfNilThenLoad(DeviceStatePeripheralInjection(bluetooth: bluetooth, peripheral: peripheral, keyPath: storage.keyPath)) + assert(injection.peripheral === peripheral, "\(#function) cannot be called more than once in the lifetime of a \(Self.self) instance") - injection.assumeIsolated { injection in - injection.setup() - } + injection.setup() } } @@ -148,10 +168,10 @@ extension DeviceState: DeviceVisitable, ServiceVisitable { extension DeviceState { var defaultValue: Value? { - if let injected = _injectedValue.value { + if let injected = storage.testInjections.load()?.injectedValue { return injected } - return _testInjections.value?.artificialValue(for: keyPath) + return DeviceStateTestInjections.artificialValue(for: storage.keyPath) } } diff --git a/Sources/SpeziBluetooth/Model/Properties/Service.swift b/Sources/SpeziBluetooth/Model/Properties/Service.swift index cf015f38..67397a47 100644 --- a/Sources/SpeziBluetooth/Model/Properties/Service.swift +++ b/Sources/SpeziBluetooth/Model/Properties/Service.swift @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // +import Atomics import CoreBluetooth @@ -38,11 +39,55 @@ import CoreBluetooth /// - ``projectedValue`` /// - ``ServiceAccessor`` @propertyWrapper -public final class Service: @unchecked Sendable { - var id: CBUUID { +public struct Service { + final class Storage: Sendable { + let injection = ManagedAtomicLazyReference>() + let state = State() + } + + @Observable + final class State: Sendable { + // Type safe capture of the current GATTService state. + // To make this lock-free and non-blocking, we collapse the information we need in `ServiceAccessor` into a single + // RawRepresentable atomic value. + enum ServiceState: UInt8, RawRepresentable, AtomicValue { + case notPresent + case presentNotPrimary + case presentPrimary + + @SpeziBluetooth + init(from value: GATTService?) { + switch value { + case .none: + self = .notPresent + case let .some(service): + self = service.isPrimary ? .presentPrimary : .presentNotPrimary + } + } + } + + private let _serviceState = ManagedAtomic(.notPresent) + + var serviceState: ServiceState { + get { + access(keyPath: \.serviceState) + return _serviceState.load(ordering: .relaxed) + } + set { + withMutation(keyPath: \.serviceState) { + _serviceState.store(newValue, ordering: .relaxed) + } + } + } + + init() {} + } + + var id: BTUUID { S.id } - private var injection: ServicePeripheralInjection? + + private let storage = Storage() /// Access the service instance. public let wrappedValue: S @@ -54,8 +99,8 @@ public final class Service: @unchecked Sendable { /// - Note: The accessor captures the service instance upon creation. Within the same `ServiceAccessor` instance /// the view on the service is consistent. However, if you project a new `ServiceAccessor` instance right /// after your access, the view on the service might have changed due to the asynchronous nature of SpeziBluetooth. - public var projectedValue: ServiceAccessor { - ServiceAccessor(id: id, injection: injection) + public var projectedValue: ServiceAccessor { + ServiceAccessor(storage) } /// Declare a service. @@ -66,16 +111,21 @@ public final class Service: @unchecked Sendable { } - func inject(peripheral: BluetoothPeripheral, service: GATTService?) { - let injection = ServicePeripheralInjection(peripheral: peripheral, serviceId: id, service: service) - self.injection = injection - injection.assumeIsolated { injection in - injection.setup() - } + @SpeziBluetooth + func inject(bluetooth: Bluetooth, peripheral: BluetoothPeripheral, service: GATTService?) { + let injection = storage.injection.storeIfNilThenLoad( + ServicePeripheralInjection(bluetooth: bluetooth, peripheral: peripheral, serviceId: id, service: service, state: storage.state) + ) + assert(injection.peripheral === peripheral, "\(#function) cannot be called more than once in the lifetime of a \(Self.self) instance") + + injection.setup() } } +extension Service: Sendable where S: Sendable {} + + extension Service: DeviceVisitable { func accept(_ visitor: inout Visitor) { visitor.visit(self) diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift index b44dfcce..2e5e8528 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift @@ -8,25 +8,6 @@ import ByteCoding import CoreBluetooth -import Spezi - - -struct CharacteristicTestInjections: DefaultInitializable { - var writeClosure: ((Value, WriteType) async throws -> Void)? - var readClosure: (() async throws -> Value)? - var requestClosure: ((Value) async throws -> Value)? - var subscriptions: ChangeSubscriptions? - var simulatePeripheral = false - - init() {} - - mutating func enableSubscriptions() { - // there is no BluetoothManager, so we need to create a queue on the fly - subscriptions = ChangeSubscriptions( - queue: DispatchSerialQueue(label: "edu.stanford.spezi.bluetooth.testing-\(Self.self)", qos: .userInitiated) - ) - } -} /// Interact with a given Characteristic. @@ -43,7 +24,6 @@ struct CharacteristicTestInjections: DefaultInitializable { /// ### Characteristic properties /// - ``isPresent`` /// - ``properties`` -/// - ``descriptors`` /// /// ### Reading a value /// - ``read()`` @@ -52,45 +32,33 @@ struct CharacteristicTestInjections: DefaultInitializable { /// - ``write(_:)`` /// - ``writeWithoutResponse(_:)`` /// -/// ### Controlling notifications +/// ### Configuring the characteristic /// - ``isNotifying`` /// - ``enableNotifications(_:)`` +/// - ``setAutoRead(_:)`` /// /// ### Get notified about changes -/// - ``onChange(initial:perform:)-6ltwk`` -/// - ``onChange(initial:perform:)-5awby`` +/// - ``onChange(initial:perform:)-4ecct`` +/// - ``onChange(initial:perform:)-6ahtp`` +/// - ``subscription`` /// /// ### Control Point Characteristics /// - ``sendRequest(_:timeout:)`` -public struct CharacteristicAccessor { - let configuration: Characteristic.Configuration - let injection: CharacteristicPeripheralInjection? - /// Capture of the characteristic. - private let characteristic: GATTCharacteristic? - - /// We keep track of this for testing support. - private let _value: ObservableBox - /// Closure that captures write for testing support. - private let _testInjections: Box?> +public struct CharacteristicAccessor { + private let storage: Characteristic.Storage + private let capturedCharacteristic: GATTCharacteristicCapture? init( - configuration: Characteristic.Configuration, - injection: CharacteristicPeripheralInjection?, - value: ObservableBox, - testInjections: Box?> + _ storage: Characteristic.Storage ) { - self.configuration = configuration - self.injection = injection - self.characteristic = injection?.unsafeCharacteristic - - self._value = value - self._testInjections = testInjections + self.storage = storage + self.capturedCharacteristic = storage.state.capture } } -extension CharacteristicAccessor: @unchecked Sendable {} +extension CharacteristicAccessor: Sendable {} extension CharacteristicAccessor { @@ -99,21 +67,14 @@ extension CharacteristicAccessor { /// Returns `true` if the characteristic is available for the current device. /// It is `true` if (a) the device is connected and (b) the device exposes the requested characteristic. public var isPresent: Bool { - characteristic != nil + capturedCharacteristic != nil } /// Properties of the characteristic. /// /// `nil` if device is not connected. public var properties: CBCharacteristicProperties? { - characteristic?.properties - } - - /// Descriptors of the characteristic. - /// - /// `nil` if device is not connected or descriptors are not yet discovered. - public var descriptors: [CBDescriptor]? { // swiftlint:disable:this discouraged_optional_collection - characteristic?.descriptors + capturedCharacteristic?.properties } } @@ -124,7 +85,7 @@ extension CharacteristicAccessor where Value: ByteDecodable { /// /// This is also `false` if device is not connected. public var isNotifying: Bool { - characteristic?.isNotifying ?? false + capturedCharacteristic?.isNotifying ?? false } @@ -132,11 +93,11 @@ extension CharacteristicAccessor where Value: ByteDecodable { /// /// This property creates an AsyncStream that yields all future updates to the characteristic value. public var subscription: AsyncStream { - if let subscriptions = _testInjections.value?.subscriptions { + if let subscriptions = storage.testInjections.load()?.subscriptions { return subscriptions.newSubscription() } - guard let injection else { + guard let injection = storage.injection.load() else { preconditionFailure( "The `subscription` of a @Characteristic cannot be accessed within the initializer. Defer access to the `configure() method" ) @@ -156,14 +117,14 @@ extension CharacteristicAccessor where Value: ByteDecodable { /// all your handlers. /// - Important: You must capture `self` weakly only. Capturing `self` strongly causes a memory leak. /// - /// - Note: This closure is called from the Bluetooth Serial Executor, if you don't pass in an async method + /// - 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). /// /// - Parameters: /// - initial: Whether the action should be run with the initial characteristic value. /// Otherwise, the action will only run strictly if the value changes. /// - action: The change handler to register. - public func onChange(initial: Bool = false, perform action: @escaping (_ value: Value) async -> Void) { + public func onChange(initial: Bool = false, perform action: @escaping @Sendable (_ value: Value) async -> Void) { onChange(initial: initial) { _, newValue in await action(newValue) } @@ -180,25 +141,25 @@ extension CharacteristicAccessor where Value: ByteDecodable { /// all your handlers. /// - Important: You must capture `self` weakly only. Capturing `self` strongly causes a memory leak. /// - /// - Note: This closure is called from the Bluetooth Serial Executor, if you don't pass in an async method + /// - 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). /// /// - Parameters: /// - initial: Whether the action should be run with the initial characteristic 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 (_ oldValue: Value, _ newValue: Value) async -> Void) { - if let subscriptions = _testInjections.value?.subscriptions { + public func onChange(initial: Bool = false, perform action: @escaping @Sendable (_ oldValue: Value, _ newValue: Value) async -> Void) { + if let subscriptions = storage.testInjections.load()?.subscriptions { let id = subscriptions.newOnChangeSubscription(perform: action) - if initial, let value = _value.value { + if initial, let value = storage.state.readOnlyValue { // if there isn't a value already, initial won't work properly with injections subscriptions.notifySubscriber(id: id, with: value) } return } - guard let injection else { + guard let injection = storage.injection.load() else { preconditionFailure( """ Register onChange(perform:) inside the initializer is not supported anymore. \ @@ -221,36 +182,55 @@ 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 else { + 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 - configuration.defaultNotify = enabled + storage.defaultNotify.store(enabled, ordering: .releasing) return } await injection.enableNotifications(enabled) } + + /// Automatically read the initial value of characteristic + /// + /// Enable or disable if the initial value should be automatically read from the peripheral. + /// + /// - Note: AutoRead behavior can only be changed within the initializer. Calls to this method outside of the initializer won't have any effect. + /// - Parameter enabled: Once the peripheral connects, a read request is sent to the characteristic to retrieve the initial value, if enabled. + public func setAutoRead(_ enabled: Bool) { + if storage.injection.load() != nil { + Bluetooth.logger.warning( + """ + AutoRead was set to \(enabled) for Characteristic \(storage.id) after it was already initialized. You must set autoRead within the \ + initializer for the setting to take effect. + """ + ) + } + storage.autoRead.store(enabled, ordering: .relaxed) + } + /// Read the current characteristic value from the remote peripheral. /// - Returns: The value that was read. /// - Throws: Throws an `CBError` or `CBATTError` if the read fails. /// It might also throw a ``BluetoothError/notPresent(service:characteristic:)`` or ``BluetoothError/incompatibleDataFormat`` error. @discardableResult public func read() async throws -> Value { - if let testInjection = _testInjections.value { + if let testInjection = storage.testInjections.load() { if let injectedReadClosure = testInjection.readClosure { return try await injectedReadClosure() } if testInjection.simulatePeripheral { - guard let value = _value.value else { - throw BluetoothError.notPresent(characteristic: configuration.id) + guard let value = await storage.state.value else { + throw BluetoothError.notPresent(characteristic: storage.id) } return value } } - guard let injection else { - throw BluetoothError.notPresent(characteristic: configuration.id) + guard let injection = storage.injection.load() else { + throw BluetoothError.notPresent(characteristic: storage.id) } return try await injection.read() @@ -271,7 +251,7 @@ extension CharacteristicAccessor where Value: ByteEncodable { /// - Throws: Throws an `CBError` or `CBATTError` if the write fails. /// It might also throw a ``BluetoothError/notPresent(service:characteristic:)`` error. public func write(_ value: Value) async throws { - if let testInjection = _testInjections.value { + if let testInjection = storage.testInjections.load() { if let injectedWriteClosure = testInjection.writeClosure { try await injectedWriteClosure(value, .withResponse) return @@ -283,8 +263,8 @@ extension CharacteristicAccessor where Value: ByteEncodable { } } - guard let injection else { - throw BluetoothError.notPresent(characteristic: configuration.id) + guard let injection = storage.injection.load() else { + throw BluetoothError.notPresent(characteristic: storage.id) } try await injection.write(value) @@ -300,7 +280,7 @@ extension CharacteristicAccessor where Value: ByteEncodable { /// - Throws: Throws an `CBError` or `CBATTError` if the write fails. /// It might also throw a ``BluetoothError/notPresent(service:characteristic:)`` error. public func writeWithoutResponse(_ value: Value) async throws { - if let testInjection = _testInjections.value { + if let testInjection = storage.testInjections.load() { if let injectedWriteClosure = testInjection.writeClosure { try await injectedWriteClosure(value, .withoutResponse) return @@ -312,8 +292,8 @@ extension CharacteristicAccessor where Value: ByteEncodable { } } - guard let injection else { - throw BluetoothError.notPresent(characteristic: configuration.id) + guard let injection = storage.injection.load() else { + throw BluetoothError.notPresent(characteristic: storage.id) } try await injection.writeWithoutResponse(value) @@ -339,12 +319,12 @@ extension CharacteristicAccessor where Value: ControlPointCharacteristic { /// ``BluetoothError/controlPointRequiresNotifying(service:characteristic:)`` or /// ``BluetoothError/controlPointInProgress(service:characteristic:)`` error. public func sendRequest(_ value: Value, timeout: Duration = .seconds(20)) async throws -> Value { - if let injectedRequestClosure = _testInjections.value?.requestClosure { + if let injectedRequestClosure = storage.testInjections.load()?.requestClosure { return try await injectedRequestClosure(value) } - guard let injection else { - throw BluetoothError.notPresent(characteristic: configuration.id) + guard let injection = storage.injection.load() else { + throw BluetoothError.notPresent(characteristic: storage.id) } return try await injection.sendRequest(value, timeout: timeout) @@ -361,14 +341,14 @@ extension CharacteristicAccessor { /// will be stored and called when injecting new values via `inject(_:)`. /// - Note: Make sure to inject a initial value if you want to make the `initial` property work properly public func enableSubscriptions() { - _testInjections.valueOrInitialize.enableSubscriptions() + storage.testInjections.storeIfNilThenLoad(.init()).enableSubscriptions() } /// Simulate a peripheral by automatically mocking read and write commands. /// /// - Note: `onWrite(perform:)` and `onRead(return:)` closures take precedence. public func enablePeripheralSimulation(_ enabled: Bool = true) { - _testInjections.valueOrInitialize.simulatePeripheral = enabled + storage.testInjections.storeIfNilThenLoad(.init()).simulatePeripheral = enabled } /// Inject a custom value for previewing purposes. @@ -381,9 +361,9 @@ extension CharacteristicAccessor { /// /// - Parameter value: The value to inject. public func inject(_ value: Value) { - _value.value = value + storage.state.inject(value) - if let subscriptions = _testInjections.value?.subscriptions { + if let subscriptions = storage.testInjections.load()?.subscriptions { subscriptions.notifySubscribers(with: value) } } @@ -395,7 +375,7 @@ extension CharacteristicAccessor { /// /// - Parameter action: The action to inject. Called for every write. public func onWrite(perform action: @escaping (Value, WriteType) async throws -> Void) { - _testInjections.valueOrInitialize.writeClosure = action + storage.testInjections.storeIfNilThenLoad(.init()).writeClosure = action } /// Inject a custom action that sinks all read operations for testing purposes. @@ -405,7 +385,7 @@ extension CharacteristicAccessor { /// /// - Parameter action: The action to inject. Called for every read. public func onRead(return action: @escaping () async throws -> Value) { - _testInjections.valueOrInitialize.readClosure = action + storage.testInjections.storeIfNilThenLoad(.init()).readClosure = action } /// Inject a custom action that sinks all control point request operations for testing purposes. @@ -415,6 +395,6 @@ extension CharacteristicAccessor { /// /// - Parameter action: The action to inject. Called for every control point request. public func onRequest(perform action: @escaping (Value) async throws -> Value) { - _testInjections.valueOrInitialize.requestClosure = action + storage.testInjections.storeIfNilThenLoad(.init()).requestClosure = action } } diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift index e5a32f41..481220e0 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift @@ -11,7 +11,8 @@ import CoreBluetooth import SpeziFoundation -private protocol DecodableCharacteristic: Actor { +private protocol DecodableCharacteristic { + @SpeziBluetooth func handleUpdateValueAssumingIsolation(_ data: Data?) } @@ -21,18 +22,14 @@ private protocol PrimitiveDecodableCharacteristic { /// Captures and synchronizes access to the state of a ``Characteristic`` property wrapper. -actor CharacteristicPeripheralInjection: BluetoothActor { - let bluetoothQueue: DispatchSerialQueue - +@SpeziBluetooth +class CharacteristicPeripheralInjection: Sendable { private let bluetooth: Bluetooth - fileprivate let peripheral: BluetoothPeripheral - let serviceId: CBUUID - let characteristicId: CBUUID + let peripheral: BluetoothPeripheral + private let serviceId: BTUUID + private let characteristicId: BTUUID - /// Observable value. Don't access directly. - private let _value: ObservableBox - /// Don't access directly. Observable for the properties of ``CharacteristicAccessor``. - private let _characteristic: WeakObservableBox + private let state: Characteristic.State /// State support for control point characteristics. /// @@ -53,45 +50,24 @@ actor CharacteristicPeripheralInjection: BluetoothActor { private var valueRegistration: OnChangeRegistration? - private(set) var value: Value? { - get { - _value.value - } - set { - _value.value = newValue - } - } - private var characteristic: GATTCharacteristic? { - get { - _characteristic.value - } - set { - _characteristic.value = newValue - } - } - - nonisolated var unsafeCharacteristic: GATTCharacteristic? { - _characteristic.value + state.characteristic } init( bluetooth: Bluetooth, peripheral: BluetoothPeripheral, - serviceId: CBUUID, - characteristicId: CBUUID, - value: ObservableBox, - characteristic: GATTCharacteristic? + serviceId: BTUUID, + characteristicId: BTUUID, + state: Characteristic.State ) { self.bluetooth = bluetooth - self.bluetoothQueue = peripheral.bluetoothQueue self.peripheral = peripheral self.serviceId = serviceId self.characteristicId = characteristicId - self._value = value - self._characteristic = .init(characteristic) - self.subscriptions = ChangeSubscriptions(queue: peripheral.bluetoothQueue) + self.state = state + self.subscriptions = ChangeSubscriptions() } /// Setup the injection. Must be called after initialization to set up all handlers and write the initial value. @@ -121,7 +97,10 @@ actor CharacteristicPeripheralInjection: BluetoothActor { subscriptions.newSubscription() } - nonisolated func newOnChangeSubscription(initial: Bool, perform action: @escaping (_ oldValue: Value, _ newValue: Value) async -> Void) { + nonisolated func newOnChangeSubscription( + initial: Bool, + perform action: @escaping @Sendable (_ oldValue: Value, _ newValue: Value) async -> Void + ) { let id = subscriptions.newOnChangeSubscription(perform: action) // Must be called detached, otherwise it might inherit TaskLocal values which includes Spezi moduleInitContext @@ -136,7 +115,7 @@ actor CharacteristicPeripheralInjection: BluetoothActor { if !initial { nonInitialChangeHandlers?.insert(id) } - } else if initial, let value { + } else if initial, let value = state.value { // nonInitialChangeHandlers is nil, meaning the initial value already arrived and we can call the action instantly if they wanted that subscriptions.notifySubscriber(id: id, with: value) } @@ -145,39 +124,22 @@ actor CharacteristicPeripheralInjection: BluetoothActor { /// Enable or disable notifications for the characteristic. /// - Parameter enabled: Flag indicating if notifications should be enabled. func enableNotifications(_ enabled: Bool = true) { - peripheral.assumeIsolated { peripheral in - peripheral.enableNotifications(enabled, serviceId: serviceId, characteristicId: characteristicId) - } + peripheral.enableNotifications(enabled, serviceId: serviceId, characteristicId: characteristicId) } private func registerCharacteristicInstanceChanges() { - self.instanceRegistration = peripheral.assumeIsolated { peripheral in - peripheral.registerOnChangeCharacteristicHandler( - service: serviceId, - characteristic: characteristicId - ) { [weak self] characteristic in - guard let self = self else { - return - } - - self.assertIsolated("BluetoothPeripheral onChange handler was unexpectedly executed outside the peripheral actor!") - self.assumeIsolated { injection in - injection.handleChangedCharacteristic(characteristic) - } - } + self.instanceRegistration = peripheral.registerOnChangeCharacteristicHandler( + service: serviceId, + characteristic: characteristicId + ) { [weak self] characteristic in + self?.handleChangedCharacteristic(characteristic) } } private func registerCharacteristicValueChanges() { - self.valueRegistration = peripheral.assumeIsolated { peripheral in - peripheral.registerOnChangeHandler(service: serviceId, characteristic: characteristicId) { [weak self] data in - guard let self = self else { - return - } - self.assertIsolated("BluetoothPeripheral onChange handler was unexpectedly executed outside the peripheral actor!") - self.assumeIsolated { injection in - injection.handleUpdatedValue(data) - } + self.valueRegistration = peripheral.registerOnChangeHandler(service: serviceId, characteristic: characteristicId) { [weak self] data in + Task {@SpeziBluetooth [weak self] in + self?.handleUpdatedValue(data) } } } @@ -196,7 +158,7 @@ actor CharacteristicPeripheralInjection: BluetoothActor { } if self.characteristic != characteristic { - self.characteristic = characteristic + state.characteristic = characteristic } if instanceChanged { @@ -206,7 +168,7 @@ actor CharacteristicPeripheralInjection: BluetoothActor { } } else { // we must make sure to not override the default value if one is present - self.value = nil + state.value = nil } } } @@ -216,9 +178,7 @@ actor CharacteristicPeripheralInjection: BluetoothActor { return } - decodable.assumeIsolated { decodable in - decodable.handleUpdateValueAssumingIsolation(data) - } + decodable.handleUpdateValueAssumingIsolation(data) } @@ -236,13 +196,13 @@ extension CharacteristicPeripheralInjection: DecodableCharacteristic where Value return } - self.value = value + state.value = value self.fullFillControlPointRequest(value) self.subscriptions.notifySubscribers(with: value, ignoring: nonInitialChangeHandlers ?? []) nonInitialChangeHandlers = nil } else { - self.value = nil + state.value = nil } } } @@ -291,6 +251,7 @@ extension CharacteristicPeripheralInjection where Value: ByteDecodable { throw BluetoothError.incompatibleDataFormat } + state.value = value // ensure we are consistent after returning return value } } @@ -304,7 +265,7 @@ extension CharacteristicPeripheralInjection where Value: ByteEncodable { let data = encodeValue(value) try await peripheral.write(data: data, for: characteristic) - self.value = value + state.value = value } func writeWithoutResponse(_ value: Value) async throws { @@ -314,7 +275,7 @@ extension CharacteristicPeripheralInjection where Value: ByteEncodable { let data = encodeValue(value) await peripheral.writeWithoutResponse(data: data, for: characteristic) - self.value = value + state.value = value } } @@ -327,9 +288,9 @@ extension CharacteristicPeripheralInjection where Value: ControlPointCharacteris } if !characteristic.isNotifying { // shortcut that doesn't require actor isolation. - // It takes some time for the characteristic to acknowledge notification registration. Assuming the characteristic was injected, - // and notifications were requests is good enough for us to assume we will receive the notification. Allows to send request much earlier. - guard await peripheral.didRequestNotifications(serviceId: serviceId, characteristicId: characteristicId) else { + // It takes some time for the characteristic to acknowledge notification registration. Assuming the characteristic was injected + // and notifications were requested, is good enough for us to assume we will receive the notification. Allows to send request much earlier. + guard peripheral.didRequestNotifications(serviceId: serviceId, characteristicId: characteristicId) else { throw BluetoothError.controlPointRequiresNotifying(service: serviceId, characteristic: characteristicId) } } @@ -359,7 +320,7 @@ extension CharacteristicPeripheralInjection where Value: ControlPointCharacteris throw error } - async let _ = withTimeout(of: timeout) { + async let _ = withTimeout(of: timeout) { @SpeziBluetooth in transaction.signalTimeout() } @@ -372,7 +333,9 @@ extension CharacteristicPeripheralInjection where Value: ControlPointCharacteris transaction.assignContinuation(continuation) } } onCancel: { - transaction.signalCancellation() + Task { @SpeziBluetooth in + transaction.signalCancellation() + } } } } diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicTestInjections.swift b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicTestInjections.swift new file mode 100644 index 00000000..081dfa60 --- /dev/null +++ b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicTestInjections.swift @@ -0,0 +1,90 @@ +// +// 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 Foundation + + +final class CharacteristicTestInjections: Sendable { + private nonisolated(unsafe) var _writeClosure: ((Value, WriteType) async throws -> Void)? + private nonisolated(unsafe) var _readClosure: (() async throws -> Value)? + private nonisolated(unsafe) var _requestClosure: ((Value) async throws -> Value)? + private nonisolated(unsafe) var _subscriptions: ChangeSubscriptions? + private nonisolated(unsafe) var _simulatePeripheral = false + private let lock = NSLock() + + var writeClosure: ((Value, WriteType) async throws -> Void)? { + get { + lock.withLock { + _writeClosure + } + } + set { + lock.withLock { + _writeClosure = newValue + } + } + } + + var readClosure: (() async throws -> Value)? { + get { + lock.withLock { + _readClosure + } + } + set { + lock.withLock { + _readClosure = newValue + } + } + } + + var requestClosure: ((Value) async throws -> Value)? { + get { + lock.withLock { + _requestClosure + } + } + set { + lock.withLock { + _requestClosure = newValue + } + } + } + + var subscriptions: ChangeSubscriptions? { + get { + lock.withLock { + _subscriptions + } + } + set { + lock.withLock { + _subscriptions = newValue + } + } + } + + var simulatePeripheral: Bool { + get { + lock.withLock { + _simulatePeripheral + } + } + set { + lock.withLock { + _simulatePeripheral = newValue + } + } + } + + init() {} + + func enableSubscriptions() { + subscriptions = ChangeSubscriptions() + } +} diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceActionAccessor.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceActionAccessor.swift index cd3ff595..4f3f2610 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceActionAccessor.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceActionAccessor.swift @@ -8,11 +8,11 @@ /// Interact with a Device Action. -public struct DeviceActionAccessor { - private let _injectedClosure: Box +public struct DeviceActionAccessor { + private let storage: DeviceAction.Storage - init(_ injectedClosure: Box) { - self._injectedClosure = injectedClosure + init(_ storage: DeviceAction.Storage) { + self.storage = storage } @@ -23,10 +23,10 @@ public struct DeviceActionAccessor { /// /// - Parameter action: The action to inject. @_spi(TestingSupport) - public func inject(_ action: ClosureType) { - _injectedClosure.value = action + public func inject(_ action: Action.ClosureType) { + storage.testInjections.storeIfNilThenLoad(.init()).injectedClosure = action } } -extension DeviceActionAccessor: @unchecked Sendable {} +extension DeviceActionAccessor: Sendable {} diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceActionPeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceActionPeripheralInjection.swift index e9f28ac8..9e0e8f10 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceActionPeripheralInjection.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceActionPeripheralInjection.swift @@ -7,7 +7,7 @@ // -class DeviceActionPeripheralInjection { +final class DeviceActionPeripheralInjection: Sendable { private let bluetooth: Bluetooth let peripheral: BluetoothPeripheral diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceActionTestInjections.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceActionTestInjections.swift new file mode 100644 index 00000000..38fcfe23 --- /dev/null +++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceActionTestInjections.swift @@ -0,0 +1,30 @@ +// +// 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 Foundation + + +final class DeviceActionTestInjections: Sendable { + private nonisolated(unsafe) var _injectedClosure: ClosureType? + private let lock = NSLock() // protects property above + + var injectedClosure: ClosureType? { + get { + lock.withLock { + _injectedClosure + } + } + set { + lock.withLock { + _injectedClosure = newValue + } + } + } + + init() {} +} diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift index 68610afc..56ad710f 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift @@ -6,57 +6,6 @@ // SPDX-License-Identifier: MIT // -import Foundation -import Spezi - - -struct DeviceStateTestInjections: DefaultInitializable { - var subscriptions: ChangeSubscriptions? - - init() {} - - mutating func enableSubscriptions() { - // there is no BluetoothManager, so we need to create a queue on the fly - subscriptions = ChangeSubscriptions( - queue: DispatchSerialQueue(label: "edu.stanford.spezi.bluetooth.testing-\(Self.self)", qos: .userInitiated) - ) - } - - func artificialValue(for keyPath: KeyPath) -> Value? { - // swiftlint:disable:previous cyclomatic_complexity - - let value: Any? = switch keyPath { - case \.id: - nil // we cannot provide a stable id? - case \.name, \.localName: - Optional.none as Any - case \.state: - PeripheralState.disconnected - case \.advertisementData: - AdvertisementData([:]) - case \.rssi: - Int(UInt8.max) - case \.nearby: - false - case \.lastActivity: - Date.now - case \.services: - Optional<[GATTService]>.none as Any - default: - nil - } - - guard let value else { - return nil - } - - guard let value = value as? Value else { - preconditionFailure("Default value \(value) was not the expected type for \(keyPath)") - } - return value - } -} - /// Interact with a given device state. /// @@ -65,29 +14,13 @@ struct DeviceStateTestInjections: DefaultInitializable { /// ## Topics /// /// ### Get notified about changes -/// - ``onChange(initial:perform:)-8x9cj`` -/// - ``onChange(initial:perform:)-9igc9`` -public struct DeviceStateAccessor { - private let id: ObjectIdentifier - private let keyPath: KeyPath - private let injection: DeviceStatePeripheralInjection? - /// To support testing support. - private let _injectedValue: ObservableBox - private let _testInjections: Box?> - - - init( - id: ObjectIdentifier, - keyPath: KeyPath, - injection: DeviceStatePeripheralInjection?, - injectedValue: ObservableBox, - testInjections: Box?> - ) { - self.id = id - self.keyPath = keyPath - self.injection = injection - self._injectedValue = injectedValue - self._testInjections = testInjections +/// - ``onChange(initial:perform:)-819sg`` +/// - ``onChange(initial:perform:)-76kjp`` +public struct DeviceStateAccessor { + private let storage: DeviceState.Storage + + init(_ storage: DeviceState.Storage) { + self.storage = storage } } @@ -97,11 +30,11 @@ extension DeviceStateAccessor { /// /// This property creates an AsyncStream that yields all future updates to the device state. public var subscription: AsyncStream { - if let subscriptions = _testInjections.value?.subscriptions { + if let subscriptions = storage.testInjections.load()?.subscriptions { return subscriptions.newSubscription() } - guard let injection else { + guard let injection = storage.injection.load() else { preconditionFailure( "The `subscription` of a @DeviceState cannot be accessed within the initializer. Defer access to the `configure() method" ) @@ -120,14 +53,14 @@ extension DeviceStateAccessor { /// all your handlers. /// - Important: You must capture `self` weakly only. Capturing `self` strongly causes a memory leak. /// - /// - Note: This closure is called from the Bluetooth Serial Executor, if you don't pass in an async method + /// - 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). /// /// - Parameters: /// - 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. - public func onChange(initial: Bool = false, perform action: @escaping (Value) async -> Void) { + public func onChange(initial: Bool = false, perform action: @escaping @Sendable (Value) async -> Void) { onChange(initial: true) { _, newValue in await action(newValue) } @@ -144,26 +77,26 @@ extension DeviceStateAccessor { /// all your handlers. /// - Important: You must capture `self` weakly only. Capturing `self` strongly causes a memory leak. /// - /// - Note: This closure is called from the Bluetooth Serial Executor, if you don't pass in an async method + /// - 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). /// /// - Parameters: /// - 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 (_ oldValue: Value, _ newValue: Value) async -> Void) { - if let testInjections = _testInjections.value, + 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) - if initial, let value = _injectedValue.value ?? testInjections.artificialValue(for: keyPath) { + if initial, let value = testInjections.injectedValue ?? DeviceStateTestInjections.artificialValue(for: storage.keyPath) { // if there isn't a value already, initial won't work properly with injections subscriptions.notifySubscriber(id: id, with: value) } return } - guard let injection else { + guard let injection = storage.injection.load() else { preconditionFailure( """ Register onChange(perform:) inside the initializer is not supported anymore. \ @@ -184,7 +117,7 @@ extension DeviceStateAccessor { } -extension DeviceStateAccessor: @unchecked Sendable {} +extension DeviceStateAccessor: Sendable {} // MARK: - Testing Support @@ -197,7 +130,7 @@ extension DeviceStateAccessor { /// will be stored and called when injecting new values via `inject(_:)`. /// - Note: Make sure to inject a initial value if you want to make the `initial` property work properly public func enableSubscriptions() { - _testInjections.valueOrInitialize.enableSubscriptions() + storage.testInjections.storeIfNilThenLoad(.init()).enableSubscriptions() } /// Inject a custom value for previewing purposes. @@ -210,9 +143,10 @@ extension DeviceStateAccessor { /// /// - Parameter value: The value to inject. public func inject(_ value: Value) { - _injectedValue.value = value + let injections = storage.testInjections.storeIfNilThenLoad(.init()) + injections.injectedValue = value - if let subscriptions = _testInjections.value?.subscriptions { + if let subscriptions = injections.subscriptions { subscriptions.notifySubscribers(with: value) } } diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift index 100a9495..d56b7c07 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift @@ -9,12 +9,11 @@ import Foundation -actor DeviceStatePeripheralInjection: BluetoothActor { - let bluetoothQueue: DispatchSerialQueue - +@SpeziBluetooth +class DeviceStatePeripheralInjection: Sendable { private let bluetooth: Bluetooth - private let peripheral: BluetoothPeripheral - private let accessKeyPath: KeyPath + let peripheral: BluetoothPeripheral + private let accessKeyPath: DeviceState.KeyPathType private let observationKeyPath: KeyPath? private let subscriptions: ChangeSubscriptions @@ -23,13 +22,12 @@ actor DeviceStatePeripheralInjection: BluetoothActor { } - init(bluetooth: Bluetooth, peripheral: BluetoothPeripheral, keyPath: KeyPath) { + init(bluetooth: Bluetooth, peripheral: BluetoothPeripheral, keyPath: DeviceState.KeyPathType) { self.bluetooth = bluetooth - self.bluetoothQueue = peripheral.bluetoothQueue self.peripheral = peripheral self.accessKeyPath = keyPath self.observationKeyPath = keyPath.storageEquivalent() - self.subscriptions = ChangeSubscriptions(queue: peripheral.bluetoothQueue) + self.subscriptions = ChangeSubscriptions() } func setup() { @@ -41,18 +39,13 @@ actor DeviceStatePeripheralInjection: BluetoothActor { return } - peripheral.assumeIsolated { peripheral in - peripheral.onChange(of: observationKeyPath) { [weak self] value in - guard let self = self else { - return - } - - self.assumeIsolated { injection in - injection.trackStateUpdate() - - self.subscriptions.notifySubscribers(with: value) - } + peripheral.onChange(of: observationKeyPath) { [weak self] value in + guard let self = self else { + return } + + self.trackStateUpdate() + self.subscriptions.notifySubscribers(with: value) } } @@ -60,7 +53,10 @@ actor DeviceStatePeripheralInjection: BluetoothActor { subscriptions.newSubscription() } - nonisolated func newOnChangeSubscription(initial: Bool, perform action: @escaping (_ oldValue: Value, _ newValue: Value) async -> Void) { + nonisolated func newOnChangeSubscription( + initial: Bool, + perform action: @escaping @Sendable (_ oldValue: Value, _ newValue: Value) async -> Void + ) { let id = subscriptions.newOnChangeSubscription(perform: action) if initial { @@ -76,21 +72,17 @@ actor DeviceStatePeripheralInjection: BluetoothActor { extension KeyPath where Root == BluetoothPeripheral { - // swiftlint:disable:next cyclomatic_complexity + @SpeziBluetooth func storageEquivalent() -> KeyPath? { let anyKeyPath: AnyKeyPath? = switch self { case \.name: \PeripheralStorage.name - case \.localName: - \PeripheralStorage.localName case \.rssi: \PeripheralStorage.rssi case \.advertisementData: \PeripheralStorage.advertisementData case \.state: \PeripheralStorage.state - case \.services: - \PeripheralStorage.services case \.nearby: \PeripheralStorage.nearby case \.lastActivity: diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateTestInjections.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateTestInjections.swift new file mode 100644 index 00000000..447e89ae --- /dev/null +++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateTestInjections.swift @@ -0,0 +1,80 @@ +// +// 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 Foundation + + +@Observable +final class DeviceStateTestInjections: Sendable { + @ObservationIgnored private nonisolated(unsafe) var _subscriptions: ChangeSubscriptions? + @ObservationIgnored private nonisolated(unsafe) var _injectedValue: Value? + private let lock = NSLock() // protects both properties above + + var subscriptions: ChangeSubscriptions? { + get { + lock.withLock { + _subscriptions + } + } + set { + lock.withLock { + _subscriptions = newValue + } + } + } + + var injectedValue: Value? { + get { + access(keyPath: \.injectedValue) + return lock.withLock { + _injectedValue + } + } + set { + withMutation(keyPath: \.injectedValue) { + lock.withLock { + _injectedValue = newValue + } + } + } + } + + static func artificialValue(for keyPath: KeyPath) -> Value? { + let value: Any? = switch keyPath { + case \.id: + nil // we cannot provide a stable id? + case \.name: + Optional.none as Any + case \.state: + PeripheralState.disconnected + case \.advertisementData: + AdvertisementData([:]) + case \.rssi: + Int(UInt8.max) + case \.nearby: + false + case \.lastActivity: + Date.now + default: + nil + } + + guard let value else { + return nil + } + + guard let value = value as? Value else { + preconditionFailure("Default value \(value) was not the expected type for \(keyPath)") + } + return value + } + + func enableSubscriptions() { + subscriptions = ChangeSubscriptions() + } +} diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/ServiceAccessor.swift b/Sources/SpeziBluetooth/Model/PropertySupport/ServiceAccessor.swift index bf1a3fc3..d4406172 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/ServiceAccessor.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/ServiceAccessor.swift @@ -6,8 +6,6 @@ // SPDX-License-Identifier: MIT // -@preconcurrency import CoreBluetooth - /// Interact with a given Service. /// @@ -22,30 +20,25 @@ /// ### Service properties /// - ``isPresent`` /// - ``isPrimary`` -public struct ServiceAccessor { - private let id: CBUUID - private let injection: ServicePeripheralInjection? - /// Capture of the service. - private let service: GATTService? +public struct ServiceAccessor { + private let serviceState: Service.State.ServiceState /// Determine if the service is available. /// /// Returns `true` if the device is connected and the service is available and discovered. public var isPresent: Bool { - service != nil + serviceState != .notPresent } /// The type of the service (primary or secondary). /// /// Returns `false` if service is not available. public var isPrimary: Bool { - service?.isPrimary == true + serviceState == .presentPrimary } - init(id: CBUUID, injection: ServicePeripheralInjection?) { - self.id = id - self.injection = injection - self.service = injection?.unsafeService + init(_ storage: Service.Storage) { + self.serviceState = storage.state.serviceState } } diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/ServicePeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/ServicePeripheralInjection.swift index 9efa7de6..06ec5ea2 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/ServicePeripheralInjection.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/ServicePeripheralInjection.swift @@ -6,38 +6,27 @@ // SPDX-License-Identifier: MIT // -import CoreBluetooth +@SpeziBluetooth +class ServicePeripheralInjection: Sendable { + private let bluetooth: Bluetooth + let peripheral: BluetoothPeripheral + private let serviceId: BTUUID + private let state: Service.State -actor ServicePeripheralInjection: BluetoothActor { - let bluetoothQueue: DispatchSerialQueue - - private let peripheral: BluetoothPeripheral - private let serviceId: CBUUID - - /// Do not access directly. - private let _service: WeakObservableBox - - - private(set) var service: GATTService? { - get { - _service.value - } - set { - _service.value = newValue + private weak var service: GATTService? { + didSet { + state.serviceState = .init(from: service) } } - nonisolated var unsafeService: GATTService? { - _service.value - } - - init(peripheral: BluetoothPeripheral, serviceId: CBUUID, service: GATTService?) { - self.bluetoothQueue = peripheral.bluetoothQueue + init(bluetooth: Bluetooth, peripheral: BluetoothPeripheral, serviceId: BTUUID, service: GATTService?, state: Service.State) { + self.bluetooth = bluetooth self.peripheral = peripheral self.serviceId = serviceId - self._service = WeakObservableBox(service) + self.state = state + self.service = service } func setup() { @@ -45,18 +34,18 @@ actor ServicePeripheralInjection: BluetoothActor { } private func trackServicesUpdate() { - peripheral.assumeIsolated { peripheral in - peripheral.onChange(of: \.services) { [weak self] services in - guard let self = self, - let service = services?.first(where: { $0.uuid == self.serviceId }) else { - return - } - - self.assumeIsolated { injection in - injection.trackServicesUpdate() - injection.service = service - } + peripheral.onChange(of: \.services) { [weak self] services in + guard let self = self, + let service = services?.first(where: { $0.uuid == self.serviceId }) else { + return } + + self.trackServicesUpdate() + self.service = service } } + + deinit { + bluetooth.notifyDeviceDeinit(for: peripheral.id) + } } diff --git a/Sources/SpeziBluetooth/Model/SemanticModel/DeviceDescriptionParser.swift b/Sources/SpeziBluetooth/Model/SemanticModel/DeviceDescriptionParser.swift index fa062ce8..b5ce18d5 100644 --- a/Sources/SpeziBluetooth/Model/SemanticModel/DeviceDescriptionParser.swift +++ b/Sources/SpeziBluetooth/Model/SemanticModel/DeviceDescriptionParser.swift @@ -30,6 +30,7 @@ private struct ServiceDescriptionBuilder: DeviceVisitor { extension BluetoothDevice { + @SpeziBluetooth static func parseDeviceDescription() -> DeviceDescription { let device = Self() @@ -41,6 +42,7 @@ extension BluetoothDevice { extension DeviceDiscoveryDescriptor { + @SpeziBluetooth func parseDiscoveryDescription() -> DiscoveryDescription { let deviceDescription = deviceType.parseDeviceDescription() return DiscoveryDescription(discoverBy: discoveryCriteria, device: deviceDescription) @@ -55,6 +57,7 @@ extension Set where Element == DeviceDiscoveryDescriptor { } } + @SpeziBluetooth func parseDiscoveryDescription() -> Set { Set(map { $0.parseDiscoveryDescription() }) } diff --git a/Sources/SpeziBluetooth/Model/SemanticModel/SetupDeviceVisitor.swift b/Sources/SpeziBluetooth/Model/SemanticModel/SetupDeviceVisitor.swift index 12f484b7..0a0e4a86 100644 --- a/Sources/SpeziBluetooth/Model/SemanticModel/SetupDeviceVisitor.swift +++ b/Sources/SpeziBluetooth/Model/SemanticModel/SetupDeviceVisitor.swift @@ -9,15 +9,16 @@ import CoreBluetooth +@SpeziBluetooth private struct SetupServiceVisitor: ServiceVisitor { private let bluetooth: Bluetooth private let peripheral: BluetoothPeripheral - private let serviceId: CBUUID + private let serviceId: BTUUID private let service: GATTService? private let didInjectAnything: Box - init(bluetooth: Bluetooth, peripheral: BluetoothPeripheral, serviceId: CBUUID, service: GATTService?, didInjectAnything: Box) { + init(bluetooth: Bluetooth, peripheral: BluetoothPeripheral, serviceId: BTUUID, service: GATTService?, didInjectAnything: Box) { self.bluetooth = bluetooth self.peripheral = peripheral self.serviceId = serviceId @@ -42,6 +43,7 @@ private struct SetupServiceVisitor: ServiceVisitor { } +@SpeziBluetooth private struct SetupDeviceVisitor: DeviceVisitor { private let bluetooth: Bluetooth private let peripheral: BluetoothPeripheral @@ -56,8 +58,9 @@ private struct SetupDeviceVisitor: DeviceVisitor { func visit(_ service: Service) { - let blService = peripheral.assumeIsolated { $0.getService(id: service.id) } - service.inject(peripheral: peripheral, service: blService) + let blService = peripheral.getService(id: service.id) + service.inject(bluetooth: bluetooth, peripheral: peripheral, service: blService) + didInjectAnything.value = true var visitor = SetupServiceVisitor( bluetooth: bluetooth, @@ -82,9 +85,8 @@ private struct SetupDeviceVisitor: DeviceVisitor { extension BluetoothDevice { + @SpeziBluetooth func inject(peripheral: BluetoothPeripheral, using bluetooth: Bluetooth) -> Bool { - peripheral.bluetoothQueue.assertIsolated("SetupDeviceVisitor must be called within the Bluetooth SerialExecutor!") - // if we don't inject anything, we do not need to retain the device let didInjectAnything = Box(false) diff --git a/Sources/SpeziBluetooth/Model/Visitor/BaseVisitor.swift b/Sources/SpeziBluetooth/Model/Visitor/BaseVisitor.swift index bdb910a7..0f1345e6 100644 --- a/Sources/SpeziBluetooth/Model/Visitor/BaseVisitor.swift +++ b/Sources/SpeziBluetooth/Model/Visitor/BaseVisitor.swift @@ -7,6 +7,7 @@ // +@SpeziBluetooth protocol BaseVisitor { mutating func visit(_ action: DeviceAction) diff --git a/Sources/SpeziBluetooth/Model/Visitor/DeviceVisitor.swift b/Sources/SpeziBluetooth/Model/Visitor/DeviceVisitor.swift index 5a4a315f..8b8d9c45 100644 --- a/Sources/SpeziBluetooth/Model/Visitor/DeviceVisitor.swift +++ b/Sources/SpeziBluetooth/Model/Visitor/DeviceVisitor.swift @@ -8,16 +8,19 @@ protocol DeviceVisitable { + @SpeziBluetooth func accept(_ visitor: inout Visitor) } +@SpeziBluetooth protocol DeviceVisitor: BaseVisitor { mutating func visit(_ service: Service) } extension BluetoothDevice { + @SpeziBluetooth func accept(_ visitor: inout Visitor) { let mirror = Mirror(reflecting: self) for (_, child) in mirror.children { diff --git a/Sources/SpeziBluetooth/Model/Visitor/ServiceVisitor.swift b/Sources/SpeziBluetooth/Model/Visitor/ServiceVisitor.swift index fdd31e79..34a2d538 100644 --- a/Sources/SpeziBluetooth/Model/Visitor/ServiceVisitor.swift +++ b/Sources/SpeziBluetooth/Model/Visitor/ServiceVisitor.swift @@ -8,16 +8,19 @@ protocol ServiceVisitable { + @SpeziBluetooth func accept(_ visitor: inout Visitor) } +@SpeziBluetooth protocol ServiceVisitor: BaseVisitor { mutating func visit(_ characteristic: Characteristic) } extension BluetoothService { + @SpeziBluetooth func accept(_ visitor: inout Visitor) { let mirror = Mirror(reflecting: self) for (_, child) in mirror.children { diff --git a/Sources/SpeziBluetooth/Modifier/AutoConnectModifier.swift b/Sources/SpeziBluetooth/Modifier/AutoConnectModifier.swift index 4fe3e007..0170665c 100644 --- a/Sources/SpeziBluetooth/Modifier/AutoConnectModifier.swift +++ b/Sources/SpeziBluetooth/Modifier/AutoConnectModifier.swift @@ -28,6 +28,7 @@ extension View { /// - advertisementStaleInterval: The time interval after which a peripheral advertisement is considered stale /// if we don't hear back from the device. Minimum is 1 second. Supply `nil` to use default the default value or a value from the environment. /// - Returns: The modified view. + @MainActor public func autoConnect( // swiftlint:disable:this function_default_parameter_at_end enabled: Bool = true, with bluetooth: Bluetooth, @@ -58,6 +59,7 @@ extension View { /// - advertisementStaleInterval: The time interval after which a peripheral advertisement is considered stale /// if we don't hear back from the device. Minimum is 1 second. Supply `nil` to use default the default value or a value from the environment. /// - Returns: The modified view. + @MainActor public func autoConnect( // swiftlint:disable:this function_default_parameter_at_end enabled: Bool = true, with bluetoothManager: BluetoothManager, diff --git a/Sources/SpeziBluetooth/Modifier/BluetoothScanner.swift b/Sources/SpeziBluetooth/Modifier/BluetoothScanner.swift index ad2728c1..a191a582 100644 --- a/Sources/SpeziBluetooth/Modifier/BluetoothScanner.swift +++ b/Sources/SpeziBluetooth/Modifier/BluetoothScanner.swift @@ -9,7 +9,7 @@ import Foundation -protocol BluetoothScanningState: Equatable { +protocol BluetoothScanningState: Equatable, Sendable { /// Merge with another state. Order should not matter in the operation. /// - Parameter other: The other state to merge with func merging(with other: Self) -> Self @@ -19,14 +19,14 @@ protocol BluetoothScanningState: Equatable { /// Any kind of Bluetooth Scanner. -protocol BluetoothScanner: Identifiable where ID: Hashable { +protocol BluetoothScanner: Identifiable, Sendable where ID: Hashable { /// Captures state required to start scanning. associatedtype ScanningState: BluetoothScanningState /// Indicates if there is at least one connected peripheral. /// /// Make sure this tracks observability of all devices. - var hasConnectedDevices: Bool { get } + @MainActor var hasConnectedDevices: Bool { get } /// Scan for nearby bluetooth devices. /// diff --git a/Sources/SpeziBluetooth/Modifier/BluetoothScanningOptionsModifier.swift b/Sources/SpeziBluetooth/Modifier/BluetoothScanningOptionsModifier.swift new file mode 100644 index 00000000..f959216f --- /dev/null +++ b/Sources/SpeziBluetooth/Modifier/BluetoothScanningOptionsModifier.swift @@ -0,0 +1,55 @@ +// +// 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 SwiftUI + + +private struct BluetoothScanningOptionsModifier: ViewModifier { + private let minimumRSSI: Int? + private let advertisementStaleInterval: TimeInterval? + + @Environment(\.minimumRSSI) + private var parentMinimumRSSI + @Environment(\.advertisementStaleInterval) + private var parentAdvertisementStaleInterval + + init(minimumRSSI: Int?, advertisementStaleInterval: TimeInterval?) { + self.minimumRSSI = minimumRSSI + self.advertisementStaleInterval = advertisementStaleInterval + } + + + func body(content: Content) -> some View { + content + .environment(\.minimumRSSI, minimumRSSI ?? parentMinimumRSSI) + .environment(\.advertisementStaleInterval, advertisementStaleInterval ?? parentAdvertisementStaleInterval) + } +} + + +extension View { + /// Define bluetooth scanning options in the view hierarchy. + /// + /// This view modifier can be used to set scanning options for the view hierarchy. + /// This will overwrite values passed to modifiers like + /// ``SwiftUI/View/scanNearbyDevices(enabled:with:discovery:minimumRSSI:advertisementStaleInterval:autoConnect:)``. + /// + /// ## Topics + /// ### Accessing Scanning Options + /// - ``SwiftUI/EnvironmentValues/minimumRSSI`` + /// - ``SwiftUI/EnvironmentValues/advertisementStaleInterval`` + /// + /// - Parameters: + /// - minimumRSSI: The minimum rssi a nearby peripheral must have to be considered nearby. Supply `nil` to use default the default value or a value from the environment. + /// - advertisementStaleInterval: The time interval after which a peripheral advertisement is considered stale + /// if we don't hear back from the device. Minimum is 1 second. Supply `nil` to use default the default value or a value from the environment. + public func bluetoothScanningOptions(minimumRSSI: Int? = nil, advertisementStaleInterval: TimeInterval? = nil) -> some View { + modifier(BluetoothScanningOptionsModifier(minimumRSSI: minimumRSSI, advertisementStaleInterval: advertisementStaleInterval)) + } +} diff --git a/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift b/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift index 73ac328b..f4405ac8 100644 --- a/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift +++ b/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift @@ -53,13 +53,14 @@ struct ConnectedDevicesEnvironmentModifier: ViewModifier { extension BluetoothDevice { - fileprivate static var deviceEnvironmentModifier: any ViewModifier { + @MainActor fileprivate static var deviceEnvironmentModifier: any ViewModifier { ConnectedDeviceEnvironmentModifier() } } extension Array where Element == any ViewModifier { + @MainActor fileprivate func modify(_ view: V) -> AnyView { var view = AnyView(view) for modifier in self { diff --git a/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md b/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md index 96383eac..1a2eccc0 100644 --- a/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md +++ b/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md @@ -75,8 +75,8 @@ Note that the value types needs to be optional and conform to [`ByteCodable`](https://swiftpackageindex.com/stanfordspezi/spezifileformats/documentation/bytecoding/bytecodable) respectively. ```swift -class DeviceInformationService: BluetoothService { - static let id = CBUUID(string: "180A") +struct DeviceInformationService: BluetoothService { + static let id: BTUUID = "180A" @Characteristic(id: "2A29") var manufacturer: String? @@ -237,8 +237,12 @@ class MyDevice: BluetoothDevice { // declare dependency to a configured Spezi Module @Dependency var measurements: Measurements - required init() { - weightScale.$weightMeasurement.onChange(perform: handleNewMeasurement) + required init() {} + + func configure() { + weightScale.$weightMeasurement.onChange { [weak self] value in + self?.handleNewMeasurement(value) + } } private func handleNewMeasurement(_ measurement: WeightMeasurement) { @@ -273,8 +277,7 @@ due to their async nature. - ``SwiftUI/View/scanNearbyDevices(enabled:with:minimumRSSI:advertisementStaleInterval:autoConnect:)`` - ``SwiftUI/View/autoConnect(enabled:with:minimumRSSI:advertisementStaleInterval:)`` -- ``SwiftUI/EnvironmentValues/minimumRSSI`` -- ``SwiftUI/EnvironmentValues/advertisementStaleInterval`` +- ``SwiftUI/View/bluetoothScanningOptions(minimumRSSI:advertisementStaleInterval:)`` - ``ConnectedDevices`` ### Declaring a Bluetooth Device @@ -286,6 +289,10 @@ due to their async nature. - ``DeviceState`` - ``DeviceAction`` +### Concurrency + +- ``SpeziBluetooth/SpeziBluetooth`` + ### Core Bluetooth - ``BluetoothManager`` @@ -298,6 +305,7 @@ due to their async nature. - ``AdvertisementData`` - ``ManufacturerIdentifier`` - ``WriteType`` +- ``BTUUID`` ### Configuring Core Bluetooth diff --git a/Sources/SpeziBluetooth/Utils/BTUUID.swift b/Sources/SpeziBluetooth/Utils/BTUUID.swift new file mode 100644 index 00000000..bec8ab19 --- /dev/null +++ b/Sources/SpeziBluetooth/Utils/BTUUID.swift @@ -0,0 +1,90 @@ +// +// 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 + + +/// A universally unique identifier, as defined by Bluetooth standards. +/// +/// The `BTUUID` type mirrors the functionality of the [`CBUUID`](https://developer.apple.com/documentation/corebluetooth/cbuuid) +/// class of CoreBluetooth. However, `BTUUID` is [`Sendable`](https://developer.apple.com/documentation/swift/sendable). +/// `CBUUID` is by its definition not `Sendable`. Not because of its implementation not-being thread-safe, but it being declared as an open class with the open properties +/// ``uuidString`` and ``data``. By wrapping the `CBUUID` type, and making sure no-one can inject a non-thread-safe sub-class, we create a effectively sendable version +/// of `CBUUID`. +public struct BTUUID { + /// The CoreBluetooth UUID. + public nonisolated(unsafe) let cbuuid: CBUUID + + /// The UUID represented as a string. + public var uuidString: String { + cbuuid.uuidString + } + + /// The data of the UUID. + public var data: Data { + cbuuid.data + } + + + /// Create a Bluetooth UUID from a 16-, 32-, or 128-bit UUID string. + /// - Parameter string: A string containing a 16-, 32-, or 128-bit UUID. + public init(string: String) { + self.cbuuid = CBUUID(string: string) + } + + /// Create a Bluetooth UUID from a 16-, 32-, or 128-bit UUID data container. + /// - Parameter data: Data containing a 16-, 32-, or 128-bit UUID. + public init(data: Data) { + self.cbuuid = CBUUID(data: data) + } + + /// Create a Bluetooth UUID from a UUID. + /// - Parameter nsuuid: The uuid. + public init(nsuuid: UUID) { + self.cbuuid = CBUUID(nsuuid: nsuuid) + } + + /// Create a Bluetooth UUID from a CoreBluetooth UUID. + /// - Parameter uuid: The CoreBluetooth UUID. + public init(from uuid: CBUUID) { + self.cbuuid = CBUUID(data: uuid.data) // this makes sure we do not accidentally inject a subclass + } +} + + +extension BTUUID: Sendable {} + + +extension BTUUID: ExpressibleByStringLiteral { + /// Create a Bluetooth UUID from a 16-, 32-, or 128-bit UUID string. + /// - Parameter value: A string containing a 16-, 32-, or 128-bit UUID. + public init(stringLiteral value: StringLiteralType) { + self.init(string: value) + } +} + + +extension BTUUID: Hashable {} + + +extension BTUUID: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { + cbuuid.description + } + + public var debugDescription: String { + cbuuid.debugDescription + } +} + + +extension CBUUID { + convenience init(from btuuid: BTUUID) { + self.init(data: btuuid.data) + } +} diff --git a/Sources/SpeziBluetooth/Utils/Box.swift b/Sources/SpeziBluetooth/Utils/Box.swift index 9c62a26d..dfbdb0c9 100644 --- a/Sources/SpeziBluetooth/Utils/Box.swift +++ b/Sources/SpeziBluetooth/Utils/Box.swift @@ -6,32 +6,10 @@ // SPDX-License-Identifier: MIT // -import Observation -import Spezi +import Foundation import SpeziFoundation -@Observable -class WeakObservableBox { - weak var value: Value? - - init(_ value: Value? = nil) { - self.value = value - } -} - - -@Observable -class ObservableBox { - var value: Value - - - init(_ value: Value) { - self.value = value - } -} - - class Box { var value: Value @@ -39,39 +17,3 @@ class Box { self.value = value } } - - -extension Box where Value: AnyOptional, Value.Wrapped: DefaultInitializable { - var valueOrInitialize: Value.Wrapped { - get { - if let value = value.unwrappedOptional { - return value - } - - let wrapped = Value.Wrapped() - value = wrappedToValue(wrapped) - return wrapped - } - _modify { - if var value = value.unwrappedOptional { - yield &value - self.value = wrappedToValue(value) - return - } - - var wrapped = Value.Wrapped() - yield &wrapped - self.value = wrappedToValue(wrapped) - } - set { - value = wrappedToValue(newValue) - } - } - - private func wrappedToValue(_ value: Value.Wrapped) -> Value { - guard let newValue = Optional.some(value) as? Value else { - preconditionFailure("Value of \(Optional.self) was not equal to \(Value.self).") - } - return newValue - } -} diff --git a/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift b/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift index 72d41593..f6bc050a 100644 --- a/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift +++ b/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift @@ -10,43 +10,35 @@ import Foundation import OrderedCollections -class ChangeSubscriptions: @unchecked Sendable { - private struct Registration { +final class ChangeSubscriptions: Sendable { + private struct Registration: Sendable { let subscription: AsyncStream let id: UUID } - private actor Dispatch: BluetoothActor { - let bluetoothQueue: DispatchSerialQueue + private nonisolated(unsafe) var continuations: OrderedDictionary.Continuation> = [:] + private let lock = RWLock() - init(_ bluetoothQueue: DispatchSerialQueue) { - self.bluetoothQueue = bluetoothQueue - } - } - - private let dispatch: Dispatch - private var continuations: OrderedDictionary.Continuation> = [:] - private var taskHandles: [UUID: Task] = [:] - private let lock = NSLock() - - init(queue: DispatchSerialQueue) { - self.dispatch = Dispatch(queue) - } + nonisolated init() {} func notifySubscribers(with value: Value, ignoring: Set = []) { - for (id, continuation) in continuations where !ignoring.contains(id) { - continuation.yield(value) + lock.withReadLock { + for (id, continuation) in continuations where !ignoring.contains(id) { + continuation.yield(value) + } } } func notifySubscriber(id: UUID, with value: Value) { - continuations[id]?.yield(value) + lock.withReadLock { + _ = continuations[id]?.yield(value) + } } - private func _newSubscription() -> Registration { + private nonisolated func _newSubscription() -> Registration { let id = UUID() let stream = AsyncStream { continuation in - self.lock.withLock { + lock.withWriteLock { self.continuations[id] = continuation } @@ -55,8 +47,10 @@ class ChangeSubscriptions: @unchecked Sendable { return } - lock.withLock { - _ = self.continuations.removeValue(forKey: id) + Task { @SpeziBluetooth in + self.lock.withWriteLock { + self.continuations.removeValue(forKey: id) + } } } } @@ -64,18 +58,18 @@ class ChangeSubscriptions: @unchecked Sendable { return Registration(subscription: stream, id: id) } - func newSubscription() -> AsyncStream { + nonisolated func newSubscription() -> AsyncStream { _newSubscription().subscription } @discardableResult - func newOnChangeSubscription(perform action: @escaping (_ 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. - let task = Task.detached { @SpeziBluetooth [weak self, dispatch] in + Task.detached { @SpeziBluetooth [weak self] in var currentValue: Value? for await element in registration.subscription { @@ -83,36 +77,24 @@ class ChangeSubscriptions: @unchecked Sendable { return } - await dispatch.isolated { _ in - await action(currentValue ?? element, element) - } + await action(currentValue ?? element, element) currentValue = element } - - self?.lock.withLock { - _ = self?.taskHandles.removeValue(forKey: registration.id) - } } - lock.withLock { - taskHandles[registration.id] = task - } + // There is no need to save this Task handle (makes it easier for use as we are in an non-isolated context right here). + // The task will automatically cleanup itself, once it the AsyncStream is getting cancelled/finished. return registration.id } deinit { - lock.withLock { + lock.withWriteLock { for continuation in continuations.values { continuation.finish() } - for task in taskHandles.values { - task.cancel() - } - continuations.removeAll() - taskHandles.removeAll() } } } diff --git a/Sources/SpeziBluetooth/Utils/ConnectedDevicesModel.swift b/Sources/SpeziBluetooth/Utils/ConnectedDevicesModel.swift index a99ae703..dd091a73 100644 --- a/Sources/SpeziBluetooth/Utils/ConnectedDevicesModel.swift +++ b/Sources/SpeziBluetooth/Utils/ConnectedDevicesModel.swift @@ -15,6 +15,8 @@ class ConnectedDevicesModel { /// We track the connected device for every BluetoothDevice type and index by peripheral identifier. @MainActor private var connectedDevices: [ObjectIdentifier: OrderedDictionary] = [:] + init() {} + @MainActor func update(with devices: [UUID: any BluetoothDevice]) { // remove devices that disconnected diff --git a/Sources/SpeziBluetooth/Utils/Lazy.swift b/Sources/SpeziBluetooth/Utils/Lazy.swift deleted file mode 100644 index 6cc7574d..00000000 --- a/Sources/SpeziBluetooth/Utils/Lazy.swift +++ /dev/null @@ -1,59 +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 -// - - -@propertyWrapper -class Lazy { - private var initializer: (() -> Value)? - private var onCleanup: (() -> Void)? - - private var storedValue: Value? - - - var wrappedValue: Value { - if let storedValue { - return storedValue - } - - guard let initializer else { - preconditionFailure("Forgot to initialize \(Self.self) lazy property!") - } - - let value = initializer() - storedValue = value - return value - } - - - var isInitialized: Bool { - storedValue != nil - } - - /// Support lazy initialization of lazy property. - init() {} - - - init(initializer: @escaping () -> Value, onCleanup: @escaping () -> Void = {}) { - self.initializer = initializer - self.onCleanup = onCleanup - } - - func supply(initializer: @escaping () -> Value, onCleanup: @escaping () -> Void = {}) { - self.initializer = initializer - self.onCleanup = onCleanup - } - - - func destroy() { - let wasStored = storedValue != nil - storedValue = nil - if wasStored, let onCleanup { - onCleanup() - } - } -} diff --git a/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOperand.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOperand.swift index cdcbc7c7..54d64d0e 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOperand.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOperand.swift @@ -19,7 +19,7 @@ import NIOCore /// The format of a operand is defined by the Service specification using the ``RecordAccessControlPoint`` characteristic. /// /// Refer to GATT Specification Supplement, 3.178.3 Operand field. -public protocol RecordAccessOperand: ByteEncodable { +public protocol RecordAccessOperand: ByteEncodable, Sendable { /// General Response representation. /// /// The operand format with the code ``RecordAccessOpCode/responseCode`` contains at least the information modeled with diff --git a/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessResponseFormatError.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessResponseFormatError.swift index cb82c3f8..a867e680 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessResponseFormatError.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessResponseFormatError.swift @@ -47,4 +47,7 @@ public struct RecordAccessResponseFormatError { } +extension RecordAccessResponseFormatError.Reason: Sendable {} + + extension RecordAccessResponseFormatError: Error {} diff --git a/Sources/SpeziBluetoothServices/Services/BatteryService.swift b/Sources/SpeziBluetoothServices/Services/BatteryService.swift index e63b1c03..1d654d19 100644 --- a/Sources/SpeziBluetoothServices/Services/BatteryService.swift +++ b/Sources/SpeziBluetoothServices/Services/BatteryService.swift @@ -6,7 +6,6 @@ // SPDX-License-Identifier: MIT // -import class CoreBluetooth.CBUUID import SpeziBluetooth @@ -14,8 +13,8 @@ import SpeziBluetooth /// /// This class partially implements the Bluetooth [Battery Service 1.1](https://www.bluetooth.com/specifications/specs/battery-service). /// - Note: The current implementation only implements mandatory characteristics. -public final class BatteryService: BluetoothService, @unchecked Sendable { - public static let id = CBUUID(string: "180F") +public struct BatteryService: BluetoothService, Sendable { + public static let id: BTUUID = "180F" /// Battery Level in percent. @@ -27,5 +26,6 @@ public final class BatteryService: BluetoothService, @unchecked Sendable { public var batteryLevel: UInt8? + /// Initialize a new Battery Service. public init() {} } diff --git a/Sources/SpeziBluetoothServices/Services/BloodPressureService.swift b/Sources/SpeziBluetoothServices/Services/BloodPressureService.swift index 61677751..b4c56eba 100644 --- a/Sources/SpeziBluetoothServices/Services/BloodPressureService.swift +++ b/Sources/SpeziBluetoothServices/Services/BloodPressureService.swift @@ -6,7 +6,6 @@ // SPDX-License-Identifier: MIT // -import class CoreBluetooth.CBUUID import SpeziBluetooth @@ -14,13 +13,13 @@ import SpeziBluetooth /// /// This class partially implements the Bluetooth [Blood Pressure Service 1.1](https://www.bluetooth.com/specifications/specs/blood-pressure-service-1-1-1). /// - Note: The Enhance Blood Pressure Service is currently not supported. -public final class BloodPressureService: BluetoothService, @unchecked Sendable { - public static let id = CBUUID(string: "1810") +public struct BloodPressureService: BluetoothService, Sendable { + public static let id: BTUUID = "1810" /// Receive blood pressure measurements /// /// - Note: This characteristic is required and indicate-only. - @Characteristic(id: "2A35", notify: true) + @Characteristic(id: "2A35", notify: true, autoRead: false) public var bloodPressureMeasurement: BloodPressureMeasurement? /// Describe supported features of the blood pressure cuff. @@ -36,5 +35,6 @@ public final class BloodPressureService: BluetoothService, @unchecked Sendable { public var intermediateCuffPressure: IntermediateCuffPressure? + /// Initialize a new Blood Pressure Service. public init() {} } diff --git a/Sources/SpeziBluetoothServices/Services/CurrentTimeService.swift b/Sources/SpeziBluetoothServices/Services/CurrentTimeService.swift index f6ca6b70..80c070eb 100644 --- a/Sources/SpeziBluetoothServices/Services/CurrentTimeService.swift +++ b/Sources/SpeziBluetoothServices/Services/CurrentTimeService.swift @@ -16,8 +16,8 @@ import SpeziBluetooth /// This class partially implements the Bluetooth [Current Time Service 1.1](https://www.bluetooth.com/specifications/specs/current-time-service-1-1). /// - Note: The Local Time Information and Reference Time Information characteristics are currently not implemented. /// Both are optional to implement for peripherals. -public final class CurrentTimeService: BluetoothService, @unchecked Sendable { - public static let id = CBUUID(string: "1805") +public struct CurrentTimeService: BluetoothService, Sendable { + public static let id: BTUUID = "1805" fileprivate static let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "CurrentTimeService") @@ -38,6 +38,7 @@ public final class CurrentTimeService: BluetoothService, @unchecked Sendable { public var currentTime: CurrentTime? + /// Initialize a new Current Time Service. public init() {} } @@ -51,14 +52,14 @@ extension CurrentTimeService { /// - Note: This method expects that the ``currentTime`` characteristic is current. /// - Parameters: /// - now: The `Date` which is perceived as now. - /// - threshold: The threshold in seconds used to decide if peripheral time should be updated. + /// - 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: TimeInterval = 1) { + public func synchronizeDeviceTime(now: Date = .now, threshold: Duration = .seconds(1)) { // we consider 1 second difference accurate enough // check if time update is necessary if let currentTime = currentTime, let deviceTime = currentTime.time.date { let difference = abs(deviceTime.timeIntervalSinceReferenceDate - now.timeIntervalSinceReferenceDate) - if difference < 1 { + if difference < threshold.timeInterval { return // we consider 1 second difference accurate enough } @@ -87,3 +88,14 @@ extension CurrentTimeService { } } } + + +extension Duration { + fileprivate var timeInterval: TimeInterval { + let components = self.components + + + let attosecondsInSeconds = Double(components.attoseconds) / 1_000_000_000_000_000_000.0 // 10^-18 + return Double(components.seconds) + attosecondsInSeconds + } +} diff --git a/Sources/SpeziBluetoothServices/Services/DeviceInformationService.swift b/Sources/SpeziBluetoothServices/Services/DeviceInformationService.swift index 584f74b8..460e844c 100644 --- a/Sources/SpeziBluetoothServices/Services/DeviceInformationService.swift +++ b/Sources/SpeziBluetoothServices/Services/DeviceInformationService.swift @@ -6,7 +6,6 @@ // SPDX-License-Identifier: MIT // -import class CoreBluetooth.CBUUID import Foundation import SpeziBluetooth @@ -17,8 +16,8 @@ import SpeziBluetooth /// All characteristics are read-only and optional to implement. /// It is possible that none are implemented at all. /// For more information refer to the specification. -public final class DeviceInformationService: BluetoothService, @unchecked Sendable { - public static let id = CBUUID(string: "180A") +public struct DeviceInformationService: BluetoothService, Sendable { + public static let id: BTUUID = "180A" /// The manufacturer name string. @Characteristic(id: "2A29") diff --git a/Sources/SpeziBluetoothServices/Services/HealthThermometerService.swift b/Sources/SpeziBluetoothServices/Services/HealthThermometerService.swift index 1e47701b..f73837f0 100644 --- a/Sources/SpeziBluetoothServices/Services/HealthThermometerService.swift +++ b/Sources/SpeziBluetoothServices/Services/HealthThermometerService.swift @@ -6,20 +6,19 @@ // SPDX-License-Identifier: MIT // -import class CoreBluetooth.CBUUID import SpeziBluetooth /// Bluetooth Health Thermometer Service implementation. /// /// This class implements the Bluetooth [Health Thermometer Service 1.0](https://www.bluetooth.com/specifications/specs/health-thermometer-service-1-0). -public final class HealthThermometerService: BluetoothService, @unchecked Sendable { - public static let id = CBUUID(string: "1809") +public struct HealthThermometerService: BluetoothService, Sendable { + public static let id: BTUUID = "1809" /// Receive temperature measurements. /// /// - Note: This characteristic is required and indicate-only. - @Characteristic(id: "2A1C", notify: true) + @Characteristic(id: "2A1C", notify: true, autoRead: false) public var temperatureMeasurement: TemperatureMeasurement? /// The body location of the temperature measurement. /// @@ -44,5 +43,6 @@ public final class HealthThermometerService: BluetoothService, @unchecked Sendab public var measurementInterval: MeasurementInterval? + /// Initialize a new Health Thermometer Service. public init() {} } diff --git a/Sources/SpeziBluetoothServices/Services/WeightScaleService.swift b/Sources/SpeziBluetoothServices/Services/WeightScaleService.swift index 175f2e74..eaa45ab9 100644 --- a/Sources/SpeziBluetoothServices/Services/WeightScaleService.swift +++ b/Sources/SpeziBluetoothServices/Services/WeightScaleService.swift @@ -6,20 +6,19 @@ // SPDX-License-Identifier: MIT // -import class CoreBluetooth.CBUUID import SpeziBluetooth /// Bluetooth Weight Scale Service implementation. /// /// This class implements the Bluetooth [Weight Scale Service 1.0](https://www.bluetooth.com/specifications/specs/weight-scale-service-1-0). -public final class WeightScaleService: BluetoothService, @unchecked Sendable { - public static let id = CBUUID(string: "181D") +public struct WeightScaleService: BluetoothService, Sendable { + public static let id: BTUUID = "181D" /// Receive weight measurements. /// /// - Note: This characteristic is required and indicate-only. - @Characteristic(id: "2A9D", notify: true) + @Characteristic(id: "2A9D", notify: true, autoRead: false) public var weightMeasurement: WeightMeasurement? /// Describe supported features and value resolutions of the weight scale. @@ -29,5 +28,6 @@ public final class WeightScaleService: BluetoothService, @unchecked Sendable { public var features: WeightScaleFeature? + /// Initialize a new Weight Scale Service. public init() {} } diff --git a/Sources/SpeziBluetoothServices/TestingSupport/CBUUID+Characteristics.swift b/Sources/SpeziBluetoothServices/TestingSupport/CBUUID+Characteristics.swift index b7d50c63..f82a9701 100644 --- a/Sources/SpeziBluetoothServices/TestingSupport/CBUUID+Characteristics.swift +++ b/Sources/SpeziBluetoothServices/TestingSupport/CBUUID+Characteristics.swift @@ -6,50 +6,50 @@ // SPDX-License-Identifier: MIT // -import CoreBluetooth +import SpeziBluetooth @_spi(TestingSupport) -extension CBUUID { +extension BTUUID { private static let prefix = "0000" private static let suffix = "-0000-1000-8000-00805F9B34FB" /// The test service. - public static var testService: CBUUID { + public static var testService: BTUUID { .uuid(ofCustom: "F001") } /// An event log of events of the test peripheral implementation. - public static var eventLogCharacteristic: CBUUID { + public static var eventLogCharacteristic: BTUUID { .uuid(ofCustom: "F002") } /// A string characteristic that you can read. - public static var readStringCharacteristic: CBUUID { + public static var readStringCharacteristic: BTUUID { .uuid(ofCustom: "F003") } /// A string characteristic that you can write. - public static var writeStringCharacteristic: CBUUID { + public static var writeStringCharacteristic: BTUUID { .uuid(ofCustom: "F004") } /// A string characteristic that you can read and write. - public static var readWriteStringCharacteristic: CBUUID { + public static var readWriteStringCharacteristic: BTUUID { .uuid(ofCustom: "F005") } /// Reset peripheral state to default settings. - public static var resetCharacteristic: CBUUID { + public static var resetCharacteristic: BTUUID { .uuid(ofCustom: "F006") } - private static func uuid(ofCustom: String) -> CBUUID { + private static func uuid(ofCustom: String) -> BTUUID { precondition(ofCustom.count == 4, "Unexpected length of \(ofCustom.count)") - return CBUUID(string: "\(prefix)\(ofCustom)\(suffix)") + return BTUUID(string: "\(prefix)\(ofCustom)\(suffix)") } /// Get a short uuid representation of your custom uuid base. /// - Parameter uuid: The uuid with the SpeziBluetooth base id. /// - Returns: Short uuid format. - public static func toCustomShort(_ uuid: CBUUID) -> String { + public static func toCustomShort(_ uuid: BTUUID) -> String { var string = uuid.uuidString assert(string.hasPrefix(prefix), "unexpected uuid format") assert(string.hasSuffix(suffix), "unexpected uuid format") diff --git a/Sources/SpeziBluetoothServices/TestingSupport/EventLog.swift b/Sources/SpeziBluetoothServices/TestingSupport/EventLog.swift index deaf0a27..4646ce0b 100644 --- a/Sources/SpeziBluetoothServices/TestingSupport/EventLog.swift +++ b/Sources/SpeziBluetoothServices/TestingSupport/EventLog.swift @@ -8,7 +8,7 @@ @_spi(TestingSupport) import ByteCoding -@preconcurrency import CoreBluetooth +import CoreBluetooth import NIO @_spi(TestingSupport) import SpeziBluetooth @@ -22,13 +22,13 @@ public enum EventLog { /// No event happened yet. case none /// Central subscribed to the notifications of the given characteristic. - case subscribedToNotification(_ characteristic: CBUUID) + case subscribedToNotification(_ characteristic: BTUUID) /// Central unsubscribed to the notifications of the given characteristic. - case unsubscribedToNotification(_ characteristic: CBUUID) + case unsubscribedToNotification(_ characteristic: BTUUID) /// The peripheral received a read request for the given characteristic. - case receivedRead(_ characteristic: CBUUID) + case receivedRead(_ characteristic: BTUUID) /// The peripheral received a write request for the given characteristic and data. - case receivedWrite(_ characteristic: CBUUID, value: Data) + case receivedWrite(_ characteristic: BTUUID, value: Data) } @@ -96,7 +96,7 @@ extension EventLog: ByteCodable { return nil } - let characteristic = CBUUID(data: data) + let characteristic = BTUUID(data: data) switch type { diff --git a/Sources/SpeziBluetoothServices/TestingSupport/TestService.swift b/Sources/SpeziBluetoothServices/TestingSupport/TestService.swift index 0de9bfa9..8598558d 100644 --- a/Sources/SpeziBluetoothServices/TestingSupport/TestService.swift +++ b/Sources/SpeziBluetoothServices/TestingSupport/TestService.swift @@ -11,8 +11,8 @@ import SpeziBluetooth @_spi(TestingSupport) -public final class TestService: BluetoothService, @unchecked Sendable { - public static let id: CBUUID = .testService +public struct TestService: BluetoothService, Sendable { + public static let id: BTUUID = .testService @Characteristic(id: .eventLogCharacteristic, notify: true) public var eventLog: EventLog? diff --git a/Sources/TestPeripheral/TestPeripheral.swift b/Sources/TestPeripheral/TestPeripheral.swift index 884a5377..03e8b3ad 100644 --- a/Sources/TestPeripheral/TestPeripheral.swift +++ b/Sources/TestPeripheral/TestPeripheral.swift @@ -15,7 +15,26 @@ import SpeziBluetoothServices @main -class TestPeripheral: NSObject, CBPeripheralManagerDelegate { +@MainActor +final class TestPeripheral: NSObject, CBPeripheralManagerDelegate { + @MainActor + class QueueUpdates: Sendable { + private var queuedUpdates: [CheckedContinuation] = [] + + func append(_ element: CheckedContinuation) { + queuedUpdates.append(element) + } + + func signalReady() { + let elements = queuedUpdates + queuedUpdates.removeAll() + + for element in elements { + element.resume() + } + } + } + private let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "TestPeripheral") private let dispatchQueue = DispatchQueue(label: "edu.stanford.spezi.bluetooth-peripheral", qos: .userInitiated) @@ -24,7 +43,7 @@ class TestPeripheral: NSObject, CBPeripheralManagerDelegate { private(set) var testService: TestService? private(set) var state: CBManagerState = .unknown - @MainActor private var queuedUpdates: [CheckedContinuation] = [] + private let queuedUpdates = QueueUpdates() override init() { super.init() @@ -69,23 +88,13 @@ class TestPeripheral: NSObject, CBPeripheralManagerDelegate { while !peripheralManager.updateValue(data, for: characteristic, onSubscribedCentrals: centrals) { // if false is returned, queue is full and we need to wait for flush signal. await withCheckedContinuation { continuation in - logger.warning("Peripheral update failed!") + logger.warning("Peripheral update failed! Queuing update operation until ready ...") queuedUpdates.append(continuation) } } } @MainActor - private func receiveManagerIsReady() { - logger.debug("Received manager is ready.") - let elements = queuedUpdates - queuedUpdates.removeAll() - - for element in elements { - element.resume() - } - } - private func addServices() { peripheralManager.removeAllServices() @@ -97,26 +106,31 @@ class TestPeripheral: NSObject, CBPeripheralManagerDelegate { // MARK: - CBPeripheralManagerDelegate - func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { - print("PeripheralManager state is now \("\(peripheral.state)")") - state = peripheral.state + nonisolated func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { + let state = peripheral.state + print("PeripheralManager state is now \("\(state)")") + MainActor.assumeIsolated { + self.state = state - if case .poweredOn = state { - addServices() + if case .poweredOn = state { + addServices() + } } } - func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) { + nonisolated func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) { if let error = error { logger.error("Error adding service \(service.uuid): \(error.localizedDescription)") return } print("Service \(service.uuid) was added!") - startAdvertising() + MainActor.assumeIsolated { + startAdvertising() + } } - func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) { + nonisolated func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) { if let error = error { logger.error("Error starting advertising: \(error.localizedDescription)") } else { @@ -126,49 +140,53 @@ class TestPeripheral: NSObject, CBPeripheralManagerDelegate { // MARK: - Interactions - func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) { - guard let testService else { + nonisolated func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) { + guard let testService = MainActor.assumeIsolated({ testService }) else { logger.error("Service was not available within \(#function)") return } + let id = BTUUID(from: characteristic.uuid) Task { @MainActor in - await testService.logEvent(.subscribedToNotification(characteristic.uuid)) + await testService.logEvent(.subscribedToNotification(id)) } } - func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) { - guard let testService else { + nonisolated func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) { + guard let testService = MainActor.assumeIsolated({ testService }) else { logger.error("Service was not available within \(#function)") return } + let id = BTUUID(from: characteristic.uuid) Task { @MainActor in - await testService.logEvent(.unsubscribedToNotification(characteristic.uuid)) + await testService.logEvent(.unsubscribedToNotification(id)) } } - func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) { - Task { @MainActor in - receiveManagerIsReady() + nonisolated func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) { + Task { @MainActor [logger, queuedUpdates] in + logger.debug("Received manager is ready. Processing queued updates.") + queuedUpdates.signalReady() } } - func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveRead request: CBATTRequest) { - guard let testService else { + nonisolated func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveRead request: CBATTRequest) { + guard let testService = MainActor.assumeIsolated({ testService }) else { logger.error("Service was not available within \(#function)") peripheral.respond(to: request, withResult: .attributeNotFound) return } - guard request.characteristic.service?.uuid == testService.service.uuid else { + guard request.characteristic.service?.uuid == MainActor.assumeIsolated({ BTUUID(from: testService.service.uuid) }).cbuuid else { logger.error("Received request for unexpected service \(request.characteristic.service?.uuid)") peripheral.respond(to: request, withResult: .attributeNotFound) return } + let id = BTUUID(from: request.characteristic.uuid) Task { @MainActor in - await testService.logEvent(.receivedRead(request.characteristic.uuid)) + await testService.logEvent(.receivedRead(id)) } guard request.offset == 0 else { @@ -178,26 +196,37 @@ class TestPeripheral: NSObject, CBPeripheralManagerDelegate { return } - Task { @MainActor in - let result = testService.handleRead(for: request) - peripheral.respond(to: request, withResult: result) + let result: CBATTError.Code + + let readResult = MainActor.assumeIsolated { + testService.handleRead(for: id) + } + + switch readResult { + case let .success(value): + request.value = value + result = .success + case let .failure(code): + result = code.code } + + peripheral.respond(to: request, withResult: result) } - func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) { + nonisolated func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) { guard let first = requests.first else { logger.error("Received invalid write request from the central. Zero elements!") return } - guard let testService else { + guard let testService = MainActor.assumeIsolated({ testService }) else { logger.error("Service was not available within \(#function)") peripheral.respond(to: first, withResult: .attributeNotFound) return } for request in requests { - guard request.characteristic.service?.uuid == testService.service.uuid else { + guard request.characteristic.service?.uuid == MainActor.assumeIsolated({ BTUUID(from: testService.service.uuid) }).cbuuid else { logger.error("Received request for unexpected service \(request.characteristic.service?.uuid)") peripheral.respond(to: first, withResult: .attributeNotFound) return @@ -209,8 +238,9 @@ class TestPeripheral: NSObject, CBPeripheralManagerDelegate { continue } + let id = BTUUID(from: request.characteristic.uuid) Task { @MainActor in - await testService.logEvent(.receivedWrite(request.characteristic.uuid, value: value)) + await testService.logEvent(.receivedWrite(id, value: value)) } } @@ -221,22 +251,22 @@ class TestPeripheral: NSObject, CBPeripheralManagerDelegate { return } - - Task { @MainActor in - // The following is mentioned in the docs: - // Always respond with the first request. - // Treat it as a multi request otherwise. - // If you can't fulfill a single one, don't fulfill any of them (we are not exactly supporting the transactions part of that). - for request in requests { - let result = testService.handleWrite(for: request) - - if result != .success { - peripheral.respond(to: first, withResult: result) - return - } + // The following is mentioned in the docs: + // Always respond with the first request. + // Treat it as a multi request otherwise. + // If you can't fulfill a single one, don't fulfill any of them (we are not exactly supporting the transactions part of that). + for request in requests { + let value = request.value + let id = BTUUID(from: request.characteristic.uuid) + let result = MainActor.assumeIsolated { + testService.handleWrite(value: value, characteristicId: id) } - peripheral.respond(to: first, withResult: .success) + if result != .success { + peripheral.respond(to: first, withResult: result) + return + } } + peripheral.respond(to: first, withResult: .success) } } diff --git a/Sources/TestPeripheral/TestService.swift b/Sources/TestPeripheral/TestService.swift index 4f42d7e0..74e07e0c 100644 --- a/Sources/TestPeripheral/TestService.swift +++ b/Sources/TestPeripheral/TestService.swift @@ -8,14 +8,27 @@ import CoreBluetooth import OSLog +import SpeziBluetooth @_spi(TestingSupport) import SpeziBluetoothServices -class TestService { - private let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "TestService") +struct ATTErrorCode: Error, Sendable { + let code: CBATTError.Code - private weak var peripheral: TestPeripheral? + init(_ code: CBATTError.Code) { + self.code = code + } +} + + +@MainActor +final class TestService: Sendable { + private var logger: Logger { + Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "TestService") + } + + private nonisolated(unsafe) weak var peripheral: TestPeripheral? let service: CBMutableService let eventLog: CBMutableCharacteristic @@ -37,30 +50,42 @@ class TestService { } private var lastEvent: EventLog = .none - private var readWriteStringValue: String + private var readWriteStringValue: String = "Hello Spezi" init(peripheral: TestPeripheral) { self.peripheral = peripheral - self.service = CBMutableService(type: .testService, primary: true) - - self.readWriteStringValue = "" + self.service = CBMutableService(type: BTUUID.testService.cbuuid, primary: true) - self.eventLog = CBMutableCharacteristic(type: .eventLogCharacteristic, properties: [.indicate, .read], value: nil, permissions: [.readable]) - self.readString = CBMutableCharacteristic(type: .readStringCharacteristic, properties: [.read], value: nil, permissions: [.readable]) - self.writeString = CBMutableCharacteristic(type: .writeStringCharacteristic, properties: [.write], value: nil, permissions: [.writeable]) + self.eventLog = CBMutableCharacteristic( + type: BTUUID.eventLogCharacteristic.cbuuid, + properties: [.indicate, .read], + value: nil, + permissions: [.readable] + ) + self.readString = CBMutableCharacteristic( + type: BTUUID.readStringCharacteristic.cbuuid, + properties: [.read], + value: nil, + permissions: [.readable] + ) + self.writeString = CBMutableCharacteristic( + type: BTUUID.writeStringCharacteristic.cbuuid, + properties: [.write], + value: nil, + permissions: [.writeable] + ) self.readWriteString = CBMutableCharacteristic( - type: .readWriteStringCharacteristic, + type: BTUUID.readWriteStringCharacteristic.cbuuid, properties: [.read, .write], value: nil, permissions: [.readable, .writeable] ) - self.reset = CBMutableCharacteristic(type: .resetCharacteristic, properties: [.write], value: nil, permissions: [.writeable]) + self.reset = CBMutableCharacteristic(type: BTUUID.resetCharacteristic.cbuuid, properties: [.write], value: nil, permissions: [.writeable]) service.characteristics = [eventLog, readString, writeString, readWriteString, reset] - - resetState() } + @MainActor private func resetState() { self.readStringCount = 1 self.readWriteStringValue = "Hello Spezi" @@ -80,31 +105,28 @@ class TestService { } @MainActor - func handleRead(for request: CBATTRequest) -> CBATTError.Code { - switch request.characteristic.uuid { + func handleRead(for uuid: BTUUID) -> Result { + switch uuid.cbuuid { case eventLog.uuid: - request.value = self.lastEvent.encode() + .success(self.lastEvent.encode()) case writeString.uuid, reset.uuid: - return .readNotPermitted + .failure(.init(.readNotPermitted)) case readString.uuid: - let value = readStringValue - request.value = value.encode() + .success(readStringValue.encode()) case readWriteString.uuid: - request.value = readWriteStringValue.encode() + .success(readWriteStringValue.encode()) default: - return .attributeNotFound + .failure(.init(.attributeNotFound)) } - - return .success } @MainActor - func handleWrite(for request: CBATTRequest) -> CBATTError.Code { - guard let value = request.value else { + func handleWrite(value: Data?, characteristicId: BTUUID) -> CBATTError.Code { + guard let value = value else { return .attributeNotFound } - switch request.characteristic.uuid { + switch characteristicId.cbuuid { case eventLog.uuid, readString.uuid: return .writeNotPermitted case writeString.uuid: diff --git a/Tests/BluetoothServicesTests/BloodPressureTests.swift b/Tests/SpeziBluetoothServicesTests/BloodPressureTests.swift similarity index 100% rename from Tests/BluetoothServicesTests/BloodPressureTests.swift rename to Tests/SpeziBluetoothServicesTests/BloodPressureTests.swift diff --git a/Tests/BluetoothServicesTests/BluetoothServicesTests.swift b/Tests/SpeziBluetoothServicesTests/BluetoothServicesTests.swift similarity index 93% rename from Tests/BluetoothServicesTests/BluetoothServicesTests.swift rename to Tests/SpeziBluetoothServicesTests/BluetoothServicesTests.swift index 1c38f843..d62b2f2a 100644 --- a/Tests/BluetoothServicesTests/BluetoothServicesTests.swift +++ b/Tests/SpeziBluetoothServicesTests/BluetoothServicesTests.swift @@ -6,7 +6,6 @@ // SPDX-License-Identifier: MIT // -import CoreBluetooth import NIO @_spi(TestingSupport) @testable import SpeziBluetooth @@ -28,7 +27,7 @@ final class BluetoothServicesTests: XCTestCase { } func testUUID() { - XCTAssertEqual(CBUUID.toCustomShort(.testService), "F001") + XCTAssertEqual(BTUUID.toCustomShort(.testService), "F001") } func testEventLog() throws { diff --git a/Tests/BluetoothServicesTests/CurrentTimeTests.swift b/Tests/SpeziBluetoothServicesTests/CurrentTimeTests.swift similarity index 99% rename from Tests/BluetoothServicesTests/CurrentTimeTests.swift rename to Tests/SpeziBluetoothServicesTests/CurrentTimeTests.swift index e3619427..df4334e5 100644 --- a/Tests/BluetoothServicesTests/CurrentTimeTests.swift +++ b/Tests/SpeziBluetoothServicesTests/CurrentTimeTests.swift @@ -56,7 +56,7 @@ final class CurrentTimeTests: XCTestCase { } let date = try XCTUnwrap(now.date) - service.synchronizeDeviceTime(now: date, threshold: 8) + service.synchronizeDeviceTime(now: date, threshold: .seconds(8)) await fulfillment(of: [writeExpectation]) try await Task.sleep(for: .milliseconds(500)) // let task complete diff --git a/Tests/BluetoothServicesTests/DeviceInformationTests.swift b/Tests/SpeziBluetoothServicesTests/DeviceInformationTests.swift similarity index 100% rename from Tests/BluetoothServicesTests/DeviceInformationTests.swift rename to Tests/SpeziBluetoothServicesTests/DeviceInformationTests.swift diff --git a/Tests/BluetoothServicesTests/HealthThermometerTests.swift b/Tests/SpeziBluetoothServicesTests/HealthThermometerTests.swift similarity index 100% rename from Tests/BluetoothServicesTests/HealthThermometerTests.swift rename to Tests/SpeziBluetoothServicesTests/HealthThermometerTests.swift diff --git a/Tests/SpeziBluetoothServicesTests/RWLockTests.swift b/Tests/SpeziBluetoothServicesTests/RWLockTests.swift new file mode 100644 index 00000000..7f874e99 --- /dev/null +++ b/Tests/SpeziBluetoothServicesTests/RWLockTests.swift @@ -0,0 +1,256 @@ +// +// 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 +// + +@testable import SpeziBluetooth +import XCTest + + +final class RWLockTests: XCTestCase { + func testConcurrentReads() { + let lock = RWLock() + let expectation1 = self.expectation(description: "First read") + let expectation2 = self.expectation(description: "Second read") + + Task.detached { + lock.withReadLock { + usleep(100_000) // Simulate read delay (200ms) + expectation1.fulfill() + } + } + + Task.detached { + lock.withReadLock { + usleep(100_000) // Simulate read delay (200ms) + expectation2.fulfill() + } + } + + wait(for: [expectation1, expectation2], timeout: 1.0) + } + + func testWriteBlocksOtherWrites() { + let lock = RWLock() + let expectation1 = self.expectation(description: "First write") + let expectation2 = self.expectation(description: "Second write") + + Task.detached { + lock.withWriteLock { + usleep(200_000) // Simulate write delay (200ms) + expectation1.fulfill() + } + } + + Task.detached { + try await Task.sleep(for: .milliseconds(100)) + lock.withWriteLock { + expectation2.fulfill() + } + } + + wait(for: [expectation1, expectation2], timeout: 1.0) + } + + func testWriteBlocksReads() { + let lock = RWLock() + let expectation1 = self.expectation(description: "Write") + let expectation2 = self.expectation(description: "Read") + + Task.detached { + lock.withWriteLock { + usleep(200_000) // Simulate write delay (200ms) + expectation1.fulfill() + } + } + + Task.detached { + try await Task.sleep(for: .milliseconds(100)) + lock.withReadLock { + expectation2.fulfill() + } + } + + wait(for: [expectation1, expectation2], timeout: 1.0) + } + + func testIsWriteLocked() { + let lock = RWLock() + + Task.detached { + lock.withWriteLock { + XCTAssertTrue(lock.isWriteLocked()) + usleep(100_000) // Simulate write delay (100ms) + } + } + + usleep(50_000) // Give the other thread time to lock (50ms) + XCTAssertFalse(lock.isWriteLocked()) + } + + func testMultipleLocksAcquired() { + let lock1 = RWLock() + let lock2 = RWLock() + let expectation1 = self.expectation(description: "Read") + + Task.detached { + lock1.withReadLock { + lock2.withReadLock { + expectation1.fulfill() + } + } + } + + wait(for: [expectation1], timeout: 1.0) + } + + + func testConcurrentReadsRecursive() { + let lock = RecursiveRWLock() + let expectation1 = self.expectation(description: "First read") + let expectation2 = self.expectation(description: "Second read") + + Task.detached { + lock.withReadLock { + usleep(100_000) // Simulate read delay 100 ms + expectation1.fulfill() + } + } + + Task.detached { + lock.withReadLock { + usleep(100_000) // Simulate read delay 100ms + expectation2.fulfill() + } + } + + wait(for: [expectation1, expectation2], timeout: 1.0) + } + + func testWriteBlocksOtherWritesRecursive() { + let lock = RecursiveRWLock() + let expectation1 = self.expectation(description: "First write") + let expectation2 = self.expectation(description: "Second write") + + Task.detached { + lock.withWriteLock { + usleep(200_000) // Simulate write delay 200ms + expectation1.fulfill() + } + } + + Task.detached { + try await Task.sleep(for: .milliseconds(100)) + lock.withWriteLock { + expectation2.fulfill() + } + } + + wait(for: [expectation1, expectation2], timeout: 1.0) + } + + func testWriteBlocksReadsRecursive() { + let lock = RecursiveRWLock() + let expectation1 = self.expectation(description: "Write") + let expectation2 = self.expectation(description: "Read") + + Task.detached { + lock.withWriteLock { + usleep(200_000) // Simulate write delay 200 ms + expectation1.fulfill() + } + } + + Task.detached { + try await Task.sleep(for: .milliseconds(100)) + lock.withReadLock { + expectation2.fulfill() + } + } + + wait(for: [expectation1, expectation2], timeout: 1.0) + } + + func testMultipleLocksAcquiredRecursive() { + let lock1 = RecursiveRWLock() + let lock2 = RecursiveRWLock() + let expectation1 = self.expectation(description: "Read") + + Task.detached { + lock1.withReadLock { + lock2.withReadLock { + expectation1.fulfill() + } + } + } + + wait(for: [expectation1], timeout: 1.0) + } + + func testRecursiveReadReadAcquisition() { + let lock = RecursiveRWLock() + let expectation1 = self.expectation(description: "Read") + + Task.detached { + lock.withReadLock { + lock.withReadLock { + expectation1.fulfill() + } + } + } + + wait(for: [expectation1], timeout: 1.0) + } + + func testRecursiveWriteRecursiveAcquisition() { + let lock = RecursiveRWLock() + let expectation1 = self.expectation(description: "Read") + let expectation2 = self.expectation(description: "ReadWrite") + let expectation3 = self.expectation(description: "WriteRead") + let expectation4 = self.expectation(description: "Write") + + let expectation5 = self.expectation(description: "Race") + + Task.detached { + lock.withWriteLock { + usleep(50_000) // Simulate write delay 50 ms + lock.withReadLock { + expectation1.fulfill() + usleep(200_000) // Simulate write delay 200 ms + lock.withWriteLock { + expectation2.fulfill() + } + } + + lock.withWriteLock { + usleep(200_000) // Simulate write delay 200 ms + lock.withReadLock { + expectation3.fulfill() + } + expectation4.fulfill() + } + } + } + + Task.detached { + await withDiscardingTaskGroup { group in + for _ in 0..<10 { + group.addTask { + // random sleep up to 50 ms + try? await Task.sleep(nanoseconds: UInt64.random(in: 0...50_000_000)) + lock.withWriteLock { + _ = usleep(100) + } + } + } + } + + expectation5.fulfill() + } + + wait(for: [expectation1, expectation2, expectation3, expectation4, expectation5], timeout: 20.0) + } +} diff --git a/Tests/BluetoothServicesTests/RecordAccessControlPointTests.swift b/Tests/SpeziBluetoothServicesTests/RecordAccessControlPointTests.swift similarity index 100% rename from Tests/BluetoothServicesTests/RecordAccessControlPointTests.swift rename to Tests/SpeziBluetoothServicesTests/RecordAccessControlPointTests.swift diff --git a/Tests/BluetoothServicesTests/WeightScaleTests.swift b/Tests/SpeziBluetoothServicesTests/WeightScaleTests.swift similarity index 100% rename from Tests/BluetoothServicesTests/WeightScaleTests.swift rename to Tests/SpeziBluetoothServicesTests/WeightScaleTests.swift diff --git a/Tests/SpeziBluetoothTests/BluetoothDeviceTestingSupportTests.swift b/Tests/SpeziBluetoothTests/BluetoothDeviceTestingSupportTests.swift new file mode 100644 index 00000000..0bb329c2 --- /dev/null +++ b/Tests/SpeziBluetoothTests/BluetoothDeviceTestingSupportTests.swift @@ -0,0 +1,195 @@ +// +// 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 +// + +@_spi(TestingSupport) +import SpeziBluetooth +import SpeziBluetoothServices +import XCTest + +final class MockDevice: BluetoothDevice, @unchecked Sendable { + @DeviceState(\.id) + var id + @DeviceState(\.name) + var name + @DeviceState(\.state) + var state + @DeviceState(\.rssi) + var rssi + @DeviceState(\.advertisementData) + var advertisementData + @DeviceState(\.nearby) + var nearby + @DeviceState(\.lastActivity) + var lastActivity + + + @DeviceAction(\.connect) + var connect + + @Service var deviceInformation = DeviceInformationService() +} + + +final class BluetoothDeviceTestingSupportTests: XCTestCase { + @MainActor + class Results { + var received: [Value] = [] + } + + func testDeviceStateInjectionArtificialValue() { + let device = MockDevice() + + XCTAssertNil(device.name) + XCTAssertEqual(device.state, .disconnected) + XCTAssertEqual(device.advertisementData, .init()) // empty + XCTAssertEqual(device.rssi, Int(UInt8.max)) + XCTAssertFalse(device.nearby) + + let now = Date.now + XCTAssert(device.lastActivity >= now) + } + + func testDeviceStateValueInjection() { + let device = MockDevice() + + let id = UUID() + device.$id.inject(id) + + XCTAssertEqual(device.id, id) + } + + @MainActor + func testDeviceStateOnChangeInjection() async throws { + let device = MockDevice() + + let id1 = UUID() + let id2 = UUID() + + device.$id.enableSubscriptions() + device.$id.inject(id1) + + let results = Results() + + device.$id.onChange(initial: true) { @MainActor value in + results.received.append(value) + } + + device.$id.inject(id2) + + try await Task.sleep(for: .milliseconds(200)) + + XCTAssertEqual(results.received, [id1, id2]) + } + + func testDeviceActionInjection() async throws { + let device = MockDevice() + + let expectation = XCTestExpectation(description: "closure") + + device.$connect.inject { + try? await Task.sleep(for: .milliseconds(10)) + expectation.fulfill() + } + + await device.connect() + + await fulfillment(of: [expectation], timeout: 0.1) + } + + func testCharacteristicInjection() { + let device = MockDevice() + + device.deviceInformation.$manufacturerName.inject("Hello World") + + XCTAssertEqual(device.deviceInformation.manufacturerName, "Hello World") + } + + @MainActor + func testCharacteristicOnChangeInjection() async throws { + let device = MockDevice() + + let service = device.deviceInformation + + let value1 = "Manufacturer1" + let value2 = "Manufacturer2" + let value3 = "Manufacturer3" + + service.$manufacturerName.enableSubscriptions() + service.$manufacturerName.inject(value1) + + let results = Results() + + service.$manufacturerName.onChange(initial: true) { @MainActor value in + results.received.append(value) + } + + service.$manufacturerName.inject(value2) + + try await Task.sleep(for: .milliseconds(50)) + service.$manufacturerName.inject(value3) + + try await Task.sleep(for: .milliseconds(50)) + + XCTAssertEqual(results.received, [value1, value2, value3]) + } + + func testCharacteristicPeripheralSimulation() async throws { + let device = MockDevice() + let service = device.deviceInformation + + let value1 = "Manufacturer1" + let value2 = "Manufacturer2" + let value3 = "Manufacturer3" + + service.$manufacturerName.enablePeripheralSimulation() + service.$manufacturerName.inject(value1) + + let read = try await service.$manufacturerName.read() + XCTAssertEqual(read, value1) + + try await service.$manufacturerName.write(value2) + XCTAssertEqual(service.manufacturerName, value2) + + try await service.$manufacturerName.writeWithoutResponse(value3) + XCTAssertEqual(service.manufacturerName, value3) + } + + func testCharacteristicClosureInjection() async throws { + let device = MockDevice() + let service = device.deviceInformation + + let value1 = "Manufacturer1" + let value2 = "Manufacturer2" + let value3 = "Manufacturer3" + + let writeExpectation = XCTestExpectation(description: "write") + let writeWithoutResponseExpectation = XCTestExpectation(description: "writeWithoutResponse") + + service.$manufacturerName.onRead { + value1 + } + service.$manufacturerName.onWrite { value, type in + switch type { + case .withResponse: + XCTAssertEqual(value, value2) + writeExpectation.fulfill() + case .withoutResponse: + XCTAssertEqual(value, value3) + writeWithoutResponseExpectation.fulfill() + } + } + + let read = try await service.$manufacturerName.read() + XCTAssertEqual(read, value1) + + try await service.$manufacturerName.write(value2) + try await service.$manufacturerName.writeWithoutResponse(value3) + + await fulfillment(of: [writeExpectation, writeWithoutResponseExpectation], timeout: 0.1) + } +} diff --git a/Tests/UITests/TestApp/Views/TestServiceView.swift b/Tests/UITests/TestApp/Views/TestServiceView.swift index fe9a358e..e9c691f4 100644 --- a/Tests/UITests/TestApp/Views/TestServiceView.swift +++ b/Tests/UITests/TestApp/Views/TestServiceView.swift @@ -8,7 +8,6 @@ @_spi(TestingSupport) import ByteCoding -import CoreBluetooth @_spi(TestingSupport) import SpeziBluetooth @_spi(TestingSupport) @@ -37,7 +36,7 @@ struct EventLogView: View { } private var characteristic: String? { - let characteristic: CBUUID? = switch log { + let characteristic: BTUUID? = switch log { case .none: nil case let .subscribedToNotification(characteristic): @@ -53,7 +52,7 @@ struct EventLogView: View { guard let characteristic else { return nil } - return CBUUID.toCustomShort(characteristic) + return BTUUID.toCustomShort(characteristic) } private var value: String? { diff --git a/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift b/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift index d952efca..7b4081bc 100644 --- a/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift +++ b/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT // -import CoreBluetooth +import SpeziBluetooth @_spi(TestingSupport) import SpeziBluetoothServices import XCTest @@ -73,7 +73,12 @@ final class SpeziBluetoothTests: XCTestCase { app.checkBoxes["EventLog Notifications"].tap() app.assert(event: "subscribed", characteristic: .eventLogCharacteristic) #else + +#if targetEnvironment(macCatalyst) let offset = 0.98 +#else + let offset = 0.93 +#endif XCTAssert(app.switches["EventLog Notifications"].exists) XCTAssertEqual(app.switches["EventLog Notifications"].value as? String, "1") @@ -184,8 +189,8 @@ final class SpeziBluetoothTests: XCTestCase { app.buttons["Connect Device"].tap() XCTAssert(app.staticTexts["State, connected"].waitForExistence(timeout: 10.0)) - XCTAssert(app.staticTexts["Manufacturer, Apple Inc."].exists) - XCTAssert(app.staticTexts["Retain Count Check, Passed"].exists) + XCTAssert(app.staticTexts["Manufacturer, Apple Inc."].waitForExistence(timeout: 2.0)) + XCTAssert(app.staticTexts["Retain Count Check, Passed"].waitForExistence(timeout: 2.0)) XCTAssert(app.buttons["Disconnect Device"].exists) app.buttons["Disconnect Device"].tap() @@ -218,9 +223,9 @@ extension XCUIApplication { #endif } - func assert(event: String, characteristic: CBUUID, value: String? = nil) { + func assert(event: String, characteristic: BTUUID, value: String? = nil) { XCTAssert(staticTexts["Event, \(event)"].waitForExistence(timeout: 5.0)) - XCTAssert(staticTexts["Characteristic, \(CBUUID.toCustomShort(characteristic))"].waitForExistence(timeout: 2.0)) + XCTAssert(staticTexts["Characteristic, \(BTUUID.toCustomShort(characteristic))"].waitForExistence(timeout: 2.0)) if let value { XCTAssert(staticTexts["Value, \(value)"].waitForExistence(timeout: 2.0)) }