diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index aa714f18..7972628b 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -43,7 +43,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' | 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 | xcpretty" secrets: inherit uploadcoveragereport: name: Upload Coverage Report diff --git a/Package.swift b/Package.swift index 1bc5563a..46b02b8e 100644 --- a/Package.swift +++ b/Package.swift @@ -8,9 +8,17 @@ // SPDX-License-Identifier: MIT // +import class Foundation.ProcessInfo import PackageDescription +#if swift(<6) +let swiftConcurrency: SwiftSetting = .enableExperimentalFeature("SwiftConcurrency") +#else +let swiftConcurrency: SwiftSetting = .enableUpcomingFeature("SwiftConcurrency") +#endif + + let package = Package( name: "SpeziBluetooth", defaultLocalization: "en", @@ -20,18 +28,17 @@ let package = Package( .macOS(.v14) ], products: [ - .library(name: "BluetoothServices", targets: ["BluetoothServices"]), - .library(name: "BluetoothViews", targets: ["BluetoothViews"]), + .library(name: "SpeziBluetoothServices", targets: ["SpeziBluetoothServices"]), .library(name: "SpeziBluetooth", targets: ["SpeziBluetooth"]) ], dependencies: [ - .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "1.0.4"), - .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.3.0"), - .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.0.1"), - .package(url: "https://github.com/StanfordSpezi/SpeziViews", from: "1.3.0"), + .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "1.1.0"), + .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.4.0"), + .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-collections.git", from: "1.0.4"), + .package(url: "https://github.com/StanfordBDHG/XCTestExtensions.git", from: "0.4.11") + ] + swiftLintPackage(), targets: [ .target( name: "SpeziBluetooth", @@ -44,39 +51,67 @@ let package = Package( ], resources: [ .process("Resources") - ] + ], + swiftSettings: [ + swiftConcurrency + ], + plugins: [] + swiftLintPlugin() ), .target( - name: "BluetoothServices", + name: "SpeziBluetoothServices", dependencies: [ .target(name: "SpeziBluetooth"), .product(name: "ByteCoding", package: "SpeziNetworking"), .product(name: "SpeziNumerics", package: "SpeziNetworking") - ] - ), - .target( - name: "BluetoothViews", - dependencies: [ - .target(name: "SpeziBluetooth"), - .product(name: "SpeziViews", package: "SpeziViews") - ] + ], + swiftSettings: [ + swiftConcurrency + ], + plugins: [] + swiftLintPlugin() ), .executableTarget( name: "TestPeripheral", dependencies: [ .target(name: "SpeziBluetooth"), - .target(name: "BluetoothServices"), + .target(name: "SpeziBluetoothServices"), .product(name: "ByteCoding", package: "SpeziNetworking") - ] + ], + swiftSettings: [ + swiftConcurrency + ], + plugins: [] + swiftLintPlugin() ), .testTarget( name: "BluetoothServicesTests", dependencies: [ - .target(name: "BluetoothServices"), + .target(name: "SpeziBluetoothServices"), .target(name: "SpeziBluetooth"), .product(name: "XCTByteCoding", package: "SpeziNetworking"), - .product(name: "NIO", package: "swift-nio") - ] + .product(name: "NIO", package: "swift-nio"), + .product(name: "XCTestExtensions", package: "XCTestExtensions") + ], + swiftSettings: [ + swiftConcurrency + ], + plugins: [] + swiftLintPlugin() ) ] ) + + +func swiftLintPlugin() -> [Target.PluginUsage] { + // Fully quit Xcode and open again with `open --env SPEZI_DEVELOPMENT_SWIFTLINT /Applications/Xcode.app` + if ProcessInfo.processInfo.environment["SPEZI_DEVELOPMENT_SWIFTLINT"] != nil { + [.plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLint")] + } else { + [] + } +} + +func swiftLintPackage() -> [PackageDescription.Package.Dependency] { + if ProcessInfo.processInfo.environment["SPEZI_DEVELOPMENT_SWIFTLINT"] != nil { + [.package(url: "https://github.com/realm/SwiftLint.git", .upToNextMinor(from: "0.55.1"))] + } else { + [] + } +} diff --git a/README.md b/README.md index d43bfb7a..2c4cd49e 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,8 @@ class DeviceInformationService: BluetoothService { We can use this Bluetooth service now in the `MyDevice` implementation as follows. -> Tip: We use the [`DeviceState`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/devicestate) and [`DeviceAction`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/deviceaction) property wrappers to get access to the device state and its actions. Those two +> [!TIP] +> We use the [`DeviceState`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/devicestate) and [`DeviceAction`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/deviceaction) property wrappers to get access to the device state and its actions. Those two property wrappers can also be used within a [`BluetoothService`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetoothservice) type. ```swift @@ -145,15 +146,17 @@ class ExampleDelegate: SpeziAppDelegate { Once you have the `Bluetooth` module configured within your Spezi app, you can access the module within your [`Environment`](https://developer.apple.com/documentation/swiftui/environment). -You can use the [`scanNearbyDevices(enabled:with:autoConnect:)`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/swiftui/view/scanNearbyDevices(enabled:with:autoConnect:)) and [`autoConnect(enabled:with:)`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/swiftui/view/autoConnect(enabled:with:)) +You can use the [`scanNearbyDevices(enabled:with:minimumRSSI:advertisementStaleInterval:autoConnect:)`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/swiftui/view/scanNearbyDevices(enabled:with:minimumRSSI:advertisementStaleInterval:autoConnect:)) +and [`autoConnect(enabled:with:minimumRSSI:advertisementStaleInterval:)`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/swiftui/view/autoConnect(enabled:with:minimumRSSI:advertisementStaleInterval:)) modifiers to scan for nearby devices and/or auto connect to the first available device. Otherwise, you can also manually start and stop scanning for nearby devices -using [`scanNearbyDevices(autoConnect:)`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth/scanNearbyDevices(autoConnect:)) and [`stopScanning()`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth/stopScanning()). +using [`scanNearbyDevices(minimumRSSI:advertisementStaleInterval:autoConnect:)`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth/scanNearbyDevices(minimumRSSI:advertisementStaleInterval:autoConnect:)) and [`stopScanning()`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth/stopScanning()). To retrieve the list of nearby devices you may use [`nearbyDevices(for:)`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth/nearbyDevices(for:)). -> Tip: To easily access the first connected device, you can just query the SwiftUI Environment for your `BluetoothDevice` type. -Make sure to declare the property as optional using the respective [`Environment(_:)`](https://developer.apple.com/documentation/swiftui/environment/init(_:)-8slkf) -initializer. +> [!TIP] +> To easily access the first connected device, you can just query the SwiftUI Environment for your `BluetoothDevice` type. + Make sure to declare the property as optional using the respective [`Environment(_:)`](https://developer.apple.com/documentation/swiftui/environment/init(_:)-8slkf) + initializer. The below code example demonstrates all these steps of retrieving the `Bluetooth` module from the environment, listing all nearby devices, auto connecting to the first one and displaying some basic information of the currently connected device. @@ -197,6 +200,28 @@ struct MyView: View { } ``` +> [!TIP] +> Use [`ConnectedDevices`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/ConnectedDevices) to retrieve the full list of connected devices from the SwiftUI environment. + +#### Retrieving Devices + +The previous section explained how to discover nearby devices and retrieve the currently connected one from the environment. +This is great ad-hoc connection establishment with devices currently nearby. +However, this might not be the most efficient approach, if you want to connect to a specific, previously paired device. +In these situations you can use the [`retrieveDevice(for:as:)`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth/retrieveDevice(for:as:)) method to retrieve a known device. + +Below is a short code example illustrating this method. + +```swift +let id: UUID = ... // a Bluetooth peripheral identifier (e.g., previously retrieved when pairing the device) + +let device = bluetooth.retrieveDevice(for: id, as: MyDevice.self) + +await device.connect() // assume declaration of @DeviceAction(\.connect) + +// Connect doesn't time out. Connection with the device will be established as soon as the device is in reach. +``` + ### Integration with Spezi Modules A Spezi [`Module`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module) is a great way of structuring your application into diff --git a/Sources/BluetoothServices/Characteristics/TemperatureType.swift b/Sources/BluetoothServices/Characteristics/TemperatureType.swift deleted file mode 100644 index 60fd9aca..00000000 --- a/Sources/BluetoothServices/Characteristics/TemperatureType.swift +++ /dev/null @@ -1,55 +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 ByteCoding -import NIO - - -/// The location of a temperature measurement. -/// -/// Refer to GATT Specification Supplement, 3.219 Temperature Type. -public enum TemperatureType: UInt8, CaseIterable { - /// Reserved for future use. - case reserved - /// Armpit. - case armpit - /// Body (general). - case body - /// Ear (usually earlobe). - case ear - /// Finger. - case finger - /// Gastrointestinal Tract. - case gastrointestinalTract - /// Mouth. - case mouth - /// Rectum. - case rectum - /// Toe. - case toe - /// Tympanum (ear drum). - case tympanum -} - - -extension TemperatureType: Hashable, Sendable {} - - -extension TemperatureType: ByteCodable { - public init?(from byteBuffer: inout ByteBuffer) { - guard let value = UInt8(from: &byteBuffer) else { - return nil - } - - self.init(rawValue: value) - } - - public func encode(to byteBuffer: inout ByteBuffer) { - rawValue.encode(to: &byteBuffer) - } -} diff --git a/Sources/BluetoothViews/BluetoothStateHint.swift b/Sources/BluetoothViews/BluetoothStateHint.swift deleted file mode 100644 index 7b0a7966..00000000 --- a/Sources/BluetoothViews/BluetoothStateHint.swift +++ /dev/null @@ -1,129 +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 SpeziBluetooth -import SwiftUI - - -public struct BluetoothStateHint: View { - private let state: BluetoothState - - private var titleMessage: LocalizedStringResource? { - switch state { - case .poweredOn: - return nil - case .poweredOff: - return .init("Bluetooth Off", bundle: .atURL(from: .module)) - case .unauthorized: - return .init("Bluetooth Prohibited", bundle: .atURL(from: .module)) - case .unsupported: - return .init("Bluetooth Unsupported", bundle: .atURL(from: .module)) - case .unknown: - return .init("Bluetooth Failure", bundle: .atURL(from: .module)) - } - } - - private var subtitleMessage: LocalizedStringResource? { - switch state { - case .poweredOn: - return nil - case .poweredOff: - return .init("Bluetooth is turned off. ...", bundle: .atURL(from: .module)) - case .unauthorized: - return .init("Bluetooth is required to make connections to nearby devices. ...", bundle: .atURL(from: .module)) - case .unknown: - return .init("We have trouble with the Bluetooth communication. Please try again.", bundle: .atURL(from: .module)) - case .unsupported: - return .init("Bluetooth is unsupported on this device!", bundle: .atURL(from: .module)) - } - } - - - public var body: some View { - if titleMessage != nil || subtitleMessage != nil { - ContentUnavailableView { - if let titleMessage { - Label { - Text(titleMessage) - } icon: { - EmptyView() - } - } - } description: { - if let subtitleMessage { - Text(subtitleMessage) - } - } actions: { - switch state { - case .poweredOff, .unauthorized: - #if os(iOS) || os(visionOS) || os(tvOS) - Button(action: { - if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url) - } - }) { - Text("Open Settings", bundle: .module) - } - #else - EmptyView() - #endif - default: - EmptyView() - } - } - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets(top: -15, leading: 0, bottom: 0, trailing: 0)) - } else { - EmptyView() - } - } - - - public init(_ state: BluetoothState) { - self.state = state - } -} - - -#if DEBUG -#Preview { - GeometryReader { proxy in - List { - BluetoothStateHint(.poweredOff) - .frame(height: proxy.size.height - 100) - } - } -} - -#Preview { - GeometryReader { proxy in - List { - BluetoothStateHint(.unauthorized) - .frame(height: proxy.size.height - 100) - } - } -} - -#Preview { - GeometryReader { proxy in - List { - BluetoothStateHint(.unsupported) - .frame(height: proxy.size.height - 100) - } - } -} - -#Preview { - GeometryReader { proxy in - List { - BluetoothStateHint(.unknown) - .frame(height: proxy.size.height - 100) - } - } -} -#endif diff --git a/Sources/BluetoothViews/BluetoothViews.docc/BluetoothViews.md b/Sources/BluetoothViews/BluetoothViews.docc/BluetoothViews.md deleted file mode 100644 index b1092718..00000000 --- a/Sources/BluetoothViews/BluetoothViews.docc/BluetoothViews.md +++ /dev/null @@ -1,31 +0,0 @@ -# ``BluetoothViews`` - -Reusable view components for interaction with Bluetooth devices. - - - -## Overview - -This target provides reusable view components that can be helpful when designing user interaction with Bluetooth devices. - -## Topics - -### Presenting nearby devices - -Views that are helpful when building a nearby devices view. - -- ``BluetoothStateHint`` -- ``NearbyDeviceRow`` -- ``LoadingSectionHeaderView`` - -### Peripheral Model - -- ``GenericBluetoothPeripheral`` diff --git a/Sources/BluetoothViews/LoadingSectionHeaderView.swift b/Sources/BluetoothViews/LoadingSectionHeaderView.swift deleted file mode 100644 index 56dc0ee4..00000000 --- a/Sources/BluetoothViews/LoadingSectionHeaderView.swift +++ /dev/null @@ -1,60 +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 SwiftUI - - -public struct LoadingSectionHeaderView: View { - private let text: Text - private let loading: Bool - - public var body: some View { - HStack { - text - if loading { - ProgressView() - .padding(.leading, 4) - .accessibilityRemoveTraits(.updatesFrequently) - } - } - .accessibilityElement(children: .combine) - .accessibilityLabel(Text("\(text), Searching", bundle: .module)) - } - - @_disfavoredOverload - public init(verbatim: String, loading: Bool) { - self.init(Text(verbatim), loading: loading) - } - - public init(_ title: LocalizedStringResource, loading: Bool) { - self.init(Text(title), loading: loading) - } - - - public init(_ text: Text, loading: Bool) { - self.text = text - self.loading = loading - } -} - - -#if DEBUG -#Preview { - List { - Section { - Text(verbatim: "...") - } header: { - LoadingSectionHeaderView(verbatim: "Devices", loading: true) - } - } -} - -#Preview { - LoadingSectionHeaderView(verbatim: "Devices", loading: true) -} -#endif diff --git a/Sources/BluetoothViews/Model/BluetoothPeripheral+GenericBluetoothPeripheral.swift b/Sources/BluetoothViews/Model/BluetoothPeripheral+GenericBluetoothPeripheral.swift deleted file mode 100644 index 60182077..00000000 --- a/Sources/BluetoothViews/Model/BluetoothPeripheral+GenericBluetoothPeripheral.swift +++ /dev/null @@ -1,16 +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 SpeziBluetooth - - -extension BluetoothPeripheral: GenericBluetoothPeripheral { - public nonisolated var label: String { - name ?? "unknown device" - } -} diff --git a/Sources/BluetoothViews/Model/GenericBluetoothPeripheral.swift b/Sources/BluetoothViews/Model/GenericBluetoothPeripheral.swift deleted file mode 100644 index f2aab657..00000000 --- a/Sources/BluetoothViews/Model/GenericBluetoothPeripheral.swift +++ /dev/null @@ -1,46 +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 SpeziBluetooth - - -/// A generic bluetooth peripheral representation used within UI components. -public protocol GenericBluetoothPeripheral { - /// The user-visible label. - /// - /// This label is used to communicate information about this device to the user. - var label: String { get } - - /// An optional accessibility label. - /// - /// This label is used as the accessibility label within views when - /// communicate information about this device to the user. - var accessibilityLabel: String { get } - - /// The current peripheral state. - var state: PeripheralState { get } - - /// Mark the device to require user attention. - /// - /// Marks the device to require user attention. The user should navigate to the details - /// view to get more information about the device. - var requiresUserAttention: Bool { get } -} - - -extension GenericBluetoothPeripheral { - /// Default implementation using the devices `label`. - public var accessibilityLabel: String { - label - } - - /// By default the peripheral doesn't require user attention. - public var requiresUserAttention: Bool { - false - } -} diff --git a/Sources/BluetoothViews/Model/MockBluetoothDevice.swift b/Sources/BluetoothViews/Model/MockBluetoothDevice.swift deleted file mode 100644 index 37a14c42..00000000 --- a/Sources/BluetoothViews/Model/MockBluetoothDevice.swift +++ /dev/null @@ -1,23 +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 SpeziBluetooth - - -/// Mock peripheral used for internal previews. -struct MockBluetoothDevice: GenericBluetoothPeripheral { - var label: String - var state: PeripheralState - var requiresUserAttention: Bool - - init(label: String, state: PeripheralState, requiresUserAttention: Bool = false) { - self.label = label - self.state = state - self.requiresUserAttention = requiresUserAttention - } -} diff --git a/Sources/BluetoothViews/NearbyDeviceRow.swift b/Sources/BluetoothViews/NearbyDeviceRow.swift deleted file mode 100644 index 4f59093b..00000000 --- a/Sources/BluetoothViews/NearbyDeviceRow.swift +++ /dev/null @@ -1,159 +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 SpeziViews -import SwiftUI - - -public struct NearbyDeviceRow: View { - private let peripheral: any GenericBluetoothPeripheral - private let devicePrimaryActionClosure: () -> Void - private let secondaryActionClosure: (() -> Void)? - - - var showDetailsButton: Bool { - secondaryActionClosure != nil && peripheral.state == .connected - } - - var localizationSecondaryLabel: LocalizedStringResource? { - if peripheral.requiresUserAttention { - return .init("Intervention Required", bundle: .atURL(from: .module)) - } - switch peripheral.state { - case .connecting: - return .init("Connecting", bundle: .atURL(from: .module)) - case .connected: - return .init("Connected", bundle: .atURL(from: .module)) - case .disconnecting: - return .init("Disconnecting", bundle: .atURL(from: .module)) - case .disconnected: - return nil - } - } - - public var body: some View { - let stack = HStack { - Button(action: devicePrimaryAction) { - HStack { - ListRow(verbatim: peripheral.label) { - deviceSecondaryLabel - } - if peripheral.state == .connecting || peripheral.state == .disconnecting { - ProgressView() - .accessibilityRemoveTraits(.updatesFrequently) - } - } - } - - if showDetailsButton { - Button(action: deviceDetailsAction) { - Label { - Text("Device Details", bundle: .module) - } icon: { - Image(systemName: "info.circle") // swiftlint:disable:this accessibility_label_for_image - } - } - .labelStyle(.iconOnly) - .font(.title3) - .buttonStyle(.plain) // ensure button is clickable next to the other button - .foregroundColor(.accentColor) - } - } - - #if TEST || targetEnvironment(simulator) - // accessibility actions cannot be unit tested - stack - #else - stack.accessibilityRepresentation { - accessibilityRepresentation - } - #endif - } - - @ViewBuilder var accessibilityRepresentation: some View { - let button = Button(action: devicePrimaryAction) { - Text(verbatim: peripheral.accessibilityLabel) - if let localizationSecondaryLabel { - Text(localizationSecondaryLabel) - } - } - - if showDetailsButton { - button - .accessibilityAction(named: Text("Device Details", bundle: .module), deviceDetailsAction) - } else { - button - } - } - - @ViewBuilder var deviceSecondaryLabel: some View { - if peripheral.requiresUserAttention { - Text("Requires Attention", bundle: .module) - } else { - switch peripheral.state { - case .connecting, .disconnecting: - EmptyView() - case .connected: - Text("Connected", bundle: .module) - case .disconnected: - EmptyView() - } - } - } - - - public init( - peripheral: any GenericBluetoothPeripheral, - primaryAction: @escaping () -> Void, - secondaryAction: (() -> Void)? = nil - ) { - self.peripheral = peripheral - self.devicePrimaryActionClosure = primaryAction - self.secondaryActionClosure = secondaryAction - } - - - private func devicePrimaryAction() { - devicePrimaryActionClosure() - } - - private func deviceDetailsAction() { - if let secondaryActionClosure { - secondaryActionClosure() - } - } -} - - -#if DEBUG -#Preview { - List { - NearbyDeviceRow(peripheral: MockBluetoothDevice(label: "MyDevice 1", state: .connecting)) { - print("Clicked") - } secondaryAction: { - } - NearbyDeviceRow(peripheral: MockBluetoothDevice(label: "MyDevice 2", state: .connected)) { - print("Clicked") - } secondaryAction: { - } - NearbyDeviceRow(peripheral: MockBluetoothDevice(label: "Long MyDevice 3", state: .connected, requiresUserAttention: true)) { - print("Clicked") - } secondaryAction: { - } - NearbyDeviceRow(peripheral: MockBluetoothDevice(label: "MyDevice 4", state: .disconnecting)) { - print("Clicked") - } secondaryAction: { - } - NearbyDeviceRow(peripheral: MockBluetoothDevice(label: "MyDevice 5", state: .disconnected)) { - print("Clicked") - } secondaryAction: { - } - } -} -#endif diff --git a/Sources/BluetoothViews/Resources/Localizable.xcstrings b/Sources/BluetoothViews/Resources/Localizable.xcstrings deleted file mode 100644 index 91d7dd74..00000000 --- a/Sources/BluetoothViews/Resources/Localizable.xcstrings +++ /dev/null @@ -1,166 +0,0 @@ -{ - "sourceLanguage" : "en", - "strings" : { - "%@, Searching" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@, Searching" - } - } - } - }, - "Bluetooth Failure" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bluetooth Failure" - } - } - } - }, - "Bluetooth is required to make connections to nearby devices. ..." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bluetooth is required to make connections to a nearby device. Please allow Bluetooth connections in your Privacy settings." - } - } - } - }, - "Bluetooth is turned off. ..." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bluetooth is turned off. Please turn on Bluetooth in Control Center or Settings, in order to connect to a nearby device." - } - } - } - }, - "Bluetooth is unsupported on this device!" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bluetooth is unsupported on this device!" - } - } - } - }, - "Bluetooth Off" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bluetooth Off" - } - } - } - }, - "Bluetooth Prohibited" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bluetooth Prohibited" - } - } - } - }, - "Bluetooth Unsupported" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bluetooth Unsupported" - } - } - } - }, - "Connected" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Connected" - } - } - } - }, - "Connecting" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Connecting" - } - } - } - }, - "Device Details" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Device Details" - } - } - } - }, - "Disconnecting" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Disconnecting" - } - } - } - }, - "Intervention Required" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Intervention REquired" - } - } - } - }, - "Open Settings" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Open Settings" - } - } - } - }, - "Requires Attention" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Requires Attention" - } - } - } - }, - "We have trouble with the Bluetooth communication. Please try again." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "We have trouble with the Bluetooth communication. Please try again." - } - } - } - } - }, - "version" : "1.0" -} \ No newline at end of file diff --git a/Sources/BluetoothViews/Resources/Localizable.xcstrings.license b/Sources/BluetoothViews/Resources/Localizable.xcstrings.license deleted file mode 100644 index 28f53d0d..00000000 --- a/Sources/BluetoothViews/Resources/Localizable.xcstrings.license +++ /dev/null @@ -1,5 +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 \ No newline at end of file diff --git a/Sources/SpeziBluetooth/Bluetooth.swift b/Sources/SpeziBluetooth/Bluetooth.swift index 2d3ed085..6ae7d187 100644 --- a/Sources/SpeziBluetooth/Bluetooth.swift +++ b/Sources/SpeziBluetooth/Bluetooth.swift @@ -8,6 +8,7 @@ import OrderedCollections import OSLog +@_spi(APISupport) import Spezi @@ -92,9 +93,10 @@ import Spezi /// Once you have the `Bluetooth` module configured within your Spezi app, you can access the module within your /// [`Environment`](https://developer.apple.com/documentation/swiftui/environment). /// -/// You can use the ``SwiftUI/View/scanNearbyDevices(enabled:with:autoConnect:)`` and ``SwiftUI/View/autoConnect(enabled:with:)`` +/// You can use the ``SwiftUI/View/scanNearbyDevices(enabled:with:minimumRSSI:advertisementStaleInterval:autoConnect:)`` +/// and ``SwiftUI/View/autoConnect(enabled:with:minimumRSSI:advertisementStaleInterval:)`` /// modifiers to scan for nearby devices and/or auto connect to the first available device. Otherwise, you can also manually start and stop scanning for nearby devices -/// using ``scanNearbyDevices(autoConnect:)`` and ``stopScanning()``. +/// using ``scanNearbyDevices(minimumRSSI:advertisementStaleInterval:autoConnect:)`` and ``stopScanning()``. /// /// To retrieve the list of nearby devices you may use ``nearbyDevices(for:)``. /// @@ -144,6 +146,27 @@ import Spezi /// } /// ``` /// +/// - Tip: Use ``ConnectedDevices`` to retrieve the full list of connected devices from the SwiftUI environment. +/// +/// #### Retrieving Devices +/// +/// The previous section explained how to discover nearby devices and retrieve the currently connected one from the environment. +/// This is great ad-hoc connection establishment with devices currently nearby. +/// However, this might not be the most efficient approach, if you want to connect to a specific, previously paired device. +/// In these situations you can use the ``retrieveDevice(for:as:)`` method to retrieve a known device. +/// +/// Below is a short code example illustrating this method. +/// +/// ```swift +/// let id: UUID = ... // a Bluetooth peripheral identifier (e.g., previously retrieved when pairing the device) +/// +/// let device = bluetooth.retrieveDevice(for: id, as: MyDevice.self) +/// +/// await device.connect() // assume declaration of @DeviceAction(\.connect) +/// +/// // Connect doesn't time out. Connection with the device will be established as soon as the device is in reach. +/// ``` +/// /// ### Integration with Spezi Modules /// /// A Spezi [`Module`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module) is a great way of structuring your application into @@ -184,17 +207,26 @@ import Spezi /// ## Topics /// /// ### Configure the Bluetooth Module -/// - ``init(minimumRSSI:advertisementStaleInterval:_:)`` +/// - ``init(_:)`` +/// - ``configuration`` /// /// ### Bluetooth State /// - ``state`` /// - ``isScanning`` +/// - ``stateSubscription`` /// /// ### Nearby Devices /// - ``nearbyDevices(for:)`` -/// - ``scanNearbyDevices(autoConnect:)`` +/// - ``scanNearbyDevices(minimumRSSI:advertisementStaleInterval:autoConnect:)`` /// - ``stopScanning()`` -public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, BluetoothActor { +/// +/// ### Retrieve Devices +/// - ``retrieveDevice(for:as:)`` +/// +/// ### Manually Manage Powered State +/// - ``powerOn()`` +/// - ``powerOff()`` +public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { @Observable class Storage { var nearbyDevices: OrderedDictionary = [:] @@ -206,7 +238,12 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo let bluetoothQueue: DispatchSerialQueue private let bluetoothManager: BluetoothManager - private let deviceConfigurations: Set + + /// 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() @@ -220,6 +257,15 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo 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 { bluetoothManager.isScanning @@ -241,18 +287,25 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo } } + /// 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 = [:] + @Application(\.spezi) private var spezi /// Stores the connected device instance for every configured ``BluetoothDevice`` type. - @Model private var connectedDevicesModel = ConnectedDevices() + @Model private var connectedDevicesModel = ConnectedDevicesModel() /// Injects the ``BluetoothDevice`` instances from the `ConnectedDevices` model into the SwiftUI environment. @Modifier private var devicesInjector: ConnectedDevicesEnvironmentModifier /// Configure the Bluetooth Module. /// - /// Configures the Bluetooth Module with the provided set of ``DiscoveryConfiguration``s. + /// Configures the Bluetooth Module with the provided set of ``DeviceDiscoveryDescriptor``s. /// Below is a short code example on how you would discover a `ExampleDevice` by its advertised service id. /// /// ```swift @@ -261,33 +314,21 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo /// } /// ``` /// - /// - Parameters: - /// - minimumRSSI: The minimum rssi a nearby peripheral must have to be considered nearby. - /// - 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. - /// - devices: + /// - Parameter devices: The set of configured devices. public init( - minimumRSSI: Int = BluetoothManager.Defaults.defaultMinimumRSSI, - advertisementStaleInterval: TimeInterval = BluetoothManager.Defaults.defaultStaleTimeout, - @DiscoveryConfigurationBuilder _ devices: @Sendable () -> Set + @DiscoveryDescriptorBuilder _ devices: @Sendable () -> Set ) { let configuration = devices() + let deviceTypes = configuration.deviceTypes + let discovery = configuration.parseDiscoveryDescription() - let devices = ClosureRegistrar.$writeableView.withValue(.init()) { - // we provide a closure registrar just to silence any out-of-band usage warnings! - configuration.parseDeviceDescription() - } - - let bluetoothManager = BluetoothManager( - devices: devices, - minimumRSSI: minimumRSSI, - advertisementStaleInterval: advertisementStaleInterval - ) + let bluetoothManager = BluetoothManager() self.bluetoothQueue = bluetoothManager.bluetoothQueue self.bluetoothManager = bluetoothManager - self.deviceConfigurations = configuration + self.configuration = configuration + self.discoveryConfiguration = discovery self._devicesInjector = Modifier(wrappedValue: ConnectedDevicesEnvironmentModifier(configuredDeviceTypes: deviceTypes)) Task { @@ -295,6 +336,31 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo } } + /// Request to power up the Bluetooth Central. + /// + /// This method manually instantiates the underlying Central Manager and ensure that it stays allocated. + /// Balance this call with a call to ``powerOff()``. + /// + /// - 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. + public func powerOn() { + bluetoothManager.assumeIsolated { manager in + manager.powerOn() + } + } + + /// Request to power down the Bluetooth Central. + /// + /// This method request to power off the central. This is delay if the central is still used (e.g., currently scanning or connected peripherals). + /// + /// - 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. + public func powerOff() { + bluetoothManager.assumeIsolated { manager in + manager.powerOff() + } + } + private func observeDiscoveredDevices() { self.assertIsolated("This didn't move to the actor even if it should.") bluetoothManager.assumeIsolated { manager in @@ -309,28 +375,10 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo bluetooth.handleUpdatedNearbyDevicesChange(discoveredDevices) } } - } - } - 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.discoveredPeripherals[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() - } - } + // 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. } } @@ -340,36 +388,32 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo // remove all delete keys for key in nearbyDevices.keys where discoveredDevices[key] == nil { checkForConnected = true - let device = nearbyDevices.removeValue(forKey: key) - if let device { - device.clearState(isolatedTo: self) - spezi.unloadModule(device) - } + 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 advertisementData = peripheral.advertisementData - guard let configuration = deviceConfigurations.find(for: advertisementData, logger: logger) else { - logger.warning("Ignoring peripheral \(peripheral.debugDescription) that cannot be mapped to a device class.") - continue - } - + let device: any BluetoothDevice + + // check if we already now the device! + if let persistentDevice = initializedDevices[uuid]?.anyValue { + device = persistentDevice + } else { + let advertisementData = peripheral.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 + } - let closures = ClosureRegistrar() - let device = ClosureRegistrar.$writeableView.withValue(closures) { - configuration.anyDeviceType.init() - } - ClosureRegistrar.$readableView.withValue(closures) { - device.inject(peripheral: peripheral) - nearbyDevices[uuid] = device + device = prepareDevice(id: uuid, configuration.deviceType, peripheral: peripheral) } - checkForConnected = true - observePeripheralState(of: uuid) // register \.state onChange closure + nearbyDevices[uuid] = device - spezi.loadModule(device) + checkForConnected = true } if checkForConnected { @@ -378,14 +422,46 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo } } + + @_spi(Internal) + public func _initializedDevicesCount() -> Int { // swiftlint:disable:this identifier_name + initializedDevices.count + } + + 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 { + 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() + } + } + } + } + private func handlePeripheralStateChange() { // check for active connected device - let connectedDevices = bluetoothManager.assumeIsolated { $0.discoveredPeripherals } + let connectedDevices = bluetoothManager.assumeIsolated { $0.knownPeripherals } .filter { _, value in value.assumeIsolated { $0.state } == .connected } - .compactMap { key, _ in - (key, nearbyDevices[key]) // map them to their devices class + .compactMap { key, _ -> (UUID, any BluetoothDevice)? in + // map them to their devices class + guard let device = initializedDevices[key]?.anyValue else { + return nil + } + return (key, device) } .reduce(into: [:]) { result, tuple in result[tuple.0] = tuple.1 @@ -399,7 +475,7 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo /// Retrieve nearby devices. /// - /// Use this method to retrieve nearby discovered Bluetooth peripherals. This method will only + /// Use this method to retrieve nearby discovered Bluetooth devices. This method will only /// 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. @@ -409,6 +485,60 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo } } + + /// Retrieve a known `BluetoothDevice` by its identifier. + /// + /// This method queries the list of known ``BluetoothDevice``s (e.g., paired devices). + /// + /// - Tip: You can use this method to connect to a known device. Retrieve the device using this method and use the ``DeviceActions/connect`` action. + /// The `connect` action doesn't time out and will make sure to connect to the device once it is available without the need for continuous scanning. + /// + /// - Important: Make sure to keep a strong reference to the returned device. The `Bluetooth` module only keeps a weak reference to the device. + /// If you don't need the device anymore, ``DeviceActions/disconnect`` and dereference it. + /// + /// - Parameters: + /// - 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. + public func retrieveDevice( + for uuid: UUID, + as device: Device.Type = Device.self + ) async -> Device? { + if let anyDevice = initializedDevices[uuid]?.anyValue { + guard let device = anyDevice as? Device else { + preconditionFailure(""" + Tried to make persistent device for nearby device with differing types. \ + Found \(type(of: anyDevice)), requested \(Device.self) + """) + } + return device + } + + // This condition is fine, every device type that wants to be paired has to be discovered at least once. + // This helps also with building the `ConnectedDevices` statically and have the SwiftUI view hierarchy not re-rendered every time. + precondition( + configuration.contains(where: { $0.deviceType == device }), + "Tried to make persistent device for non-configured device class \(Device.self)" + ) + + let configuration = device.parseDeviceDescription() + + guard let peripheral = await bluetoothManager.retrievePeripheral(for: uuid, with: configuration) else { + return nil + } + + + let device = prepareDevice(id: uuid, Device.self, peripheral: peripheral) + + // 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. + // We want to replicate this behavior with the Bluetooth Module as well, however `BluetoothDevice`s do have reference cycles and require explicit + // deallocation. Therefore, we introduce this helper RAII structure `PersistentDevice` that equally moves into the ownership of the caller. + // If they happen to release their reference, the deinit of the class is called informing the Bluetooth Module of de-initialization, allowing us + // to clean up the underlying BluetoothDevice instance (removing all self references) and therefore allowing to deinit the underlying BluetoothPeripheral. + return device + } + /// Scan for nearby bluetooth devices. /// /// Scans on nearby devices based on the ``Discover`` declarations provided in the initializer. @@ -417,30 +547,120 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo /// The first connected device can be accessed through the /// [Environment(_:)](https://developer.apple.com/documentation/swiftui/environment/init(_:)-8slkf) in your SwiftUI view. /// - /// - Tip: Scanning for nearby devices can easily be managed via the ``SwiftUI/View/scanNearbyDevices(enabled:with:autoConnect:)`` + /// - Tip: Scanning for nearby devices can easily be managed via the ``SwiftUI/View/scanNearbyDevices(enabled:with:minimumRSSI:advertisementStaleInterval:autoConnect:)`` /// modifier. /// - /// - Parameter autoConnect: If enabled, the bluetooth manager will automatically connect to + /// - Parameters: + /// - minimumRSSI: The minimum rssi a nearby peripheral must have to be considered nearby. + /// - 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. + /// - autoConnect: If enabled, the bluetooth manager will automatically connect to /// the nearby device if only one is found for a given time threshold. - public func scanNearbyDevices(autoConnect: Bool = false) { + public func scanNearbyDevices( + minimumRSSI: Int? = nil, + advertisementStaleInterval: TimeInterval? = nil, + autoConnect: Bool = false + ) { bluetoothManager.assumeIsolated { manager in - manager.scanNearbyDevices(autoConnect: autoConnect) + manager.scanNearbyDevices( + discovery: discoveryConfiguration, + minimumRSSI: minimumRSSI, + advertisementStaleInterval: advertisementStaleInterval, + autoConnect: autoConnect + ) } } - /// If scanning, toggle the auto-connect feature. - /// - Parameter autoConnect: Flag to turn on or off auto-connect - @_documentation(visibility: internal) - public func setAutoConnect(_ autoConnect: Bool) { + /// Stop scanning for nearby bluetooth devices. + public func stopScanning() { bluetoothManager.assumeIsolated { manager in - manager.setAutoConnect(autoConnect) + manager.stopScanning() } } +} + + +extension Bluetooth: BluetoothScanner { + func scanNearbyDevices(_ state: BluetoothModuleDiscoveryState) { + scanNearbyDevices( + minimumRSSI: state.minimumRSSI, + advertisementStaleInterval: state.advertisementStaleInterval, + autoConnect: state.autoConnect + ) + } + + func updateScanningState(_ state: BluetoothModuleDiscoveryState) { + let managerState = BluetoothManagerDiscoveryState( + configuredDevices: discoveryConfiguration, + minimumRSSI: state.minimumRSSI, + advertisementStaleInterval: state.advertisementStaleInterval, + autoConnect: state.autoConnect + ) - /// Stop scanning for nearby bluetooth devices. - public func stopScanning() { bluetoothManager.assumeIsolated { manager in - manager.stopScanning() + manager.updateScanningState(managerState) } } } + +// MARK: - Device Handling + +extension Bluetooth { + func prepareDevice(id uuid: UUID, _ device: Device.Type, peripheral: BluetoothPeripheral) -> Device { + let device = device.init() + + let didInjectAnything = device.inject(peripheral: peripheral, using: self) + if didInjectAnything { + initializedDevices[uuid] = device.weaklyReference + } else { + logger.warning( + """ + \(Device.self) is an empty device implementation. \ + The same peripheral might be instantiated via multiple \(Device.self) instances if no device property wrappers like + @Characteristic, @DeviceState or @DeviceAction is used. + """ + ) + } + + + observePeripheralState(of: peripheral.id) // register \.state onChange closure + + + precondition(!(device is EnvironmentAccessible), "Cannot load BluetoothDevice \(Device.self) that conforms to \(EnvironmentAccessible.self)!") + + + // 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 + + return device + } + + + nonisolated func notifyDeviceDeinit(for uuid: UUID) { + Task { @SpeziBluetooth in + await _notifyDeviceDeinit(for: uuid) + } + } + + + private func _notifyDeviceDeinit(for uuid: UUID) { + precondition(nearbyDevices[uuid] == nil, "\(#function) was wrongfully called for a device that is still referenced: \(uuid)") + + // this clears our weak reference that we use to reuse already created device class once they connect + let removedEntry = initializedDevices.removeValue(forKey: uuid) + + if let removedEntry { + logger.debug("\(removedEntry.typeName) device was de-initialized and removed from the Bluetooth module.") + } + } +} + + +extension BluetoothDevice { + fileprivate var weaklyReference: AnyWeakDeviceReference { + WeakReference(self) + } +} + +// swiftlint:disable:this file_length diff --git a/Sources/SpeziBluetooth/Bridging/BluetoothScanner.swift b/Sources/SpeziBluetooth/Bridging/BluetoothScanner.swift deleted file mode 100644 index 93368d2b..00000000 --- a/Sources/SpeziBluetooth/Bridging/BluetoothScanner.swift +++ /dev/null @@ -1,41 +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 -// - - -/// Any kind of Bluetooth Scanner. -public protocol BluetoothScanner: Identifiable where ID: Hashable { - /// Indicates if there is at least one connected peripheral. - /// - /// Make sure this tracks observability of all devices classes. - var hasConnectedDevices: Bool { get } - - /// Scan for nearby bluetooth devices. - /// - /// How devices are discovered and how they can be accessed is implementation defined. - /// - /// - Parameter autoConnect: If enabled, the bluetooth manager will automatically connect to - /// the nearby device if only one is found for a given time threshold. - func scanNearbyDevices(autoConnect: Bool) async - - /// Updates the auto-connect capability if currently scanning. - /// - /// Does nothing if not currently scanning. - /// - Parameter autoConnect: Flag if auto-connect should be enabled. - func setAutoConnect(_ autoConnect: Bool) async - - /// Stop scanning for nearby bluetooth devices. - func stopScanning() async -} - - -extension BluetoothScanner where Self: AnyObject { - /// Default id based on `ObjectIdentifier`. - public var id: ObjectIdentifier { - ObjectIdentifier(self) - } -} diff --git a/Sources/SpeziBluetooth/Configuration/DeviceDiscoveryDescriptor.swift b/Sources/SpeziBluetooth/Configuration/DeviceDiscoveryDescriptor.swift new file mode 100644 index 00000000..f3f9a846 --- /dev/null +++ b/Sources/SpeziBluetooth/Configuration/DeviceDiscoveryDescriptor.swift @@ -0,0 +1,45 @@ +// +// 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 +// + + +/// Describes how to discover a given `BluetoothDevice`. +/// +/// Provides a strategy on how to discovery given ``BluetoothDevice`` device type. +public struct DeviceDiscoveryDescriptor { + /// The criteria by which we identify a discovered device. + public let discoveryCriteria: DiscoveryCriteria + /// The associated device type. + public let deviceType: any BluetoothDevice.Type + + + init(discoveryCriteria: DiscoveryCriteria, deviceType: any BluetoothDevice.Type) { + self.discoveryCriteria = discoveryCriteria + self.deviceType = deviceType + } +} + + +extension DeviceDiscoveryDescriptor: Sendable {} + + +extension DeviceDiscoveryDescriptor: Identifiable { + public var id: DiscoveryCriteria { + discoveryCriteria + } +} + + +extension DeviceDiscoveryDescriptor: Hashable { + public static func == (lhs: DeviceDiscoveryDescriptor, rhs: DeviceDiscoveryDescriptor) -> Bool { + lhs.discoveryCriteria == rhs.discoveryCriteria + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(discoveryCriteria) + } +} diff --git a/Sources/SpeziBluetooth/Configuration/Discover.swift b/Sources/SpeziBluetooth/Configuration/Discover.swift index 3c479dd3..ff183320 100644 --- a/Sources/SpeziBluetooth/Configuration/Discover.swift +++ b/Sources/SpeziBluetooth/Configuration/Discover.swift @@ -20,8 +20,8 @@ /// - ``init(_:by:)`` /// /// ### Semantic Model -/// - ``DiscoveryConfiguration`` -/// - ``DiscoveryConfigurationBuilder`` +/// - ``DeviceDiscoveryDescriptor`` +/// - ``DiscoveryDescriptorBuilder`` public struct Discover { let deviceType: Device.Type let discoveryCriteria: DiscoveryCriteria diff --git a/Sources/SpeziBluetooth/Configuration/DiscoveryConfiguration.swift b/Sources/SpeziBluetooth/Configuration/DiscoveryConfiguration.swift deleted file mode 100644 index 15ecadd2..00000000 --- a/Sources/SpeziBluetooth/Configuration/DiscoveryConfiguration.swift +++ /dev/null @@ -1,38 +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 -// - - -/// Describes how to discover a given ``BluetoothDevice``. -public struct DiscoveryConfiguration: Sendable { - let discoveryCriteria: DiscoveryCriteria - let anyDeviceType: any BluetoothDevice.Type - - - init(discoveryCriteria: DiscoveryCriteria, anyDeviceType: any BluetoothDevice.Type) { - self.discoveryCriteria = discoveryCriteria - self.anyDeviceType = anyDeviceType - } -} - - -extension DiscoveryConfiguration: Identifiable { - public var id: DiscoveryCriteria { - discoveryCriteria - } -} - - -extension DiscoveryConfiguration: Hashable { - public static func == (lhs: DiscoveryConfiguration, rhs: DiscoveryConfiguration) -> Bool { - lhs.discoveryCriteria == rhs.discoveryCriteria - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(discoveryCriteria) - } -} diff --git a/Sources/SpeziBluetooth/Configuration/DiscoveryConfigurationBuilder.swift b/Sources/SpeziBluetooth/Configuration/DiscoveryConfigurationBuilder.swift deleted file mode 100644 index 03bad5cb..00000000 --- a/Sources/SpeziBluetooth/Configuration/DiscoveryConfigurationBuilder.swift +++ /dev/null @@ -1,50 +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 -// - - -/// Building a set of ``Discover`` expressions to express what peripherals to discover. -@resultBuilder -public enum DiscoveryConfigurationBuilder { - /// Build a ``Discover`` expression to define a ``DiscoveryConfiguration``. - public static func buildExpression(_ expression: Discover) -> Set { - [DiscoveryConfiguration(discoveryCriteria: expression.discoveryCriteria, anyDeviceType: expression.deviceType)] - } - - /// Build a block of ``DiscoveryConfiguration``s. - public static func buildBlock(_ components: Set...) -> Set { - buildArray(components) - } - - /// Build the first block of an conditional ``DiscoveryConfiguration`` component. - public static func buildEither(first component: Set) -> Set { - component - } - - /// Build the second block of an conditional ``DiscoveryConfiguration`` component. - public static func buildEither(second component: Set) -> Set { - component - } - - /// Build an optional ``DiscoveryConfiguration`` component. - public static func buildOptional(_ component: Set?) -> Set { - // swiftlint:disable:previous discouraged_optional_collection - component ?? [] - } - - /// Build an ``DiscoveryConfiguration`` component with limited availability. - public static func buildLimitedAvailability(_ component: Set) -> Set { - component - } - - /// Build an array of ``DiscoveryConfiguration`` components. - public static func buildArray(_ components: [Set]) -> Set { - components.reduce(into: []) { result, component in - result.formUnion(component) - } - } -} diff --git a/Sources/SpeziBluetooth/Configuration/DiscoveryDescriptorBuilder.swift b/Sources/SpeziBluetooth/Configuration/DiscoveryDescriptorBuilder.swift new file mode 100644 index 00000000..40f7c83d --- /dev/null +++ b/Sources/SpeziBluetooth/Configuration/DiscoveryDescriptorBuilder.swift @@ -0,0 +1,50 @@ +// +// 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 +// + + +/// Building a set of ``Discover`` expressions to express what peripherals to discover. +@resultBuilder +public enum DiscoveryDescriptorBuilder { + /// Build a ``Discover`` expression to define a ``DeviceDiscoveryDescriptor``. + public static func buildExpression(_ expression: Discover) -> Set { + [DeviceDiscoveryDescriptor(discoveryCriteria: expression.discoveryCriteria, deviceType: expression.deviceType)] + } + + /// Build a block of ``DeviceDiscoveryDescriptor``s. + public static func buildBlock(_ components: Set...) -> Set { + buildArray(components) + } + + /// Build the first block of an conditional ``DeviceDiscoveryDescriptor`` component. + public static func buildEither(first component: Set) -> Set { + component + } + + /// Build the second block of an conditional ``DeviceDiscoveryDescriptor`` component. + public static func buildEither(second component: Set) -> Set { + component + } + + /// Build an optional ``DeviceDiscoveryDescriptor`` component. + public static func buildOptional(_ component: Set?) -> Set { + // swiftlint:disable:previous discouraged_optional_collection + component ?? [] + } + + /// Build an ``DeviceDiscoveryDescriptor`` component with limited availability. + public static func buildLimitedAvailability(_ component: Set) -> Set { + component + } + + /// Build an array of ``DeviceDiscoveryDescriptor`` components. + public static func buildArray(_ components: [Set]) -> Set { + components.reduce(into: []) { result, component in + result.formUnion(component) + } + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift index 12b27ed3..3177cf7e 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -23,12 +23,14 @@ import OSLog /// /// To configure the Bluetooth Manager, you need to specify what devices you want to discover and what services and /// characteristics you are interested in. -/// To do so, provide a set of ``DeviceDescription``s upon initialization of the `BluetoothManager`. +/// To do so, provide a set of ``DiscoveryDescription``s upon initialization of the `BluetoothManager`. /// /// Below is a short code example to discover devices with a Heart Rate service. /// /// ```swift -/// let manager = BluetoothManager(devices [ +/// let manager = BluetoothManager() +/// +/// manager.scanNearbyDevices(discovery: devices [ /// DeviceDescription(discoverBy: .advertisedService("180D"), services: [ /// ServiceDescription(serviceId: "180D", characteristics: [ /// "2A37", // heart rate measurement @@ -37,20 +39,19 @@ import OSLog /// ]) /// ]) /// ]) -/// -/// manager.scanNearbyDevices() /// // ... /// manager.stopScanning() /// ``` /// /// ### Searching for nearby devices /// -/// You can scan for nearby devices using the ``scanNearbyDevices(autoConnect:)`` and stop scanning with ``stopScanning()``. +/// You can scan for nearby devices using the ``scanNearbyDevices(discovery:minimumRSSI:advertisementStaleInterval:autoConnect:)`` and stop scanning with ``stopScanning()``. /// All discovered peripherals will be populated through the ``nearbyPeripherals`` properties. /// /// Refer to the documentation of ``BluetoothPeripheral`` on how to interact with a Bluetooth peripheral. /// -/// - Tip: You can also use the ``SwiftUI/View/scanNearbyDevices(enabled:with:autoConnect:)`` and ``SwiftUI/View/autoConnect(enabled:with:)`` +/// - Tip: You can also use the ``SwiftUI/View/scanNearbyDevices(enabled:with:discovery:minimumRSSI:advertisementStaleInterval:autoConnect:)`` +/// and ``SwiftUI/View/autoConnect(enabled:with:discovery:minimumRSSI:advertisementStaleInterval:)`` /// modifiers within your SwiftUI view to automatically manage device scanning and/or auto connect to the /// first available device. /// @@ -58,153 +59,129 @@ import OSLog /// /// ### Create a Bluetooth Manager /// -/// - ``init(devices:minimumRSSI:advertisementStaleInterval:)`` +/// - ``init()`` /// /// ### Bluetooth State /// /// - ``state`` /// - ``isScanning`` +/// - ``stateSubscription`` /// /// ### Discovering nearby Peripherals /// - ``nearbyPeripherals`` -/// - ``scanNearbyDevices(autoConnect:)`` +/// - ``scanNearbyDevices(discovery:minimumRSSI:advertisementStaleInterval:autoConnect:)`` /// - ``stopScanning()`` +/// - ``SwiftUI/View/scanNearbyDevices(enabled:with:discovery:minimumRSSI:advertisementStaleInterval:autoConnect:)`` +/// - ``SwiftUI/View/autoConnect(enabled:with:discovery:minimumRSSI:advertisementStaleInterval:)`` +/// +/// ### Retrieving known Peripherals +/// - ``retrievePeripheral(for:with:)`` +/// +/// ### Manually Manage Powered State +/// - ``powerOn()`` +/// - ``powerOff()`` public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable:this type_body_length - @Observable - final class ObservableStorage: ValueObservable { - var state: BluetoothState = .unknown { - didSet { - _$simpleRegistrar.triggerDidChange(for: \.state, on: self) - } - } - var isScanning = false { - didSet { - _$simpleRegistrar.triggerDidChange(for: \.isScanning, on: self) - } - } - var discoveredPeripherals: OrderedDictionary = [:] { - didSet { - _$simpleRegistrar.triggerDidChange(for: \.discoveredPeripherals, on: self) - } - } - - // swiftlint:disable:next identifier_name - @ObservationIgnored var _$simpleRegistrar = ValueObservationRegistrar() - - init() {} - } - private let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "BluetoothManager") /// The serial executor for all Bluetooth related functionality. let bluetoothQueue: DispatchSerialQueue - - /// The device descriptions describing how nearby devices are discovered. - private let configuredDevices: Set - /// The minimum rssi that is required for a device to be discovered. - private let minimumRSSI: Int - /// The time interval after which an advertisement is considered stale and the device is removed. - private let advertisementStaleInterval: TimeInterval - @Lazy private var centralManager: CBCentralManager private var centralDelegate: Delegate? private var isScanningObserver: KVOStateObserver? private let _storage: ObservableStorage + private var isolatedStorage: ObservableStorage { + _storage + } + + /// Flag indicating that we want the CBCentral to stay allocated. + private var keepPoweredOn = false + + /// Currently ongoing discovery session. + private var discoverySession: DiscoverySession? + + /// 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) + } /// Represents the current state of the Bluetooth Manager. - nonisolated public private(set) var state: BluetoothState { - get { - _storage.state - } - set { - _storage.state = newValue + nonisolated public var state: BluetoothState { + _storage.state + } + + /// 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) + } + } } } + /// Whether or not we are currently scanning for nearby devices. - nonisolated public private(set) var isScanning: Bool { - get { - _storage.isScanning - } - set { - _storage.isScanning = newValue - } + nonisolated public var isScanning: Bool { + _storage.isScanning } + /// The list of discovered and connected bluetooth devices indexed by their identifier UUID. /// The state is isolated to our `dispatchQueue`. - var discoveredPeripherals: OrderedDictionary { + private(set) var discoveredPeripherals: OrderedDictionary { get { - _storage.discoveredPeripherals + isolatedStorage.discoveredPeripherals } _modify { - yield &_storage.discoveredPeripherals + yield &isolatedStorage.discoveredPeripherals } set { - _storage.discoveredPeripherals = newValue + isolatedStorage.discoveredPeripherals = newValue } } - /// Track if we should be scanning. This is important to check which resources should stay allocated. - private var shouldBeScanning = false - /// The identifier of the last manually disconnected device. - /// This is to avoid automatically reconnecting to a device that was manually disconnected. - private var lastManuallyDisconnectedDevice: UUID? - - private var autoConnect = false - private var autoConnectItem: BluetoothWorkItem? - private var staleTimer: DiscoveryStaleTimer? - - /// Checks and determines the device candidate for auto-connect. - /// - /// This will deliver a matching candidate with the lowest RSSI if we don't have a device already connected, - /// and there wasn't a device manually disconnected. - private var autoConnectDeviceCandidate: BluetoothPeripheral? { - guard autoConnect else { - return nil // auto-connect is disabled + private(set) var retrievedPeripherals: OrderedDictionary> { + get { + isolatedStorage.retrievedPeripherals } - - guard lastManuallyDisconnectedDevice == nil && !hasConnectedDevices else { - return nil + _modify { + yield &isolatedStorage.retrievedPeripherals + } + set { + isolatedStorage.retrievedPeripherals = newValue } - - let sortedCandidates = discoveredPeripherals.values - .filter { $0.cbPeripheral.state == .disconnected } - .sorted { lhs, rhs in - lhs.assumeIsolated { $0.rssi } < rhs.assumeIsolated { $0.rssi } - } - - return sortedCandidates.first } - /// The list of nearby bluetooth devices. + /// The combined collection of `discoveredPeripherals` and `retrievedPeripherals`. /// - /// This array contains all discovered bluetooth peripherals and those with which we are currently connected. - nonisolated public var nearbyPeripherals: [BluetoothPeripheral] { - Array(_storage.discoveredPeripherals.values) - } + /// Don't store this dictionary as this will accidentally reference retrieved peripherals strongly. + var knownPeripherals: OrderedDictionary { + let keysAndValues = retrievedPeripherals.elements + .map { ($0, $1.value) } + .compactMap { id, value in + if let value { + return (id, value) + } + return nil + } - /// The set of serviceIds we request to discover upon scanning. - /// Returning nil means scanning for all peripherals. - private var serviceDiscoveryIds: [CBUUID]? { // swiftlint:disable:this discouraged_optional_collection - let discoveryIds = configuredDevices.compactMap { configuration in - configuration.discoveryCriteria.discoveryId + return discoveredPeripherals.merging(keysAndValues) { lhs, rhs in + assertionFailure("Peripheral was present in both, discovered and retrieved set, lhs: \(lhs), rhs: \(rhs)") + return lhs } - - return discoveryIds.isEmpty ? nil : discoveryIds } /// Initialize a new Bluetooth Manager with provided device description and optional configuration options. - /// - Parameters: - /// - devices: The set of device description describing **how** to discover **what** to discover. - /// - minimumRSSI: The minimum rssi a nearby peripheral must have to be considered nearby. - /// - 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. - public init( - devices: Set, - minimumRSSI: Int = Defaults.defaultMinimumRSSI, - advertisementStaleInterval: TimeInterval = Defaults.defaultStaleTimeout - ) { + 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!") @@ -212,10 +189,6 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable self.bluetoothQueue = serialQueue - self.configuredDevices = devices - self.minimumRSSI = minimumRSSI - self.advertisementStaleInterval = max(1, advertisementStaleInterval) - self._storage = ObservableStorage() let delegate = Delegate() @@ -254,72 +227,128 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable delegate.initManager(self) } + /// Request to power up the Bluetooth Central. + /// + /// This method manually instantiates the underlying Central Manager and ensure that it stays allocated. + /// Balance this call with a call to ``powerOff()``. + /// + /// - 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. + public func powerOn() { + keepPoweredOn = true + _ = centralManager // ensure it is allocated + } + + /// Request to power down the Bluetooth Central. + /// + /// This method request to power off the central. This is delay if the central is still used (e.g., currently scanning or connected peripherals). + /// + /// - 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. + public func powerOff() { + keepPoweredOn = false + checkForCentralDeinit() + } + /// Scan for nearby bluetooth devices. /// - /// Scans on nearby devices based on the ``DeviceDescription`` provided in the initializer. + /// Scans on nearby devices based on the ``DiscoveryDescription`` provided in the initializer. /// All discovered devices can be accessed through the ``nearbyPeripherals`` property. /// - /// - Tip: Scanning for nearby devices can easily be managed via the ``SwiftUI/View/scanNearbyDevices(enabled:with:autoConnect:)`` + /// - Tip: Scanning for nearby devices can easily be managed via the ``SwiftUI/View/scanNearbyDevices(enabled:with:discovery:minimumRSSI:advertisementStaleInterval:autoConnect:)`` /// modifier. /// - /// - Parameter autoConnect: If enabled, the bluetooth manager will automatically connect to + /// - Parameters: + /// - discovery: The set of device description describing **how** and **what** to discover. + /// - minimumRSSI: The minimum rssi a nearby peripheral must have to be considered nearby. + /// - 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. + /// - autoConnect: If enabled, the bluetooth manager will automatically connect to /// the nearby device if only one is found for a given time threshold. - public func scanNearbyDevices(autoConnect: Bool = false) { - guard !isScanning else { - return + public func scanNearbyDevices( + discovery: Set, + minimumRSSI: Int? = nil, + advertisementStaleInterval: TimeInterval? = nil, + autoConnect: Bool = false + ) { + let state = BluetoothManagerDiscoveryState( + configuredDevices: discovery, + minimumRSSI: minimumRSSI, + advertisementStaleInterval: advertisementStaleInterval, + autoConnect: autoConnect + ) + scanNearbyDevices(state) + } + + func scanNearbyDevices(_ state: BluetoothManagerDiscoveryState) { + guard discoverySession == nil else { + return // already scanning! } - logger.debug("Starting scanning for nearby devices ...") + logger.debug("Creating discovery session ...") - shouldBeScanning = true - self.autoConnect = autoConnect + let session = DiscoverySession( + boundTo: self, + configuration: state + ) + self.discoverySession = session + // if powered of, we start scanning later in `handlePoweredOn()` if case .poweredOn = centralManager.state { - centralManager.scanForPeripherals( - withServices: serviceDiscoveryIds, - options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] - ) - isScanning = centralManager.isScanning // ensure this is propagated instantly + _scanForPeripherals(using: session) } } - /// If scanning, toggle the auto-connect feature. - /// - Parameter autoConnect: Flag to turn on or off auto-connect - @_documentation(visibility: internal) - public func setAutoConnect(_ autoConnect: Bool) { - if self.shouldBeScanning { - self.autoConnect = autoConnect + private func _scanForPeripherals(using session: DiscoverySession) { + guard !isScanning else { + return } + + logger.debug("Starting scanning for nearby devices ...") + centralManager.scanForPeripherals( + withServices: session.assumeIsolated { $0.serviceDiscoveryIds }, + options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] + ) + isolatedStorage.isScanning = centralManager.isScanning // ensure this is propagated instantly + } + + private func _restartScanning(using session: DiscoverySession) { + guard !isScanning else { + return + } + + centralManager.stopScan() + centralManager.scanForPeripherals( + withServices: session.assumeIsolated { $0.serviceDiscoveryIds }, + options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] + ) + isolatedStorage.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() - isScanning = centralManager.isScanning // ensure this is synced + isolatedStorage.isScanning = centralManager.isScanning // ensure this is synced logger.debug("Scanning stopped") } - if shouldBeScanning { - shouldBeScanning = false + if discoverySession != nil { + logger.debug("Discovery session cleared.") + discoverySession = nil checkForCentralDeinit() } } - func onChange(of keyPath: KeyPath, perform closure: @escaping (Value) -> Void) { - _storage.onChange(of: keyPath, perform: closure) - } /// Reactive scan upon powered on. private func handlePoweredOn() { - if shouldBeScanning && !isScanning { - scanNearbyDevices(autoConnect: autoConnect) + if let discoverySession, !isScanning { + _scanForPeripherals(using: discoverySession) } } private func handleStoppedScanning() { - self.autoConnect = false - let devices = discoveredPeripherals.values.filter { device in device.cbPeripheral.state == .disconnected } @@ -333,181 +362,216 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable } } - private func clearDiscoveredPeripheral(forKey id: UUID) { - discoveredPeripherals.removeValue(forKey: id) - if lastManuallyDisconnectedDevice == id { - lastManuallyDisconnectedDevice = nil + /// Retrieve a known `BluetoothPeripheral` by its identifier. + /// + /// This method queries the list of known ``BluetoothPeripheral``s (e.g., paired peripherals). + /// + /// - Tip: You can use this method to connect to a known peripheral. Retrieve the peripheral using this method and call the ``BluetoothPeripheral/connect()`` method. + /// The `connect()` method doesn't time out and will make sure to connect to the peripheral once it is available without the need for continuous scanning. + /// + /// - Important: Make sure to keep a strong reference to the returned peripheral. The `BluetoothManager` only keeps a weak reference to the peripheral. + /// If you don't need the peripheral anymore, ``BluetoothPeripheral/disconnect()`` and dereference it. + /// + /// - Parameters: + /// - uuid: The Bluetooth peripheral identifier. + /// - 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 { + _ = centralManager // make sure central is initialized! + + // we are waiting for the next state transition, ideally to poweredOn state! + logger.debug("Waiting for CBCentral to power on, before retrieving peripheral.") + for await nextState in stateSubscription { + logger.debug("CBCentral state transitioned to state \(nextState)") + break + } } - checkForCentralDeinit() - } + guard case .poweredOn = centralManager.state else { + logger.warning("Cannot retrieve peripheral with id \(uuid) while central is not powered on \(self.state)") + checkForCentralDeinit() + return nil + } - /// De-initializes the Bluetooth Central if we currently don't use it. - private func checkForCentralDeinit() { - if !shouldBeScanning && discoveredPeripherals.isEmpty { - _centralManager.destroy() - self.state = .unknown - self.lastManuallyDisconnectedDevice = nil + if let peripheral = knownPeripheral(for: uuid) { + return peripheral // peripheral was already retrieved or was recently discovered } - } - func connect(peripheral: BluetoothPeripheral) { - logger.debug("Trying to connect to \(peripheral.cbPeripheral.debugIdentifier) ...") + guard let peripheral = centralManager.retrievePeripherals(withIdentifiers: [uuid]).first else { + checkForCentralDeinit() + return nil + } - let cancelled = self.cancelStaleTask(for: peripheral) - self.centralManager.connect(peripheral.cbPeripheral, options: nil) + let device = BluetoothPeripheral( + manager: self, + peripheral: peripheral, + configuration: description, + advertisementData: .init([:]), // there was no advertisement + rssi: 127 // value of 127 signifies unavailability of RSSI value + ) - if cancelled { - self.scheduleStaleTaskForOldestActivityDevice(ignore: peripheral) - } - } + retrievedPeripherals.updateValue(WeakReference(device), forKey: peripheral.identifier) - func disconnect(peripheral: BluetoothPeripheral) { - logger.debug("Disconnecting peripheral \(peripheral.cbPeripheral.debugIdentifier) ...") - // stale timer is handled in the delegate method - centralManager.cancelPeripheralConnection(peripheral.cbPeripheral) + return device + } - self.lastManuallyDisconnectedDevice = peripheral.id + func onChange(of keyPath: KeyPath, perform closure: @escaping (Value) -> Void) { + _storage.onChange(of: keyPath, perform: closure) } - // MARK: - Auto Connect + 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() + } - private func kickOffAutoConnect() { - guard autoConnectItem == nil && autoConnectDeviceCandidate != nil else { - return + // 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) } - let item = BluetoothWorkItem(manager: self) { manager in - manager.autoConnectItem = nil - - guard let candidate = manager.autoConnectDeviceCandidate else { - return - } + discoveredPeripherals.removeValue(forKey: id) - candidate.assumeIsolated { peripheral in - peripheral.connect() - } + discoverySession?.assumeIsolated { session in + session.clearManuallyDisconnectedDevice(for: id) } - autoConnectItem = item - bluetoothQueue.schedule(for: .now() + .seconds(Defaults.defaultAutoConnectDebounce), execute: item) + checkForCentralDeinit() } - // MARK: - Stale Advertisement Timeout + fileprivate func knownPeripheral(for uuid: UUID) -> BluetoothPeripheral? { + if let peripheral = discoveredPeripherals[uuid] { + return peripheral + } - /// Schedule a new `DiscoveryStaleTimer`, cancelling any previous one. - /// - Parameters: - /// - device: The device for which the timer is scheduled for. - /// - timeout: The timeout for which the timer is scheduled for. - private func scheduleStaleTask(for device: BluetoothPeripheral, withTimeout timeout: TimeInterval) { - let timer = DiscoveryStaleTimer(device: device.id, manager: self) { manager in - manager.handleStaleTask() + guard let reference = retrievedPeripherals[uuid] else { + return nil } - self.staleTimer = timer - timer.schedule(for: timeout, in: bluetoothQueue) + guard let peripheral = reference.value else { + retrievedPeripherals.removeValue(forKey: uuid) + return nil + } + return peripheral } - private func scheduleStaleTaskForOldestActivityDevice(ignore device: BluetoothPeripheral? = nil) { - if let oldestActivityDevice = oldestActivityDevice(ignore: device) { - let lastActivity = oldestActivityDevice.assumeIsolated { $0.lastActivity } + fileprivate func ensurePeripheralReference(_ peripheral: BluetoothPeripheral) { + guard retrievedPeripherals[peripheral.id] != nil else { + return // is not weakly referenced + } + + retrievedPeripherals[peripheral.id] = nil + discoveredPeripherals[peripheral.id] = peripheral + } - let intervalSinceLastActivity = Date.now.timeIntervalSince(lastActivity) - let nextTimeout = max(0, advertisementStaleInterval - intervalSinceLastActivity) + /// 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) - scheduleStaleTask(for: oldestActivityDevice, withTimeout: nextTimeout) + discoverySession?.assumeIsolated { session in + session.clearManuallyDisconnectedDevice(for: uuid) } + + checkForCentralDeinit() } - private func cancelStaleTask(for device: BluetoothPeripheral) -> Bool { - guard let staleTimer, staleTimer.targetDevice == device.id else { - return false + /// De-initializes the Bluetooth Central if we currently don't use it. + private func checkForCentralDeinit() { + guard !keepPoweredOn else { + return // requested to stay allocated + } + + guard discoverySession == nil else { + return // discovery is currently running } - staleTimer.cancel() - self.staleTimer = nil - return true - } + 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)") + return // there are still associated devices + } - /// The device with the oldest device activity. - /// - Parameter device: The device to ignore. - private func oldestActivityDevice(ignore device: BluetoothPeripheral? = nil) -> BluetoothPeripheral? { - // when we are just interested in the min device, this operation is a bit cheaper then sorting the whole list - discoveredPeripherals.values - // it's important to access the underlying state here - .filter { - $0.cbPeripheral.state == .disconnected && $0.id != device?.id - } - .min { lhs, rhs in - lhs.assumeIsolated { - $0.lastActivity - } < rhs.assumeIsolated { - $0.lastActivity - } - } + _centralManager.destroy() + isolatedStorage.state = .unknown } - private func handleStaleTask() { - staleTimer = nil // reset the timer + func connect(peripheral: BluetoothPeripheral) { + logger.debug("Trying to connect to \(peripheral.cbPeripheral.debugIdentifier) ...") + + let cancelled = discoverySession?.assumeIsolated { session in + session.cancelStaleTask(for: peripheral) + } + + self.centralManager.connect(peripheral.cbPeripheral, options: nil) - let staleDevices = discoveredPeripherals.values.filter { device in - device.assumeIsolated { isolated in - isolated.isConsideredStale(interval: advertisementStaleInterval) + if cancelled == true { + discoverySession?.assumeIsolated { session in + session.scheduleStaleTaskForOldestActivityDevice(ignore: peripheral) } } + } - for device in staleDevices { - logger.debug("Removing stale peripheral \(device.cbPeripheral.debugIdentifier)") - // we know it won't be connected, therefore we just need to remove it - clearDiscoveredPeripheral(forKey: device.id) + func disconnect(peripheral: BluetoothPeripheral) { + logger.debug("Disconnecting peripheral \(peripheral.cbPeripheral.debugIdentifier) ...") + // stale timer is handled in the delegate method + centralManager.cancelPeripheralConnection(peripheral.cbPeripheral) + + discoverySession?.assumeIsolated { session in + session.deviceManuallyDisconnected(id: peripheral.id) } + } + private func handledConnected(device: BluetoothPeripheral) { + device.assumeIsolated { device in + device.handleConnect() + } - // schedule the next timeout for devices in the list - scheduleStaleTaskForOldestActivityDevice() + // we might have connected a bluetooth peripheral that was weakly referenced + ensurePeripheralReference(device) } private func discardDevice(device: BluetoothPeripheral) { - if !isScanning { - device.assumeIsolated { device in - device.markLastActivity() - device.handleDisconnect() - } - clearDiscoveredPeripheral(forKey: device.id) - } else { + if let discoverySession, isScanning { // we will keep discarded devices for max 2s before the stale timer kicks off - let interval = max(0, advertisementStaleInterval - 2) + let backdateInterval = max(0, discoverySession.assumeIsolated { $0.advertisementStaleInterval } - 2) device.assumeIsolated { device in - device.markLastActivity(.now - interval) + device.markLastActivity(.now - backdateInterval) device.handleDisconnect() } // We just schedule the new timer if there is a device to schedule one for. - scheduleStaleTaskForOldestActivityDevice() + discoverySession.assumeIsolated { session in + session.scheduleStaleTaskForOldestActivityDevice() + } + } else { + device.assumeIsolated { device in + device.markLastActivity() + device.handleDisconnect() + } + clearDiscoveredPeripheral(forKey: device.id) } } - private func isolatedUpdate(of keyPath: WritableKeyPath, _ value: Value) { - var manager = self - manager[keyPath: keyPath] = value - } - deinit { - staleTimer?.cancel() - autoConnectItem?.cancel() + discoverySession = nil // non-isolated workaround for calling stopScanning() if isScanning { - isScanning = false + _storage.isScanning = false _centralManager.wrappedValue.stopScan() logger.debug("Scanning stopped") } - state = .unknown + _storage.state = .unknown _storage.discoveredPeripherals = [:] + _storage.retrievedPeripherals = [:] centralDelegate = nil logger.debug("BluetoothManager destroyed") @@ -515,11 +579,71 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable } +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) + } + } + } + + 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: - self.isolatedUpdate(of: \.isScanning, value as! Bool) // swiftlint:disable:this force_cast + isolatedStorage.isScanning = value as! Bool // swiftlint:disable:this force_cast if !self.isScanning { self.handleStoppedScanning() } @@ -531,6 +655,11 @@ extension BluetoothManager: KVOReceiver { extension BluetoothManager: BluetoothScanner { + /// Default id based on `ObjectIdentifier`. + public nonisolated var id: ObjectIdentifier { + ObjectIdentifier(self) + } + /// Support for the auto connect modifier. @_documentation(visibility: internal) public nonisolated var hasConnectedDevices: Bool { @@ -538,6 +667,25 @@ extension BluetoothManager: BluetoothScanner { // 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) + } + }) + } + + func updateScanningState(_ state: BluetoothManagerDiscoveryState) { + guard let discoverySession else { + return + } + + let discoveryItemsChanged = discoverySession.assumeIsolated { session in + session.updateConfigurationReportingDiscoveryItemsChanged(state) + } + + if discoveryItemsChanged == true { + _restartScanning(using: discoverySession) } } } @@ -546,13 +694,13 @@ extension BluetoothManager: BluetoothScanner { // MARK: Defaults extension BluetoothManager { /// Set of default values used within the Bluetooth Manager - public enum Defaults { + enum Defaults { /// The default timeout after which stale advertisements are removed. - public static let defaultStaleTimeout: TimeInterval = 8 + static let defaultStaleTimeout: TimeInterval = 8 /// The minimum rssi of a peripheral to consider it for discovery. - public static let defaultMinimumRSSI = -80 + static let defaultMinimumRSSI = -80 /// The default time in seconds after which we check for auto connectable devices after the initial advertisement. - public static let defaultAutoConnectDebounce: Int = 1 + static let defaultAutoConnectDebounce: Int = 1 } } @@ -580,8 +728,8 @@ extension BluetoothManager { return } - // All these delegate methods are actually running on the DispatchQueue the Actor is isolated though. - // So in theory we should just be able to spring into isolation with assumeIsolated(). + // All these delegate methods are actually running on the DispatchQueue the Actor is isolated to. + // So in theory we should just be able to jump into isolation with assumeIsolated(). // However, executing a scheduled Job is different to just running a scheduled Job in the dispatch queue // form a Swift Runtime perspective. // Refer to _isCurrentExecutor (checked in assumeIsolated): @@ -594,7 +742,7 @@ extension BluetoothManager { // order and make sure to capture all important state before that. Task { @SpeziBluetooth in await manager.isolated { manager in - manager.isolatedUpdate(of: \.state, state) + manager.isolatedStorage.state = state logger.info("BluetoothManager central state is now \(manager.state)") if case .poweredOn = state { @@ -627,41 +775,43 @@ extension BluetoothManager { Task { @SpeziBluetooth in await manager.isolated { manager in - guard manager.isScanning else { + guard let session = manager.discoverySession, + manager.isScanning else { return } - // rssi of 127 is a magic value signifying unavailability of the value. - guard rssi.intValue >= manager.minimumRSSI, rssi.intValue != 127 else { // ensure the signal strength is not too low + // 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 data = AdvertisementData(advertisementData: advertisementData) + let data = AdvertisementData(advertisementData) // check if we already seen this device! - - let discoveredPeripherals = manager.discoveredPeripherals - if let device = discoveredPeripherals[peripheral.identifier] { + if let device = manager.knownPeripheral(for: peripheral.identifier) { device.assumeIsolated { device in device.markLastActivity() device.update(advertisement: data, rssi: rssi.intValue) } - if manager.cancelStaleTask(for: device) { - // current device was earliest to go stale, schedule timeout for next oldest device - manager.scheduleStaleTaskForOldestActivityDevice() - } + // we might have discovered a previously "retrieved" peripheral that must be strongly referenced now + manager.ensurePeripheralReference(device) - manager.kickOffAutoConnect() + session.assumeIsolated { session in + session.deviceDiscoveryPostAction(device: device, newlyDiscovered: false) + } return } logger.debug("Discovered peripheral \(peripheral.debugIdentifier) at \(rssi.intValue) dB (data: \(advertisementData))") + 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 ) @@ -669,12 +819,9 @@ extension BluetoothManager { manager.discoveredPeripherals.updateValue(device, forKey: peripheral.identifier) - if manager.staleTimer == nil { - // There is no stale timer running. So new device will be the one with the oldest activity. Schedule ... - manager.scheduleStaleTask(for: device, withTimeout: manager.advertisementStaleInterval) + session.assumeIsolated { session in + session.deviceDiscoveryPostAction(device: device, newlyDiscovered: true) } - - manager.kickOffAutoConnect() } } } @@ -686,17 +833,14 @@ extension BluetoothManager { Task { @SpeziBluetooth in await manager.isolated { manager in - let discoveredPeripherals = manager.discoveredPeripherals - guard let device = discoveredPeripherals[peripheral.identifier] else { + 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.") - device.assumeIsolated { device in - device.handleConnect(consider: manager.configuredDevices) - } + manager.handledConnected(device: device) } } } @@ -711,8 +855,7 @@ extension BluetoothManager { Task { @SpeziBluetooth in await manager.isolated { manager in - let discoveredPeripherals = manager.discoveredPeripherals - guard let device = discoveredPeripherals[peripheral.identifier] else { + 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 @@ -740,8 +883,7 @@ extension BluetoothManager { Task { @SpeziBluetooth in await manager.isolated { manager in - let discoveredPeripherals = manager.discoveredPeripherals - guard let device = discoveredPeripherals[peripheral.identifier] else { + guard let device = manager.knownPeripheral(for: peripheral.identifier) else { logger.error("Received didDisconnect for unknown peripheral \(peripheral.debugIdentifier).") return } @@ -757,4 +899,6 @@ extension BluetoothManager { } } } -} // swiftlint:disable:this file_length +} + +// swiftlint:disable:this file_length diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift index f855628b..96857c54 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift @@ -31,6 +31,8 @@ enum CharacteristicOnChangeHandler { /// - ``state`` /// - ``rssi`` /// - ``advertisementData`` +/// - ``nearby`` +/// - ``lastActivity`` /// /// ### Accessing Services /// - ``services`` @@ -63,6 +65,7 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ private weak var manager: BluetoothManager? private let peripheral: CBPeripheral + private let configuration: DeviceDescription private let delegate: Delegate private let stateObserver: KVOStateObserver @@ -88,8 +91,6 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ private var notifyRequested: Set = [] - /// The list of requested characteristic uuids indexed by service uuids. - private var requestedCharacteristics: [CBUUID: Set?]? // swiftlint:disable:this discouraged_optional_collection /// A set of service ids we are currently awaiting characteristics discovery for private var servicesAwaitingCharacteristicsDiscovery: Set = [] @@ -102,11 +103,15 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ } /// 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 } - nonisolated private(set) var localName: String? { + + /// The local name included in the advertisement. + nonisolated public private(set) var localName: String? { get { _storage.localName } @@ -171,13 +176,17 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ } } - private(set) var lastActivity: Date { + /// 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 .disconnected = peripheral.state { - _storage.lastActivity - } else { + if case .connected = state { // we are currently connected or connecting/disconnecting, therefore last activity is defined as "now" .now + } else { + _storage.lastActivity } } set { @@ -185,10 +194,23 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ } } + /// 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) + } + } + init( manager: BluetoothManager, peripheral: CBPeripheral, + configuration: DeviceDescription, advertisementData: AdvertisementData, rssi: Int ) { @@ -196,6 +218,7 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ self.manager = manager self.peripheral = peripheral + self.configuration = configuration self._storage = PeripheralStorage( peripheralName: peripheral.name, @@ -277,31 +300,19 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ _storage.onChange(of: keyPath, perform: closure) } - func handleConnect(consider configuredDevices: Set) { - if let description = configuredDevices.find(for: advertisementData, logger: logger), - let services = description.services { - requestedCharacteristics = services.reduce(into: [CBUUID: Set?]()) { result, configuration in - if let characteristics = configuration.characteristics { - result[configuration.serviceId, default: []]?.formUnion(characteristics) - } else if result[configuration.serviceId] == nil { - result[configuration.serviceId] = .some(nil) - } - } - } else { - // all services will be discovered - requestedCharacteristics = nil - } - + func handleConnect() { // ensure that it is updated instantly. self.isolatedUpdate(of: \.state, PeripheralState(from: peripheral.state)) logger.debug("Discovering services for \(self.peripheral.debugIdentifier) ...") - let services = requestedCharacteristics.map { Array($0.keys) } + let services = configuration.services?.reduce(into: Set()) { result, description in + result.insert(description.serviceId) + } if let services, services.isEmpty { _storage.signalFullyDiscovered() } else { - peripheral.discoverServices(requestedCharacteristics.map { Array($0.keys) }) + peripheral.discoverServices(services.map { Array($0) }) } } @@ -312,7 +323,6 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ // clear all the ongoing access - self.requestedCharacteristics = nil self.servicesAwaitingCharacteristicsDiscovery.removeAll() if let services { @@ -333,6 +343,10 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ } } + func handleDiscarded() { + isolatedUpdate(of: \.nearby, false) + } + func markLastActivity(_ lastActivity: Date = .now) { self.lastActivity = lastActivity } @@ -341,6 +355,7 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ self.isolatedUpdate(of: \.localName, advertisement.localName) self.isolatedUpdate(of: \.advertisementData, advertisement) self.isolatedUpdate(of: \.rssi, rssi) + self.isolatedUpdate(of: \.nearby, true) } /// Determines if the device is considered stale. @@ -648,6 +663,25 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ 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 + } + + let id = id + let name = name + + self.logger.debug("Device \(id), \(name ?? "unnamed") was de-initialized...") + Task.detached { @SpeziBluetooth in + await manager.handlePeripheralDeinit(id: id) + } + } } @@ -683,8 +717,10 @@ 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) + // pull initial value if none is present - if characteristic.value == nil && characteristic.properties.contains(.read) { + if description?.autoRead != false && characteristic.value == nil && characteristic.properties.contains(.read) { peripheral.readValue(for: characteristic) } @@ -697,20 +733,8 @@ extension BluetoothPeripheral { peripheral.setNotifyValue(true, for: characteristic) } } - } - - // check if we discover descriptors - guard let requestedCharacteristics = requestedCharacteristics, - let descriptions = requestedCharacteristics[service.uuid] else { - return - } - - for characteristic in characteristics { - guard let description = descriptions?.first(where: { $0.characteristicId == characteristic.uuid }) else { - continue - } - if description.discoverDescriptors { + if description?.discoverDescriptors == true { logger.debug("Discovering descriptors for \(characteristic.debugIdentifier)...") peripheral.discoverDescriptors(for: characteristic) } @@ -718,11 +742,13 @@ extension BluetoothPeripheral { } private func receivedUpdatedValue(for characteristic: CBCharacteristic, result: Result) { - if case let .read(continuation) = characteristicAccesses.retrieveAccess(for: characteristic) { + if let access = characteristicAccesses.retrieveAccess(for: characteristic), + case let .read(continuation) = access.value { if case let .failure(error) = result { logger.debug("Characteristic read for \(characteristic.debugIdentifier) returned with error: \(error)") } + access.consume() continuation.resume(with: result) } else if case let .failure(error) = result { logger.debug("Received unsolicited value update error for \(characteristic.debugIdentifier): \(error)") @@ -748,7 +774,8 @@ extension BluetoothPeripheral { } private func receivedWriteResponse(for characteristic: CBCharacteristic, result: Result) { - guard case let .write(continuation) = characteristicAccesses.retrieveAccess(for: characteristic) else { + guard let access = characteristicAccesses.retrieveAccess(for: characteristic), + case let .write(continuation) = access.value else { switch result { case .success: logger.warning("Received write response for \(characteristic.debugIdentifier) without an ongoing access. Discarding write ...") @@ -762,6 +789,7 @@ extension BluetoothPeripheral { logger.debug("Characteristic write for \(characteristic.debugIdentifier) returned with error: \(error)") } + access.consume() continuation.resume(with: result) } } @@ -889,19 +917,20 @@ extension BluetoothPeripheral { logger.debug("Discovered \(services) services for peripheral \(device.peripheral.debugIdentifier)") for service in services { - guard let requestedCharacteristicsDic = device.requestedCharacteristics, - let requestedCharacteristicsDescriptions = requestedCharacteristicsDic[service.uuid] else { + guard let serviceDescription = device.configuration.description(for: service.uuid) else { continue } - let requestedCharacteristics = requestedCharacteristicsDescriptions?.map { $0.characteristicId } + let characteristicIds = serviceDescription.characteristics?.reduce(into: Set()) { partialResult, description in + partialResult.insert(description.characteristicId) + } - if let requestedCharacteristics, requestedCharacteristics.isEmpty { + if let characteristicIds, characteristicIds.isEmpty { continue } device.servicesAwaitingCharacteristicsDiscovery.insert(service.uuid) - peripheral.discoverCharacteristics(requestedCharacteristics, for: service) + peripheral.discoverCharacteristics(characteristicIds.map { Array($0) }, for: service) } if device.servicesAwaitingCharacteristicsDiscovery.isEmpty { diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift index 95fdd785..0d566e07 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift @@ -15,15 +15,19 @@ public struct CharacteristicDescription: Sendable { public let characteristicId: CBUUID /// 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. + public let autoRead: Bool /// Create a new characteristic description. /// - Parameters: /// - id: The bluetooth characteristic id. /// - discoverDescriptors: Optional flag to specify that descriptors of this characteristic should be discovered. - public init(id: CBUUID, discoverDescriptors: Bool = false) { + /// - autoRead: Flag indicating if SpeziBluetooth should automatically read the initial value from the peripheral. + public init(id: CBUUID, discoverDescriptors: Bool = false, autoRead: Bool = true) { self.characteristicId = id self.discoverDescriptors = discoverDescriptors + self.autoRead = autoRead } } @@ -35,12 +39,4 @@ extension CharacteristicDescription: ExpressibleByStringLiteral { } -extension CharacteristicDescription: Hashable { - public static func == (lhs: CharacteristicDescription, rhs: CharacteristicDescription) -> Bool { - lhs.characteristicId == rhs.characteristicId - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(characteristicId) - } -} +extension CharacteristicDescription: Hashable {} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift index ce41c1bd..1edfe627 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // +@preconcurrency import class CoreBluetooth.CBUUID import OSLog @@ -13,44 +14,40 @@ import OSLog /// /// Describes what services we expect to be present for a certain type of device. /// The ``BluetoothManager`` uses that to determine what devices to discover and what services and characteristics to expect. -public struct DeviceDescription: Sendable { - /// The criteria by which we identify a discovered device. - public let discoveryCriteria: DiscoveryCriteria +public struct DeviceDescription { /// The set of service configurations we expect from the device. /// /// This will be the list of services we are interested in and we try to discover. - public let services: Set? // swiftlint:disable:this discouraged_optional_collection + public var services: Set? { // swiftlint:disable:this discouraged_optional_collection + let values: Dictionary.Values? = _services?.values + return values.map { Set($0) } + } + private let _services: [CBUUID: ServiceDescription]? // swiftlint:disable:this discouraged_optional_collection - /// Create a new discovery configuration for a certain 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. - public init(discoverBy discoveryCriteria: DiscoveryCriteria, services: Set?) { + /// Create a new device description. + /// - Parameter services: The set of service descriptions specifying the expected services. + public init(services: Set? = nil) { // swiftlint:disable:previous discouraged_optional_collection - self.discoveryCriteria = discoveryCriteria - self.services = services + self._services = services?.reduce(into: [:]) { partialResult, description in + partialResult[description.serviceId] = description + } } -} -extension DeviceDescription: Identifiable { - public var id: DiscoveryCriteria { - discoveryCriteria + /// 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? { + _services?[serviceId] } } -extension DeviceDescription: Hashable { - public static func == (lhs: DeviceDescription, rhs: DeviceDescription) -> Bool { - lhs.discoveryCriteria == rhs.discoveryCriteria - } +extension DeviceDescription: Sendable {} - public func hash(into hasher: inout Hasher) { - hasher.combine(discoveryCriteria) - } -} + +extension DeviceDescription: Hashable {} extension Collection where Element: Identifiable, Element.ID == DiscoveryCriteria { diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift index 65d05a0c..81d7512d 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift @@ -20,12 +20,16 @@ public enum DiscoveryCriteria: Sendable { /// Identify a device by their advertised service. case advertisedService(_ uuid: CBUUID) + /// Identify a device by its manufacturer and advertised service. + case accessory(manufacturer: ManufacturerIdentifier, advertising: CBUUID) var discoveryId: CBUUID { switch self { case let .advertisedService(uuid): uuid + case let .accessory(_, service): + service } } @@ -34,6 +38,18 @@ public enum DiscoveryCriteria: Sendable { switch self { case let .advertisedService(uuid): return advertisementData.serviceUUIDs?.contains(uuid) ?? false + case let .accessory(manufacturer, service): + guard let manufacturerData = advertisementData.manufacturerData, + let identifier = ManufacturerIdentifier(data: manufacturerData) else { + return false + } + + guard identifier == manufacturer else { + return false + } + + + return advertisementData.serviceUUIDs?.contains(service) ?? false } } } @@ -41,7 +57,7 @@ public enum DiscoveryCriteria: Sendable { extension DiscoveryCriteria { /// Identify a device by their advertised service. - /// - Parameter uuid: The Bluetooth ServiceId in string format. + /// - 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)) @@ -51,7 +67,31 @@ extension DiscoveryCriteria { /// - Parameter service: The service type. /// - Returns: A ``DiscoveryCriteria/advertisedService(_:)-swift.enum.case`` criteria. public static func advertisedService(_ service: Service.Type) -> DiscoveryCriteria { - .advertisedService(Service.id) + .advertisedService(service.id) + } +} + + +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. + /// - service: The service type. + /// - Returns: A ``DiscoveryCriteria/accessory(manufacturer:advertising:)-swift.enum.case`` criteria. + public static func accessory( + manufacturer: ManufacturerIdentifier, + advertising service: Service.Type + ) -> DiscoveryCriteria { + .accessory(manufacturer: manufacturer, advertising: service.id) } } @@ -61,6 +101,8 @@ extension DiscoveryCriteria: Hashable, CustomStringConvertible { switch self { case let .advertisedService(uuid): ".advertisedService(\(uuid))" + case let .accessory(manufacturer, service): + "accessory(company: \(manufacturer), advertised: \(service))" } } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryDescription.swift new file mode 100644 index 00000000..5db3be03 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryDescription.swift @@ -0,0 +1,49 @@ +// +// 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 +// + + +/// Description of a device discovery strategy. +/// +/// This type describes how to discover a device and what services and characteristics +/// to expect. +public struct DiscoveryDescription { + /// The criteria by which we identify a discovered device. + public let discoveryCriteria: DiscoveryCriteria + /// Description of the device. + /// + /// Provides guidance how and what to discover of the bluetooth peripheral. + public let device: DeviceDescription + + + /// 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. + public init(discoverBy discoveryCriteria: DiscoveryCriteria, device: DeviceDescription) { + self.discoveryCriteria = discoveryCriteria + self.device = device + } +} + + +extension DiscoveryDescription: Sendable {} + + +extension DiscoveryDescription: Identifiable { + public var id: DiscoveryCriteria { + discoveryCriteria + } +} + + +extension DiscoveryDescription: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(discoveryCriteria) + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift index e794379b..d0a97c32 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT // -@preconcurrency import CoreBluetooth +@preconcurrency import class CoreBluetooth.CBUUID /// A service description for a certain device. @@ -19,8 +19,12 @@ public struct ServiceDescription: Sendable { /// /// Those are the characteristics we try to discover. If empty, we discover all characteristics /// on a given service. - public let characteristics: Set? // swiftlint:disable:this discouraged_optional_collection + public var characteristics: Set? { // swiftlint:disable:this discouraged_optional_collection + let values: Dictionary.Values? = _characteristics?.values + return values.map { Set($0) } + } + private let _characteristics: [CBUUID: CharacteristicDescription]? // swiftlint:disable:this discouraged_optional_collection /// Create a new service description. /// - Parameters: @@ -29,7 +33,9 @@ public struct ServiceDescription: Sendable { /// Use `nil` to discover all characteristics. public init(serviceId: CBUUID, characteristics: Set?) { // swiftlint:disable:this discouraged_optional_collection self.serviceId = serviceId - self.characteristics = characteristics + self._characteristics = characteristics?.reduce(into: [:]) { partialResult, description in + partialResult[description.characteristicId] = description + } } /// Create a new service description. @@ -40,15 +46,15 @@ public struct ServiceDescription: Sendable { public init(serviceId: String, characteristics: Set?) { // swiftlint:disable:this discouraged_optional_collection self.init(serviceId: CBUUID(string: serviceId), characteristics: characteristics) } -} - -extension ServiceDescription: Hashable { - public static func == (lhs: ServiceDescription, rhs: ServiceDescription) -> Bool { - lhs.serviceId == rhs.serviceId - } - public func hash(into hasher: inout Hasher) { - hasher.combine(serviceId) + /// Retrieve the characteristic description for a given service id. + /// - Parameter serviceId: The Bluetooth characteristic id. + /// - Returns: Returns the characteristic description if present. + public func description(for characteristicsId: CBUUID) -> CharacteristicDescription? { + _characteristics?[characteristicsId] } } + + +extension ServiceDescription: Hashable {} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/AdvertisementData.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/AdvertisementData.swift index 8cac8a14..4cd499e3 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/AdvertisementData.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/AdvertisementData.swift @@ -63,7 +63,7 @@ public struct AdvertisementData { /// Creates advertisement data based on CoreBluetooth's dictionary. /// - Parameter advertisementData: Core Bluetooth's advertisement data - init(advertisementData: [String: Any]) { + public init(_ advertisementData: [String: Any]) { self.rawAdvertisementData = advertisementData } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift index 3b04149b..100a23af 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift @@ -20,7 +20,7 @@ class CharacteristicAccess { private let id: CBUUID private let semaphore = AsyncSemaphore() - private(set) var access: Access? + private(set) var value: Access? fileprivate init(id: CBUUID) { @@ -32,22 +32,20 @@ class CharacteristicAccess { try await semaphore.waitCheckingCancellation() } - func store(_ access: Access) { - precondition(self.access == nil, "Access was unexpectedly not nil") - self.access = access + func store(_ value: Access) { + precondition(self.value == nil, "Access was unexpectedly not nil") + self.value = value } - func receive() -> Access? { - let access = access - self.access = nil + func consume() { + self.value = nil semaphore.signal() - return access } func cancelAll() { semaphore.cancelAll() - let access = access - self.access = nil + let access = value + self.value = nil switch access { case let .read(continuation): @@ -75,12 +73,8 @@ struct CharacteristicAccesses { return access } - func retrieveAccess(for characteristic: CBCharacteristic) -> CharacteristicAccess.Access? { - guard let access = ongoingAccesses[characteristic] else { - return nil - } - - return access.receive() + func retrieveAccess(for characteristic: CBCharacteristic) -> CharacteristicAccess? { + ongoingAccesses[characteristic] } mutating func cancelAll() { diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift new file mode 100644 index 00000000..98e8f7c9 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift @@ -0,0 +1,332 @@ +// +// This source file is part of the Stanford Spezi open source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import class CoreBluetooth.CBUUID +import Foundation +import OSLog + +private func optionalMax(_ lhs: Value?, _ rhs: Value?) -> Value? { + guard let lhs, let rhs else { + return lhs ?? rhs + } + return max(lhs, rhs) +} + + +struct BluetoothManagerDiscoveryState: BluetoothScanningState { + /// The device descriptions describing how nearby devices are discovered. + let configuredDevices: Set + /// The minimum rssi that is required for a device to be considered discovered. + let minimumRSSI: Int? + /// The time interval after which an advertisement is considered stale and the device is removed. + let advertisementStaleInterval: TimeInterval? + /// Flag indicating if first discovered device should be auto-connected. + let autoConnect: Bool + + + init(configuredDevices: Set, minimumRSSI: Int?, advertisementStaleInterval: TimeInterval?, autoConnect: Bool) { + self.configuredDevices = configuredDevices + self.minimumRSSI = minimumRSSI + self.advertisementStaleInterval = advertisementStaleInterval.map { max(1, $0) } + self.autoConnect = autoConnect + } + + func merging(with other: BluetoothManagerDiscoveryState) -> BluetoothManagerDiscoveryState { + BluetoothManagerDiscoveryState( + configuredDevices: configuredDevices.union(other.configuredDevices), + minimumRSSI: optionalMax(minimumRSSI, other.minimumRSSI), + advertisementStaleInterval: optionalMax(advertisementStaleInterval, other.advertisementStaleInterval), + autoConnect: autoConnect || other.autoConnect + ) + } + + func updateOptions(minimumRSSI: Int?, advertisementStaleInterval: TimeInterval?) -> BluetoothManagerDiscoveryState { + BluetoothManagerDiscoveryState( + configuredDevices: configuredDevices, + minimumRSSI: optionalMax(self.minimumRSSI, minimumRSSI), + advertisementStaleInterval: optionalMax(self.advertisementStaleInterval, advertisementStaleInterval), + autoConnect: autoConnect + ) + } +} + + +/// Intermediate storage object that is later translated to a BluetoothManagerDiscoveryState. +struct BluetoothModuleDiscoveryState: BluetoothScanningState { + /// The minimum rssi that is required for a device to be considered discovered. + let minimumRSSI: Int? + /// The time interval after which an advertisement is considered stale and the device is removed. + let advertisementStaleInterval: TimeInterval? + /// Flag indicating if first discovered device should be auto-connected. + let autoConnect: Bool + + + init(minimumRSSI: Int?, advertisementStaleInterval: TimeInterval?, autoConnect: Bool) { + self.minimumRSSI = minimumRSSI + self.advertisementStaleInterval = advertisementStaleInterval + self.autoConnect = autoConnect + } + + func merging(with other: BluetoothModuleDiscoveryState) -> BluetoothModuleDiscoveryState { + BluetoothModuleDiscoveryState( + minimumRSSI: optionalMax(minimumRSSI, other.minimumRSSI), + advertisementStaleInterval: optionalMax(advertisementStaleInterval, other.advertisementStaleInterval), + autoConnect: autoConnect || other.autoConnect + ) + } + + func updateOptions(minimumRSSI: Int?, advertisementStaleInterval: TimeInterval?) -> BluetoothModuleDiscoveryState { + BluetoothModuleDiscoveryState( + minimumRSSI: optionalMax(self.minimumRSSI, minimumRSSI), + advertisementStaleInterval: optionalMax(self.advertisementStaleInterval, advertisementStaleInterval), + autoConnect: autoConnect + ) + } +} + + +actor DiscoverySession: BluetoothActor { + private let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "DiscoverySession") + let bluetoothQueue: DispatchSerialQueue + + + fileprivate weak var manager: BluetoothManager? + + private var configuration: BluetoothManagerDiscoveryState + + /// The identifier of the last manually disconnected device. + /// This is to avoid automatically reconnecting to a device that was manually disconnected. + private(set) var lastManuallyDisconnectedDevice: UUID? + + private var autoConnectItem: BluetoothWorkItem? + private(set) var staleTimer: DiscoveryStaleTimer? + + var configuredDevices: Set { + configuration.configuredDevices + } + + var minimumRSSI: Int { + configuration.minimumRSSI ?? BluetoothManager.Defaults.defaultMinimumRSSI + } + + var advertisementStaleInterval: TimeInterval { + configuration.advertisementStaleInterval ?? BluetoothManager.Defaults.defaultStaleTimeout + } + + /// 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 + let discoveryIds = configuration.configuredDevices.compactMap { configuration in + configuration.discoveryCriteria.discoveryId + } + + return discoveryIds.isEmpty ? nil : discoveryIds + } + + + init( + boundTo manager: BluetoothManager, + configuration: BluetoothManagerDiscoveryState + ) { + self.bluetoothQueue = manager.bluetoothQueue + self.manager = manager + self.configuration = configuration + } + + func isInRange(rssi: NSNumber) -> Bool { + // rssi of 127 is a magic value signifying unavailability of the value. + rssi.intValue >= minimumRSSI && rssi.intValue != 127 + } + + func deviceManuallyDisconnected(id uuid: UUID) { + lastManuallyDisconnectedDevice = uuid + } + + func clearManuallyDisconnectedDevice(for uuid: UUID) { + if lastManuallyDisconnectedDevice == uuid { + lastManuallyDisconnectedDevice = nil + } + } + + func deviceDiscoveryPostAction(device: BluetoothPeripheral, newlyDiscovered: Bool) { + if newlyDiscovered { + if staleTimer == nil { + // There is no stale timer running. So new device will be the one with the oldest activity. Schedule ... + scheduleStaleTask(for: device, withTimeout: advertisementStaleInterval) + } + } else { + if cancelStaleTask(for: device) { + // current device was earliest to go stale, schedule timeout for next oldest device + scheduleStaleTaskForOldestActivityDevice() + } + } + + kickOffAutoConnect() + } + + func updateConfigurationReportingDiscoveryItemsChanged(_ configuration: BluetoothManagerDiscoveryState) -> Bool { + let discoveryItemsChanged = self.configuration.configuredDevices != configuration.configuredDevices + self.configuration = configuration + return discoveryItemsChanged + } + + deinit { + staleTimer?.cancel() + autoConnectItem?.cancel() + } +} + + +extension BluetoothManagerDiscoveryState: Hashable {} + + +extension BluetoothModuleDiscoveryState: Hashable {} + +// MARK: - Auto Connect + +extension DiscoverySession { + /// Checks and determines the device candidate for auto-connect. + /// + /// This will deliver a matching candidate with the lowest RSSI if we don't have a device already connected, + /// and there wasn't a device manually disconnected. + private var autoConnectDeviceCandidate: BluetoothPeripheral? { + guard let manager else { + return nil // we are orphaned + } + + guard configuration.autoConnect else { + return nil // auto-connect is disabled + } + + guard lastManuallyDisconnectedDevice == nil && !manager.hasConnectedDevices else { + return nil + } + + manager.assertIsolated("\(#function) was not called from within isolation.") + let sortedCandidates = manager.assumeIsolated { $0.discoveredPeripherals } + .values + .filter { $0.cbPeripheral.state == .disconnected } + .sorted { lhs, rhs in + lhs.assumeIsolated { $0.rssi } < rhs.assumeIsolated { $0.rssi } + } + + return sortedCandidates.first + } + + + func kickOffAutoConnect() { + guard autoConnectItem == nil && autoConnectDeviceCandidate != nil else { + return + } + + let item = BluetoothWorkItem(boundTo: self) { session in + session.autoConnectItem = nil + + guard let candidate = session.autoConnectDeviceCandidate else { + return + } + + candidate.assumeIsolated { peripheral in + peripheral.connect() + } + } + + autoConnectItem = item + self.bluetoothQueue.schedule(for: .now() + .seconds(BluetoothManager.Defaults.defaultAutoConnectDebounce), execute: item) + } +} + +// MARK: - Stale Advertisement Timeout + +extension DiscoverySession { + /// Schedule a new `DiscoveryStaleTimer`, cancelling any previous one. + /// - Parameters: + /// - 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() + } + + self.staleTimer = timer + timer.schedule(for: timeout, in: self.bluetoothQueue) + } + + func scheduleStaleTaskForOldestActivityDevice(ignore device: BluetoothPeripheral? = nil) { + if let oldestActivityDevice = oldestActivityDevice(ignore: device) { + let lastActivity = oldestActivityDevice.assumeIsolated { $0.lastActivity } + + let intervalSinceLastActivity = Date.now.timeIntervalSince(lastActivity) + let nextTimeout = max(0, advertisementStaleInterval - intervalSinceLastActivity) + + scheduleStaleTask(for: oldestActivityDevice, withTimeout: nextTimeout) + } + } + + func cancelStaleTask(for device: BluetoothPeripheral) -> Bool { + guard let staleTimer, staleTimer.targetDevice == device.id else { + return false + } + + staleTimer.cancel() + self.staleTimer = nil + return true + } + + /// The device with the oldest device activity. + /// - Parameter device: The device to ignore. + private func oldestActivityDevice(ignore device: BluetoothPeripheral? = nil) -> BluetoothPeripheral? { + guard let manager else { + return nil + } + + // 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 } + .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 + } + } + } + + private func handleStaleTask() { + guard let manager else { + return + } + + staleTimer = nil // reset the timer + + let staleInternal = advertisementStaleInterval + let staleDevices = manager.assumeIsolated { $0.discoveredPeripherals } + .values + .filter { device in + device.assumeIsolated { isolated in + isolated.isConsideredStale(interval: staleInternal) + } + } + + for device in staleDevices { + logger.debug("Removing stale peripheral \(device.cbPeripheral.debugIdentifier)") + // we know it won't be connected, therefore we just need to remove it + manager.assumeIsolated { manager in + manager.clearDiscoveredPeripheral(forKey: device.id) + } + } + + + // schedule the next timeout for devices in the list + scheduleStaleTaskForOldestActivityDevice() + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/ManufacturerIdentifier.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/ManufacturerIdentifier.swift new file mode 100644 index 00000000..10b78eb9 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/ManufacturerIdentifier.swift @@ -0,0 +1,56 @@ +// +// 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 ByteCoding +import NIOCore + + +/// Bluetooth SIG-assigned Manufacturer Identifier. +/// +/// Refer to Assigned Numbers 7. Company Identifiers. +public struct ManufacturerIdentifier { + /// The raw manufacturer identifier. + public let rawValue: UInt16 + + /// Initialize a new manufacturer identifier form its code. + /// - Parameter code: The Bluetooth SIG-assigned Manufacturer Identifier. + public init(_ code: UInt16) { + self.init(rawValue: code) + } +} + + +extension ManufacturerIdentifier: Hashable, Sendable {} + + +extension ManufacturerIdentifier: RawRepresentable { + public init(rawValue: UInt16) { + self.rawValue = rawValue + } +} + + +extension ManufacturerIdentifier: ExpressibleByIntegerLiteral { + public init(integerLiteral value: UInt16) { + self.init(rawValue: value) + } +} + + +extension ManufacturerIdentifier: ByteCodable { + public init?(from byteBuffer: inout ByteBuffer) { + guard let rawValue = UInt16(from: &byteBuffer) else { + return nil + } + self.init(rawValue: rawValue) + } + + public func encode(to byteBuffer: inout ByteBuffer) { + rawValue.encode(to: &byteBuffer) + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift index fc0aed13..ae241338 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift @@ -42,7 +42,7 @@ public class OnChangeRegistration { let locator = locator let handlerId = handlerId - Task { @SpeziBluetooth in + Task.detached { @SpeziBluetooth in await peripheral?.deregisterOnChange(locator: locator, handlerId: handlerId) } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift index f425a9a8..1d6e10a3 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift @@ -17,7 +17,7 @@ import Foundation @Observable final class PeripheralStorage: ValueObservable { var name: String? { - localName ?? peripheralName + peripheralName ?? localName } private(set) var peripheralName: String? { @@ -25,32 +25,48 @@ final class PeripheralStorage: ValueObservable { _$simpleRegistrar.triggerDidChange(for: \.peripheralName, on: self) } } + private(set) var localName: String? { didSet { _$simpleRegistrar.triggerDidChange(for: \.localName, on: self) } } + private(set) var rssi: Int { didSet { _$simpleRegistrar.triggerDidChange(for: \.rssi, on: self) } } + private(set) var advertisementData: AdvertisementData { didSet { _$simpleRegistrar.triggerDidChange(for: \.advertisementData, on: self) } } + private(set) var state: PeripheralState { didSet { _$simpleRegistrar.triggerDidChange(for: \.state, on: self) } } + + private(set) var nearby: Bool { + didSet { + _$simpleRegistrar.triggerDidChange(for: \.nearby, on: self) + } + } + private(set) var services: [GATTService]? { // swiftlint:disable:this discouraged_optional_collection didSet { _$simpleRegistrar.triggerDidChange(for: \.services, on: self) } } - @ObservationIgnored var lastActivity: Date + + private(set) var lastActivity: Date { + didSet { + _$simpleRegistrar.triggerDidChange(for: \.lastActivity, on: self) + } + } // swiftlint:disable:next identifier_name @ObservationIgnored var _$simpleRegistrar = ValueObservationRegistrar() @@ -61,6 +77,7 @@ final class PeripheralStorage: ValueObservable { self.advertisementData = advertisementData self.rssi = rssi self.state = .init(from: state) + self.nearby = false self.lastActivity = lastActivity } @@ -88,16 +105,27 @@ final class PeripheralStorage: ValueObservable { func update(state: PeripheralState) { if self.state != state { - if self.state == .connecting && state == .connected { - return // we set connected on our own! + // we set connected on our own! See `signalFullyDiscovered` + if !(self.state == .connecting && state == .connected) { + self.state = state } - self.state = state + } + + if !nearby && (self.state == .connecting || self.state == .connected) { + self.nearby = true + } + } + + func update(nearby: Bool) { + if nearby != self.nearby { + self.nearby = nearby } } func signalFullyDiscovered() { if state == .connecting { state = .connected + update(state: .connected) // ensure other logic is called as well } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/WriteType.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/WriteType.swift index e7f29f96..2f8964e3 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/WriteType.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/WriteType.swift @@ -7,7 +7,7 @@ // -/// Determine the type of Bluetooth write operation. +/// Determine the type of a Bluetooth write operation. public enum WriteType { /// A write expecting an acknowledgment. case withResponse diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothActor.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothActor.swift index 262479ec..92f51887 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothActor.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothActor.swift @@ -11,7 +11,9 @@ import Foundation protocol BluetoothActor: Actor { nonisolated var bluetoothQueue: DispatchSerialQueue { get } - func isolated(perform: (isolated Self) -> Void) + func isolated(perform: (isolated Self) throws -> Void) rethrows + + func isolated(perform: (isolated Self) async throws -> Void) async rethrows } extension BluetoothActor { @@ -20,7 +22,11 @@ extension BluetoothActor { bluetoothQueue.asUnownedSerialExecutor() } - func isolated(perform: (isolated Self) -> Void) { - perform(self) + 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 82a52360..60f5579f 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothWorkItem.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothWorkItem.swift @@ -12,9 +12,9 @@ import Foundation class BluetoothWorkItem { let workItem: DispatchWorkItem - init(manager: BluetoothManager, handler: @escaping (isolated BluetoothManager) -> Void) { - self.workItem = DispatchWorkItem { [weak manager] in - guard let manager else { + init(boundTo actor: Actor, handler: @escaping (isolated Actor) -> Void) { + self.workItem = DispatchWorkItem { [weak actor] in + guard let actor else { return } @@ -22,7 +22,7 @@ class BluetoothWorkItem { // So sadly, we can't just jump into the actor isolation. But no big deal here for synchronization. Task { @SpeziBluetooth in - await manager.isolated(perform: handler) + await actor.isolated(perform: handler) } } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/DiscoveryStaleTimer.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/DiscoveryStaleTimer.swift index e1e1c96e..b5907b37 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/DiscoveryStaleTimer.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/DiscoveryStaleTimer.swift @@ -14,11 +14,11 @@ class DiscoveryStaleTimer { /// The dispatch work item that schedules the next stale timer. private let workItem: BluetoothWorkItem - init(device: UUID, manager: BluetoothManager, handler: @escaping (isolated BluetoothManager) -> Void) { + init(device: UUID, boundTo actor: Actor, handler: @escaping (isolated Actor) -> Void) { // make sure that you don't create a reference cycle through the closure above! self.targetDevice = device - self.workItem = BluetoothWorkItem(manager: manager, handler: handler) + self.workItem = BluetoothWorkItem(boundTo: actor, handler: handler) } diff --git a/Sources/SpeziBluetooth/Environment/AdvertisementStaleIntervalEnvironmentKey.swift b/Sources/SpeziBluetooth/Environment/AdvertisementStaleIntervalEnvironmentKey.swift new file mode 100644 index 00000000..8f024656 --- /dev/null +++ b/Sources/SpeziBluetooth/Environment/AdvertisementStaleIntervalEnvironmentKey.swift @@ -0,0 +1,29 @@ +// +// 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 AdvertisementStaleIntervalEnvironmentKey: EnvironmentKey { + static let defaultValue: TimeInterval? = nil +} + + +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? { + get { + self[AdvertisementStaleIntervalEnvironmentKey.self] + } + set { + if let newValue, newValue >= 1 { + self[AdvertisementStaleIntervalEnvironmentKey.self] = newValue + } + } + } +} diff --git a/Sources/SpeziBluetooth/Environment/MinimumRSSIEnvironmentKey.swift b/Sources/SpeziBluetooth/Environment/MinimumRSSIEnvironmentKey.swift new file mode 100644 index 00000000..22498756 --- /dev/null +++ b/Sources/SpeziBluetooth/Environment/MinimumRSSIEnvironmentKey.swift @@ -0,0 +1,29 @@ +// +// 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 MinimumRSSIEnvironmentKey: EnvironmentKey { + static let defaultValue: Int? = nil +} + + +extension EnvironmentValues { + /// The minimum rssi a nearby peripheral must have to be considered nearby. + public var minimumRSSI: Int? { + get { + self[MinimumRSSIEnvironmentKey.self] + } + set { + if let newValue { + self[MinimumRSSIEnvironmentKey.self] = newValue + } + } + } +} diff --git a/Sources/SpeziBluetooth/Environment/SurroundingScanModifiers.swift b/Sources/SpeziBluetooth/Environment/SurroundingScanModifiers.swift index 4828953c..b486d82b 100644 --- a/Sources/SpeziBluetooth/Environment/SurroundingScanModifiers.swift +++ b/Sources/SpeziBluetooth/Environment/SurroundingScanModifiers.swift @@ -9,20 +9,43 @@ import SwiftUI +@Observable class SurroundingScanModifiers: EnvironmentKey { static let defaultValue = SurroundingScanModifiers() - @MainActor private var registeredModifiers: [AnyHashable: Set] = [:] + @MainActor private var registeredModifiers: [AnyHashable: [UUID: any BluetoothScanningState]] = [:] @MainActor - func setModifierScanningState(enabled: Bool, with scanner: Scanner, modifierId: UUID) { + func setModifierScanningState(enabled: Bool, with scanner: Scanner, modifierId: UUID, state: Scanner.ScanningState) { if enabled { - registeredModifiers[AnyHashable(scanner.id), default: []] - .insert(modifierId) + registeredModifiers[AnyHashable(scanner.id), default: [:]] + .updateValue(state, forKey: modifierId) } else { - registeredModifiers[AnyHashable(scanner.id), default: []] - .remove(modifierId) + registeredModifiers[AnyHashable(scanner.id), default: [:]] + .removeValue(forKey: modifierId) + + if registeredModifiers[AnyHashable(scanner.id)]?.isEmpty == true { + registeredModifiers[AnyHashable(scanner.id)] = nil + } + } + } + + @MainActor + func retrieveReducedScanningState(for scanner: Scanner) -> Scanner.ScanningState? { + guard let entries = registeredModifiers[AnyHashable(scanner.id)] else { + return nil } + + return entries.values + .compactMap { anyState in + anyState as? Scanner.ScanningState + } + .reduce(nil) { partialResult, state in + guard let partialResult else { + return state + } + return partialResult.merging(with: state) + } } @MainActor diff --git a/Sources/SpeziBluetooth/Model/Actions/BluetoothConnectAction.swift b/Sources/SpeziBluetooth/Model/Actions/BluetoothConnectAction.swift index df56c3bf..97ba2222 100644 --- a/Sources/SpeziBluetooth/Model/Actions/BluetoothConnectAction.swift +++ b/Sources/SpeziBluetooth/Model/Actions/BluetoothConnectAction.swift @@ -10,8 +10,8 @@ /// Connect to the Bluetooth peripheral. /// /// For more information refer to ``DeviceActions/connect`` -public struct BluetoothConnectAction: _BluetoothPeripheralAction { - public typealias ClosureType = () async -> Void +public struct BluetoothConnectAction: _BluetoothPeripheralAction, Sendable { + public typealias ClosureType = @Sendable () async -> Void private let content: _PeripheralActionContent diff --git a/Sources/SpeziBluetooth/Model/Actions/BluetoothDisconnectAction.swift b/Sources/SpeziBluetooth/Model/Actions/BluetoothDisconnectAction.swift index e6f4d385..6d57d2d1 100644 --- a/Sources/SpeziBluetooth/Model/Actions/BluetoothDisconnectAction.swift +++ b/Sources/SpeziBluetooth/Model/Actions/BluetoothDisconnectAction.swift @@ -10,8 +10,8 @@ /// Disconnect from the Bluetooth peripheral. /// /// For more information refer to ``DeviceActions/disconnect`` -public struct BluetoothDisconnectAction: _BluetoothPeripheralAction { - public typealias ClosureType = () async -> Void +public struct BluetoothDisconnectAction: _BluetoothPeripheralAction, Sendable { + public typealias ClosureType = @Sendable () async -> Void private let content: _PeripheralActionContent diff --git a/Sources/SpeziBluetooth/Model/Actions/BluetoothPeripheralAction.swift b/Sources/SpeziBluetooth/Model/Actions/BluetoothPeripheralAction.swift index dc5a214d..7b5ee6d4 100644 --- a/Sources/SpeziBluetooth/Model/Actions/BluetoothPeripheralAction.swift +++ b/Sources/SpeziBluetooth/Model/Actions/BluetoothPeripheralAction.swift @@ -7,7 +7,7 @@ // /// The content of an implemented peripheral action. -public enum _PeripheralActionContent { // swiftlint:disable:this type_name file_types_order +public enum _PeripheralActionContent { // swiftlint:disable:this type_name file_types_order /// Execute the action on the provided bluetooth peripheral. case peripheral(BluetoothPeripheral) /// Execute the injected closure instead. @@ -27,3 +27,6 @@ public protocol _BluetoothPeripheralAction { // swiftlint:disable:this type_name /// - Parameter content: The action content. init(_ content: _PeripheralActionContent) } + + +extension _PeripheralActionContent: Sendable {} diff --git a/Sources/SpeziBluetooth/Model/Actions/ReadRSSIAction.swift b/Sources/SpeziBluetooth/Model/Actions/ReadRSSIAction.swift index 62925cee..36334bf8 100644 --- a/Sources/SpeziBluetooth/Model/Actions/ReadRSSIAction.swift +++ b/Sources/SpeziBluetooth/Model/Actions/ReadRSSIAction.swift @@ -9,8 +9,8 @@ /// Read the current RSSI from the Bluetooth peripheral. /// /// For more information refer to ``DeviceActions/readRSSI`` -public struct ReadRSSIAction: _BluetoothPeripheralAction { - public typealias ClosureType = () async throws -> Int +public struct ReadRSSIAction: _BluetoothPeripheralAction, Sendable { + public typealias ClosureType = @Sendable () async throws -> Int private let content: _PeripheralActionContent diff --git a/Sources/SpeziBluetooth/Model/Characteristic/ControlPointSupport.swift b/Sources/SpeziBluetooth/Model/Characteristic/ControlPointSupport.swift index c13c5ba5..3fdc05f8 100644 --- a/Sources/SpeziBluetooth/Model/Characteristic/ControlPointSupport.swift +++ b/Sources/SpeziBluetooth/Model/Characteristic/ControlPointSupport.swift @@ -7,6 +7,7 @@ // import Foundation +import SpeziFoundation final class ControlPointTransaction: @unchecked Sendable { @@ -34,7 +35,7 @@ final class ControlPointTransaction: @unchecked Sendable { } func signalTimeout() { - resume(with: .failure(ControlPointTimeoutError())) + resume(with: .failure(TimeoutError())) } func fulfill(_ value: Value) { diff --git a/Sources/SpeziBluetooth/Model/Errors/ControlPointTimeoutError.swift b/Sources/SpeziBluetooth/Model/Errors/ControlPointTimeoutError.swift deleted file mode 100644 index 620e77e2..00000000 --- a/Sources/SpeziBluetooth/Model/Errors/ControlPointTimeoutError.swift +++ /dev/null @@ -1,19 +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 -// - - -/// Timeout occurred with a control point characteristic. -/// -/// This error indicates that there was a timeout while waiting for the response to a request sent to -/// a ``ControlPointCharacteristic``. -public struct ControlPointTimeoutError { - /// Create new timeout error. - public init() {} -} - -extension ControlPointTimeoutError: Error {} diff --git a/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift b/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift index ed5759c1..bbaaeeb7 100644 --- a/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift +++ b/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift @@ -42,7 +42,8 @@ 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:)`` to set up your action. +/// ``CharacteristicAccessor/onChange(initial:perform:)-6ltwk`` or +/// ``CharacteristicAccessor/onChange(initial:perform:)-5awby`` 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. @@ -145,7 +146,8 @@ import Foundation /// - ``CharacteristicAccessor/enableNotifications(_:)`` /// /// ### Get notified about changes -/// - ``CharacteristicAccessor/onChange(initial:perform:)`` +/// - ``CharacteristicAccessor/onChange(initial:perform:)-6ltwk`` +/// - ``CharacteristicAccessor/onChange(initial:perform:)-5awby`` /// /// ### Control Point Characteristics /// - ``ControlPointCharacteristic`` @@ -158,9 +160,7 @@ import Foundation @propertyWrapper public final class Characteristic: @unchecked Sendable { class Configuration { - let id: CBUUID - let discoverDescriptors: Bool - + let description: CharacteristicDescription var defaultNotify: Bool /// Memory address as an identifier for this Characteristic instance. @@ -168,9 +168,12 @@ public final class Characteristic: @unchecked Sendable { ObjectIdentifier(self) } - init(id: CBUUID, discoverDescriptors: Bool, defaultNotify: Bool) { - self.id = id - self.discoverDescriptors = discoverDescriptors + var id: CBUUID { + description.characteristicId + } + + init(description: CharacteristicDescription, defaultNotify: Bool) { + self.description = description self.defaultNotify = defaultNotify } } @@ -179,10 +182,10 @@ public final class Characteristic: @unchecked Sendable { private let _value: ObservableBox private(set) var injection: CharacteristicPeripheralInjection? - private let _testInjections = Box(CharacteristicTestInjections()) + private let _testInjections: Box?> = Box(nil) var description: CharacteristicDescription { - CharacteristicDescription(id: configuration.id, discoverDescriptors: configuration.discoverDescriptors) + configuration.description } /// Access the current characteristic value. @@ -204,26 +207,24 @@ public final class Characteristic: @unchecked Sendable { CharacteristicAccessor(configuration: configuration, injection: injection, value: _value, testInjections: _testInjections) } - fileprivate init(wrappedValue: Value? = nil, characteristic: CBUUID, notify: Bool, discoverDescriptors: Bool = false) { + fileprivate init(wrappedValue: Value? = nil, characteristic: CBUUID, notify: Bool, autoRead: Bool = true, discoverDescriptors: Bool = false) { // swiftlint:disable:previous function_default_parameter_at_end - self.configuration = .init(id: characteristic, discoverDescriptors: discoverDescriptors, defaultNotify: notify) + let description = CharacteristicDescription(id: characteristic, discoverDescriptors: discoverDescriptors, autoRead: autoRead) + self.configuration = .init(description: description, defaultNotify: notify) self._value = ObservableBox(wrappedValue) } - func inject(peripheral: BluetoothPeripheral, serviceId: CBUUID, service: GATTService?) { + func inject(bluetooth: Bluetooth, peripheral: BluetoothPeripheral, serviceId: CBUUID, service: GATTService?) { let characteristic = service?.getCharacteristic(id: configuration.id) - // Any potential onChange closure registration that happened within the initializer. Forward them to the injection. - let onChangeClosure = ClosureRegistrar.readableView?.retrieve(for: configuration.objectId, value: Value.self) - let injection = CharacteristicPeripheralInjection( + bluetooth: bluetooth, peripheral: peripheral, serviceId: serviceId, characteristicId: configuration.id, value: _value, - characteristic: characteristic, - onChangeClosure: onChangeClosure + characteristic: characteristic ) // mutual access with `CharacteristicAccessor/enableNotifications` @@ -240,20 +241,22 @@ extension Characteristic where Value: ByteEncodable { /// - 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: String, discoverDescriptors: Bool = false) { + public convenience init(wrappedValue: Value? = nil, id: String, autoRead: Bool = true, discoverDescriptors: Bool = false) { // swiftlint:disable:previous function_default_parameter_at_end - self.init(wrappedValue: wrappedValue, id: CBUUID(string: id), discoverDescriptors: discoverDescriptors) + 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, discoverDescriptors: Bool = false) { + 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, discoverDescriptors: discoverDescriptors) + self.init(wrappedValue: wrappedValue, characteristic: id, notify: false, autoRead: autoRead, discoverDescriptors: discoverDescriptors) } } @@ -264,10 +267,11 @@ extension Characteristic where Value: ByteDecodable { /// - 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: String, notify: Bool = false, discoverDescriptors: Bool = false) { + 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, discoverDescriptors: discoverDescriptors) + self.init(wrappedValue: wrappedValue, id: CBUUID(string: id), notify: notify, autoRead: autoRead, discoverDescriptors: discoverDescriptors) } /// Declare a read-only characteristic. @@ -275,10 +279,11 @@ extension Characteristic where Value: ByteDecodable { /// - 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, discoverDescriptors: Bool = false) { + 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, discoverDescriptors: discoverDescriptors) + self.init(wrappedValue: wrappedValue, characteristic: id, notify: notify, autoRead: autoRead, discoverDescriptors: discoverDescriptors) } } @@ -289,10 +294,11 @@ extension Characteristic where Value: ByteCodable { // reduce ambiguity /// - 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: String, notify: Bool = false, discoverDescriptors: Bool = false) { + 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, discoverDescriptors: discoverDescriptors) + self.init(wrappedValue: wrappedValue, id: CBUUID(string: id), notify: notify, autoRead: autoRead, discoverDescriptors: discoverDescriptors) } /// Declare a read and write characteristic. @@ -300,10 +306,11 @@ extension Characteristic where Value: ByteCodable { // reduce ambiguity /// - 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, discoverDescriptors: Bool = false) { + 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, discoverDescriptors: discoverDescriptors) + self.init(wrappedValue: wrappedValue, characteristic: id, notify: notify, autoRead: autoRead, discoverDescriptors: discoverDescriptors) } } diff --git a/Sources/SpeziBluetooth/Model/Properties/DeviceAction.swift b/Sources/SpeziBluetooth/Model/Properties/DeviceAction.swift index d4064d6a..57e4460b 100644 --- a/Sources/SpeziBluetooth/Model/Properties/DeviceAction.swift +++ b/Sources/SpeziBluetooth/Model/Properties/DeviceAction.swift @@ -56,14 +56,14 @@ /// - ``DeviceActions`` @propertyWrapper public final class DeviceAction: @unchecked Sendable { - private var peripheral: BluetoothPeripheral? + private var injection: DeviceActionPeripheralInjection? /// Support injection of closures for testing support. private let _injectedClosure = Box(nil) /// Access the device action. public var wrappedValue: Action { - guard let peripheral else { + guard let injection else { if let injectedClosure = _injectedClosure.value { return Action(.injected(injectedClosure)) } @@ -75,7 +75,7 @@ public final class DeviceAction: @unchecked """ ) } - return Action(.peripheral(peripheral)) + return Action(.peripheral(injection.peripheral)) } /// Retrieve a temporary accessors instance. @@ -89,8 +89,8 @@ public final class DeviceAction: @unchecked public init(_ keyPath: KeyPath) {} - func inject(peripheral: BluetoothPeripheral) { - self.peripheral = peripheral + func inject(bluetooth: Bluetooth, peripheral: BluetoothPeripheral) { + self.injection = DeviceActionPeripheralInjection(bluetooth: bluetooth, peripheral: peripheral) } } diff --git a/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift b/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift index 6a81442b..c2a254e0 100644 --- a/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift +++ b/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift @@ -73,7 +73,8 @@ import Observation /// - ``BluetoothPeripheral/advertisementData`` /// /// ### Get notified about changes -/// - ``DeviceStateAccessor/onChange(initial:perform:)`` +/// - ``DeviceStateAccessor/onChange(initial:perform:)-8x9cj`` +/// - ``DeviceStateAccessor/onChange(initial:perform:)-9igc9`` /// /// ### Property wrapper access /// - ``wrappedValue`` @@ -84,7 +85,9 @@ import Observation 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) @@ -104,13 +107,13 @@ public final class DeviceState: @unchecked Sendable { """ ) } - return injection.peripheral[keyPath: keyPath] + return injection.value } /// Retrieve a temporary accessors instance. public var projectedValue: DeviceStateAccessor { - DeviceStateAccessor(id: objectId, injection: injection, injectedValue: _injectedValue) + DeviceStateAccessor(id: objectId, keyPath: keyPath, injection: injection, injectedValue: _injectedValue, testInjections: _testInjections) } @@ -121,10 +124,8 @@ public final class DeviceState: @unchecked Sendable { } - func inject(peripheral: BluetoothPeripheral) { - let changeClosure = ClosureRegistrar.readableView?.retrieve(for: objectId, value: Value.self) - - let injection = DeviceStatePeripheralInjection(peripheral: peripheral, keyPath: keyPath, onChangeClosure: changeClosure) + func inject(bluetooth: Bluetooth, peripheral: BluetoothPeripheral) { + let injection = DeviceStatePeripheralInjection(bluetooth: bluetooth, peripheral: peripheral, keyPath: keyPath) self.injection = injection injection.assumeIsolated { injection in @@ -151,30 +152,6 @@ extension DeviceState { return injected } - 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(advertisementData: [:]) - case \.rssi: - Int(UInt8.max) - 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 + return _testInjections.value?.artificialValue(for: keyPath) } } diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift index 7ebfc71d..b44dfcce 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift @@ -8,11 +8,24 @@ import ByteCoding import CoreBluetooth +import Spezi -struct CharacteristicTestInjections { + +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) + ) + } } @@ -44,7 +57,8 @@ struct CharacteristicTestInjections { /// - ``enableNotifications(_:)`` /// /// ### Get notified about changes -/// - ``onChange(initial:perform:)`` +/// - ``onChange(initial:perform:)-6ltwk`` +/// - ``onChange(initial:perform:)-5awby`` /// /// ### Control Point Characteristics /// - ``sendRequest(_:timeout:)`` @@ -57,14 +71,14 @@ public struct CharacteristicAccessor { /// We keep track of this for testing support. private let _value: ObservableBox /// Closure that captures write for testing support. - private let _testInjections: Box> + private let _testInjections: Box?> init( configuration: Characteristic.Configuration, injection: CharacteristicPeripheralInjection?, value: ObservableBox, - testInjections: Box> + testInjections: Box?> ) { self.configuration = configuration self.injection = injection @@ -114,43 +128,93 @@ extension CharacteristicAccessor where Value: ByteDecodable { } + /// Retrieve a subscription to changes to the characteristic value. + /// + /// This property creates an AsyncStream that yields all future updates to the characteristic value. + public var subscription: AsyncStream { + if let subscriptions = _testInjections.value?.subscriptions { + return subscriptions.newSubscription() + } + + guard let injection else { + preconditionFailure( + "The `subscription` of a @Characteristic cannot be accessed within the initializer. Defer access to the `configure() method" + ) + } + return injection.newSubscription() + } + + /// Perform action whenever the characteristic value changes. /// - /// - Important: This closure is called from the Bluetooth Serial Executor, if you don't pass in an async method + /// Register a change handler with the characteristic that is called every time the value changes. + /// + /// - Note: `onChange` handlers are bound to the lifetime of the device. If you need to control the lifetime yourself refer to using ``subscription``. + /// + /// Note that you cannot set up onChange handlers within the initializers. + /// Use the [`configure()`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module/configure()-5pa83) to set up + /// all your handlers. + /// - Important: You must capture `self` weakly only. Capturing `self` strongly causes a memory leak. + /// + /// - Note: This closure is called from the Bluetooth Serial Executor, if you don't pass in an async method /// that has an annotated actor isolation (e.g., `@MainActor` or actor isolated methods). /// - /// - Note: It is perfectly fine if you capture strongly self within your closure. The framework will - /// resolve any reference cycles for you. /// - 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) async -> Void) { - let closure = OnChangeClosure(initial: initial, closure: action) + public func onChange(initial: Bool = false, perform action: @escaping (_ value: Value) async -> Void) { + onChange(initial: initial) { _, newValue in + await action(newValue) + } + } - guard let injection else { - guard let closures = ClosureRegistrar.writeableView else { - Bluetooth.logger.warning( - """ - Tried to register onChange(perform:) closure out-of-band. Make sure to register your onChange closure \ - within the initializer or when the peripheral is fully injected. This is expected if you manually initialized your device. \ - The closure was discarded and won't have any effect. - """ - ) - return + /// Perform action whenever the characteristic value changes. + /// + /// Register a change handler with the characteristic that is called every time the value changes. + /// + /// - Note: `onChange` handlers are bound to the lifetime of the device. If you need to control the lifetime yourself refer to using ``subscription``. + /// + /// Note that you cannot set up onChange handlers within the initializers. + /// Use the [`configure()`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module/configure()-5pa83) to set up + /// all your handlers. + /// - Important: You must capture `self` weakly only. Capturing `self` strongly causes a memory leak. + /// + /// - Note: This closure is called from the Bluetooth Serial Executor, 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 { + let id = subscriptions.newOnChangeSubscription(perform: action) + + if initial, let value = _value.value { + // if there isn't a value already, initial won't work properly with injections + subscriptions.notifySubscriber(id: id, with: value) } - - // We save the instance in the global registrar if its available. - // It will be available if we are instantiated through the Bluetooth module. - // This indirection is required to support self referencing closures without encountering a strong reference cycle. - closures.insert(for: configuration.objectId, closure: closure) return } - // global actor ensures these tasks are queued serially and are executed in order. - Task { @SpeziBluetooth in - await injection.setOnChangeClosure(closure) + guard let injection else { + preconditionFailure( + """ + Register onChange(perform:) inside the initializer is not supported anymore. \ + Further, they no longer support capturing `self` without causing a memory leak. \ + Please migrate your code to register onChange listeners in the `configure()` method and make sure to weakly capture self. + + func configure() { + $state.onChange { [weak self] value in + self?.handleStateChange(value) + } + } + """ + ) } + + injection.newOnChangeSubscription(initial: initial, perform: action) } @@ -160,16 +224,6 @@ extension CharacteristicAccessor where Value: ByteDecodable { guard let injection else { // this value will be populated to the injection once it is set up configuration.defaultNotify = enabled - - if ClosureRegistrar.writeableView == nil { - Bluetooth.logger.warning( - """ - Tried to \(enabled ? "enable" : "disable") notifications out-of-band. Make sure to change notification settings \ - within the initializer or when the peripheral is fully injected. This is expected if you manually initialized your device. \ - The change was discarded and won't have any effect. - """ - ) - } return } @@ -182,8 +236,17 @@ extension CharacteristicAccessor where Value: ByteDecodable { /// It might also throw a ``BluetoothError/notPresent(service:characteristic:)`` or ``BluetoothError/incompatibleDataFormat`` error. @discardableResult public func read() async throws -> Value { - if let injectedReadClosure = _testInjections.value.readClosure { - return try await injectedReadClosure() + if let testInjection = _testInjections.value { + if let injectedReadClosure = testInjection.readClosure { + return try await injectedReadClosure() + } + + if testInjection.simulatePeripheral { + guard let value = _value.value else { + throw BluetoothError.notPresent(characteristic: configuration.id) + } + return value + } } guard let injection else { @@ -208,9 +271,16 @@ 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 injectedWriteClosure = _testInjections.value.writeClosure { - try await injectedWriteClosure(value, .withResponse) - return + if let testInjection = _testInjections.value { + if let injectedWriteClosure = testInjection.writeClosure { + try await injectedWriteClosure(value, .withResponse) + return + } + + if testInjection.simulatePeripheral { + inject(value) + return + } } guard let injection else { @@ -230,9 +300,16 @@ 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 injectedWriteClosure = _testInjections.value.writeClosure { - try await injectedWriteClosure(value, .withoutResponse) - return + if let testInjection = _testInjections.value { + if let injectedWriteClosure = testInjection.writeClosure { + try await injectedWriteClosure(value, .withoutResponse) + return + } + + if testInjection.simulatePeripheral { + inject(value) + return + } } guard let injection else { @@ -262,7 +339,7 @@ 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 = _testInjections.value?.requestClosure { return try await injectedRequestClosure(value) } @@ -278,6 +355,22 @@ extension CharacteristicAccessor where Value: ControlPointCharacteristic { @_spi(TestingSupport) extension CharacteristicAccessor { + /// Enable testing support for subscriptions and onChange handlers. + /// + /// After this method is called, subsequent calls to ``subscription`` and ``onChange(initial:perform:)-6ltwk`` or ``onChange(initial:perform:)-5awby`` + /// 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() + } + + /// 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 + } + /// Inject a custom value for previewing purposes. /// /// This method can be used to inject a custom characteristic value. @@ -289,6 +382,10 @@ extension CharacteristicAccessor { /// - Parameter value: The value to inject. public func inject(_ value: Value) { _value.value = value + + if let subscriptions = _testInjections.value?.subscriptions { + subscriptions.notifySubscribers(with: value) + } } /// Inject a custom action that sinks all write operations for testing purposes. @@ -298,7 +395,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.value.writeClosure = action + _testInjections.valueOrInitialize.writeClosure = action } /// Inject a custom action that sinks all read operations for testing purposes. @@ -308,7 +405,7 @@ extension CharacteristicAccessor { /// /// - Parameter action: The action to inject. Called for every read. public func onRead(return action: @escaping () async throws -> Value) { - _testInjections.value.readClosure = action + _testInjections.valueOrInitialize.readClosure = action } /// Inject a custom action that sinks all control point request operations for testing purposes. @@ -318,6 +415,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.value.requestClosure = action + _testInjections.valueOrInitialize.requestClosure = action } } diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift index 4302a75f..e5a32f41 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift @@ -8,6 +8,7 @@ import ByteCoding import CoreBluetooth +import SpeziFoundation private protocol DecodableCharacteristic: Actor { @@ -23,7 +24,8 @@ private protocol PrimitiveDecodableCharacteristic { actor CharacteristicPeripheralInjection: BluetoothActor { let bluetoothQueue: DispatchSerialQueue - let peripheral: BluetoothPeripheral + private let bluetooth: Bluetooth + fileprivate let peripheral: BluetoothPeripheral let serviceId: CBUUID let characteristicId: CBUUID @@ -37,8 +39,14 @@ actor CharacteristicPeripheralInjection: BluetoothActor { /// Fore more information see ``ControlPointCharacteristic``. private var controlPointTransaction: ControlPointTransaction? - /// The user supplied onChange closure we use to forward notifications. - private var onChangeClosure: ChangeClosureState + /// Manages the user supplied subscriptions to the value. + private let subscriptions: ChangeSubscriptions + /// We track all onChange closure registrations with `initial=false` to make sure to not call them with the initial value. + /// The property is set to nil, once the initial value arrived. + /// + /// The initial value might only arrive later (e.g., only once the device is connected). Therefore, we need to keep track what handlers to call and which not while we are still waiting. + private var nonInitialChangeHandlers: Set? = [] // swiftlint:disable:this discouraged_optional_collection + /// The registration object we received from the ``BluetoothPeripheral`` for our instance onChange handler. private var instanceRegistration: OnChangeRegistration? /// The registration object we received from the ``BluetoothPeripheral`` for our value onChange handler. @@ -69,20 +77,21 @@ actor CharacteristicPeripheralInjection: BluetoothActor { init( + bluetooth: Bluetooth, peripheral: BluetoothPeripheral, serviceId: CBUUID, characteristicId: CBUUID, value: ObservableBox, - characteristic: GATTCharacteristic?, - onChangeClosure: OnChangeClosure? + characteristic: GATTCharacteristic? ) { + self.bluetooth = bluetooth self.bluetoothQueue = peripheral.bluetoothQueue self.peripheral = peripheral self.serviceId = serviceId self.characteristicId = characteristicId self._value = value self._characteristic = .init(characteristic) - self.onChangeClosure = onChangeClosure.map { .value($0) } ?? .none + self.subscriptions = ChangeSubscriptions(queue: peripheral.bluetoothQueue) } /// Setup the injection. Must be called after initialization to set up all handlers and write the initial value. @@ -108,28 +117,28 @@ actor CharacteristicPeripheralInjection: BluetoothActor { } } - /// Signal from the Bluetooth state to cleanup the device - func clearState() { - self.instanceRegistration?.cancel() - self.instanceRegistration = nil - self.valueRegistration?.cancel() - self.valueRegistration = nil - self.onChangeClosure = .cleared // might contain a self reference, so we need to clear that! + nonisolated func newSubscription() -> AsyncStream { + subscriptions.newSubscription() } + nonisolated func newOnChangeSubscription(initial: Bool, perform action: @escaping (_ oldValue: Value, _ newValue: Value) async -> Void) { + let id = subscriptions.newOnChangeSubscription(perform: action) - func setOnChangeClosure(_ closure: OnChangeClosure) { - if case .cleared = onChangeClosure { - // object is about to be cleared. Make sure we don't create a self reference last minute. - return + // Must be called detached, otherwise it might inherit TaskLocal values which includes Spezi moduleInitContext + // which would create a strong reference to the device. + Task.detached { @SpeziBluetooth in + await self.handleInitialCall(id: id, initial: initial, action: action) } - self.onChangeClosure = .value(closure) + } - // if configured as initial, and there is a value, we notify - if let value, closure.initial { - Task { @SpeziBluetooth in - await closure(value) + private func handleInitialCall(id: UUID, initial: Bool, action: (_ oldValue: Value, _ newValue: Value) async -> Void) async { + if nonInitialChangeHandlers != nil { + if !initial { + nonInitialChangeHandlers?.insert(id) } + } else if initial, let 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) } } @@ -212,14 +221,9 @@ actor CharacteristicPeripheralInjection: BluetoothActor { } } - private func dispatchChangeHandler(previous previousValue: Value?, new newValue: Value, with onChangeClosure: ChangeClosureState) async { - guard case let .value(closure) = onChangeClosure else { - return - } - if closure.initial || previousValue != nil { - await closure(newValue) - } + deinit { + bluetooth.notifyDeviceDeinit(for: peripheral.id) } } @@ -232,15 +236,11 @@ extension CharacteristicPeripheralInjection: DecodableCharacteristic where Value return } - let previousValue = self.value self.value = value - self.fullFillControlPointRequest(value) - let onChangeClosure = onChangeClosure // make sure we capture it now, not later where it might have changed. - Task { @SpeziBluetooth in - await self.dispatchChangeHandler(previous: previousValue, new: value, with: onChangeClosure) - } + self.subscriptions.notifySubscribers(with: value, ignoring: nonInitialChangeHandlers ?? []) + nonInitialChangeHandlers = nil } else { self.value = nil } @@ -341,31 +341,28 @@ extension CharacteristicPeripheralInjection where Value: ControlPointCharacteris let transaction = ControlPointTransaction() self.controlPointTransaction = transaction + defer { + if controlPointTransaction?.id == transaction.id { + controlPointTransaction = nil + } + } + + // make sure we are ready to receive the response async let response = controlPointContinuationTask(transaction) do { try await write(value) } catch { transaction.signalCancellation() - resetControlPointTransaction(with: transaction.id) - _ = try? await response + _ = try? await response // await response to avoid cancellation throw error } - let timeoutTask = Task { - try? await Task.sleep(for: timeout) - if !Task.isCancelled { - transaction.signalTimeout() - resetControlPointTransaction(with: transaction.id) - } - } - - defer { - timeoutTask.cancel() + async let _ = withTimeout(of: timeout) { + transaction.signalTimeout() } - return try await response } @@ -376,15 +373,6 @@ extension CharacteristicPeripheralInjection where Value: ControlPointCharacteris } } onCancel: { transaction.signalCancellation() - Task { - await resetControlPointTransaction(with: transaction.id) - } - } - } - - private func resetControlPointTransaction(with id: UUID) { - if controlPointTransaction?.id == id { - self.controlPointTransaction = nil } } } diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/ClosureRegistrar.swift b/Sources/SpeziBluetooth/Model/PropertySupport/ClosureRegistrar.swift deleted file mode 100644 index dcd47fd5..00000000 --- a/Sources/SpeziBluetooth/Model/PropertySupport/ClosureRegistrar.swift +++ /dev/null @@ -1,39 +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 - - -/// Tracking notification closure registrations for ``Characteristic`` when peripheral is not available yet. -final class ClosureRegistrar { - struct Entry { - let closure: OnChangeClosure - } - - // task local value ensures nobody is interfering here and resolves thread safety - // we maintain two different states for different processes (init vs. setup). - @TaskLocal static var writeableView: ClosureRegistrar? - @TaskLocal static var readableView: ClosureRegistrar? - - - private var registrations: [ObjectIdentifier: Any] = [:] - - init() {} - - func insert(for object: ObjectIdentifier, closure: OnChangeClosure) { - registrations[object] = Entry(closure: closure) - } - - func retrieve(for object: ObjectIdentifier, value: Value.Type = Value.self) -> OnChangeClosure? { - guard let optionalEntry = registrations[object], - let entry = optionalEntry as? Entry else { - return nil - } - return entry.closure - } -} diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceActionPeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceActionPeripheralInjection.swift new file mode 100644 index 00000000..e9f28ac8 --- /dev/null +++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceActionPeripheralInjection.swift @@ -0,0 +1,24 @@ +// +// 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 +// + + +class DeviceActionPeripheralInjection { + private let bluetooth: Bluetooth + let peripheral: BluetoothPeripheral + + + init(bluetooth: Bluetooth, peripheral: BluetoothPeripheral) { + self.bluetooth = bluetooth + self.peripheral = peripheral + } + + + deinit { + bluetooth.notifyDeviceDeinit(for: peripheral.id) + } +} diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift index c3fc943d..68610afc 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift @@ -6,6 +6,57 @@ // 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. /// @@ -14,56 +65,121 @@ /// ## Topics /// /// ### Get notified about changes -/// - ``onChange(initial:perform:)`` +/// - ``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, injection: DeviceStatePeripheralInjection?, injectedValue: ObservableBox) { + 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 } +} + + +extension DeviceStateAccessor { + /// Retrieve a subscription to changes to the device state. + /// + /// This property creates an AsyncStream that yields all future updates to the device state. + public var subscription: AsyncStream { + if let subscriptions = _testInjections.value?.subscriptions { + return subscriptions.newSubscription() + } + guard let injection else { + preconditionFailure( + "The `subscription` of a @DeviceState cannot be accessed within the initializer. Defer access to the `configure() method" + ) + } + return injection.newSubscription() + } /// Perform action whenever the state value changes. /// - /// - Important: This closure is called from the Bluetooth Serial Executor, if you don't pass in an async method + /// Register a change handler with the device state that is called every time the value changes. + /// + /// - Note: `onChange` handlers are bound to the lifetime of the device. If you need to control the lifetime yourself refer to using ``subscription``. + /// + /// Note that you cannot set up onChange handlers within the initializers. + /// Use the [`configure()`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module/configure()-5pa83) to set up + /// all your handlers. + /// - Important: You must capture `self` weakly only. Capturing `self` strongly causes a memory leak. + /// + /// - Note: This closure is called from the Bluetooth Serial Executor, if you don't pass in an async method /// that has an annotated actor isolation (e.g., `@MainActor` or actor isolated methods). /// - /// - Note: It is perfectly fine if you capture strongly self within your closure. The framework will - /// resolve any reference cycles for you. /// - 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) { - let closure = OnChangeClosure(initial: initial, closure: action) + onChange(initial: true) { _, newValue in + await action(newValue) + } + } - guard let injection else { - guard let closures = ClosureRegistrar.writeableView else { - Bluetooth.logger.warning( - """ - Tried to register onChange(perform:) closure out-of-band. Make sure to register your onChange closure \ - within the initializer or when the peripheral is fully injected. This is expected if you manually initialized your device. \ - The closure was discarded and won't have any effect. - """ - ) - return + /// Perform action whenever the state value changes. + /// + /// Register a change handler with the device state that is called every time the value changes. + /// + /// - Note: `onChange` handlers are bound to the lifetime of the device. If you need to control the lifetime yourself refer to using ``subscription``. + /// + /// Note that you cannot set up onChange handlers within the initializers. + /// Use the [`configure()`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module/configure()-5pa83) to set up + /// all your handlers. + /// - Important: You must capture `self` weakly only. Capturing `self` strongly causes a memory leak. + /// + /// - Note: This closure is called from the Bluetooth Serial Executor, 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, + let subscriptions = testInjections.subscriptions { + let id = subscriptions.newOnChangeSubscription(perform: action) + + if initial, let value = _injectedValue.value ?? testInjections.artificialValue(for: keyPath) { + // if there isn't a value already, initial won't work properly with injections + subscriptions.notifySubscriber(id: id, with: value) } - // Similar to CharacteristicAccessor/onChange(perform:), we save it in a global registrar - // to avoid reference cycles we can't control. - closures.insert(for: id, closure: closure) return } - // global actor ensures these tasks are queued serially and are executed in order. - Task { @SpeziBluetooth in - await injection.setOnChangeClosure(closure) + guard let injection else { + preconditionFailure( + """ + Register onChange(perform:) inside the initializer is not supported anymore. \ + Further, they no longer support capturing `self` without causing a memory leak. \ + Please migrate your code to register onChange listeners in the `configure()` method and make sure to weakly capture self. + + func configure() { + $state.onChange { [weak self] value in + self?.handleStateChange(value) + } + } + """ + ) } + + injection.newOnChangeSubscription(initial: initial, perform: action) } } @@ -75,6 +191,15 @@ extension DeviceStateAccessor: @unchecked Sendable {} @_spi(TestingSupport) extension DeviceStateAccessor { + /// Enable testing support for subscriptions and onChange handlers. + /// + /// After this method is called, subsequent calls to ``subscription`` and ``onChange(initial:perform:)-6ltwk`` or ``onChange(initial:perform:)-5awby`` + /// 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() + } + /// Inject a custom value for previewing purposes. /// /// This method can be used to inject a custom device state value. @@ -86,5 +211,9 @@ extension DeviceStateAccessor { /// - Parameter value: The value to inject. public func inject(_ value: Value) { _injectedValue.value = value + + if let subscriptions = _testInjections.value?.subscriptions { + subscriptions.notifySubscribers(with: value) + } } } diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift index ca3fdef5..100a9495 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift @@ -12,18 +12,24 @@ import Foundation actor DeviceStatePeripheralInjection: BluetoothActor { let bluetoothQueue: DispatchSerialQueue - let peripheral: BluetoothPeripheral + private let bluetooth: Bluetooth + private let peripheral: BluetoothPeripheral private let accessKeyPath: KeyPath private let observationKeyPath: KeyPath? - private var onChangeClosure: ChangeClosureState + private let subscriptions: ChangeSubscriptions + + nonisolated var value: Value { + peripheral[keyPath: accessKeyPath] + } - init(peripheral: BluetoothPeripheral, keyPath: KeyPath, onChangeClosure: OnChangeClosure?) { + init(bluetooth: Bluetooth, peripheral: BluetoothPeripheral, keyPath: KeyPath) { + self.bluetooth = bluetooth self.bluetoothQueue = peripheral.bluetoothQueue self.peripheral = peripheral self.accessKeyPath = keyPath self.observationKeyPath = keyPath.storageEquivalent() - self.onChangeClosure = onChangeClosure.map { .value($0) } ?? .none + self.subscriptions = ChangeSubscriptions(queue: peripheral.bluetoothQueue) } func setup() { @@ -35,8 +41,6 @@ actor DeviceStatePeripheralInjection: BluetoothActor { return } - dispatchOnChangeWithInitialValue() - peripheral.assumeIsolated { peripheral in peripheral.onChange(of: observationKeyPath) { [weak self] value in guard let self = self else { @@ -46,62 +50,39 @@ actor DeviceStatePeripheralInjection: BluetoothActor { self.assumeIsolated { injection in injection.trackStateUpdate() - // The onChange handler of global Bluetooth module is called right after this to clear this - // injection if the state changed to `disconnected`. So we must capture the onChangeClosure before - // that to still be able to deliver `disconnected` events. - let onChangeClosure = injection.onChangeClosure - Task { @SpeziBluetooth in - await injection.dispatchChangeHandler(value, with: onChangeClosure) - } + self.subscriptions.notifySubscribers(with: value) } } } } - /// Returns once the change handler completes. - private func dispatchChangeHandler(_ value: Value, with onChangeClosure: ChangeClosureState, isInitial: Bool = false) async { - guard case let .value(closure) = onChangeClosure else { - return - } - - if closure.initial || !isInitial { - await closure(value) - } + nonisolated func newSubscription() -> AsyncStream { + subscriptions.newSubscription() } - func setOnChangeClosure(_ closure: OnChangeClosure) { - if case .cleared = onChangeClosure { - // object is about to be cleared. Make sure we don't create a self reference last minute. - return - } - - self.onChangeClosure = .value(closure) - dispatchOnChangeWithInitialValue() - } + nonisolated func newOnChangeSubscription(initial: Bool, perform action: @escaping (_ oldValue: Value, _ newValue: Value) async -> Void) { + let id = subscriptions.newOnChangeSubscription(perform: action) - private func dispatchOnChangeWithInitialValue() { - // For most values, this just delivers a nil value (e.g., name or localName). - // However, there might be a use case to retrieve the initial value for the deviceState or advertisement data. - let value = peripheral[keyPath: accessKeyPath] - Task { @SpeziBluetooth in - await dispatchChangeHandler(value, with: onChangeClosure, isInitial: true) + if initial { + let value = peripheral[keyPath: accessKeyPath] + subscriptions.notifySubscriber(id: id, with: value) } } - /// Remove any onChangeClosure and mark injection as cleared. - /// - /// This important to ensure to clear any potential reference cycles because of a captured self in the closure. - func clearOnChangeClosure() { - onChangeClosure = .cleared + deinit { + bluetooth.notifyDeviceDeinit(for: peripheral.id) } } extension KeyPath where Root == BluetoothPeripheral { + // swiftlint:disable:next cyclomatic_complexity func storageEquivalent() -> KeyPath? { let anyKeyPath: AnyKeyPath? = switch self { case \.name: \PeripheralStorage.name + case \.localName: + \PeripheralStorage.localName case \.rssi: \PeripheralStorage.rssi case \.advertisementData: @@ -110,6 +91,10 @@ extension KeyPath where Root == BluetoothPeripheral { \PeripheralStorage.state case \.services: \PeripheralStorage.services + case \.nearby: + \PeripheralStorage.nearby + case \.lastActivity: + \PeripheralStorage.lastActivity case \.id: nil default: diff --git a/Sources/SpeziBluetooth/Model/SemanticModel/ClearStateDeviceVisitor.swift b/Sources/SpeziBluetooth/Model/SemanticModel/ClearStateDeviceVisitor.swift deleted file mode 100644 index cee7f6b4..00000000 --- a/Sources/SpeziBluetooth/Model/SemanticModel/ClearStateDeviceVisitor.swift +++ /dev/null @@ -1,47 +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 - - -private struct ClearStateServiceVisitor: ServiceVisitor { - func visit(_ characteristic: Characteristic) { - characteristic.injection?.assumeIsolated { injection in - injection.clearState() - } - } - - func visit(_ state: DeviceState) { - state.injection?.assumeIsolated { injection in - injection.clearOnChangeClosure() - } - } -} - - -private struct ClearStateDeviceVisitor: DeviceVisitor { - func visit(_ service: Service) { - var visitor = ClearStateServiceVisitor() - service.wrappedValue.accept(&visitor) - } - - func visit(_ state: DeviceState) { - state.injection?.assumeIsolated { injection in - injection.clearOnChangeClosure() - } - } -} - - -extension BluetoothDevice { - func clearState(isolatedTo bluetooth: isolated Bluetooth) { - bluetooth.bluetoothQueue.assertIsolated("ClearStateDeviceVisitor must be called within the Bluetooth SerialExecutor!") - var visitor = ClearStateDeviceVisitor() - accept(&visitor) - } -} diff --git a/Sources/SpeziBluetooth/Model/SemanticModel/DeviceDescriptionParser.swift b/Sources/SpeziBluetooth/Model/SemanticModel/DeviceDescriptionParser.swift index ee0ff3ea..fa062ce8 100644 --- a/Sources/SpeziBluetooth/Model/SemanticModel/DeviceDescriptionParser.swift +++ b/Sources/SpeziBluetooth/Model/SemanticModel/DeviceDescriptionParser.swift @@ -29,25 +29,33 @@ private struct ServiceDescriptionBuilder: DeviceVisitor { } -extension DiscoveryConfiguration { - func parseDeviceDescription() -> DeviceDescription { - let device = anyDeviceType.init() +extension BluetoothDevice { + static func parseDeviceDescription() -> DeviceDescription { + let device = Self() var builder = ServiceDescriptionBuilder() device.accept(&builder) - return DeviceDescription(discoverBy: discoveryCriteria, services: builder.configurations) + return DeviceDescription(services: builder.configurations) } } -extension Set where Element == DiscoveryConfiguration { +extension DeviceDiscoveryDescriptor { + func parseDiscoveryDescription() -> DiscoveryDescription { + let deviceDescription = deviceType.parseDeviceDescription() + return DiscoveryDescription(discoverBy: discoveryCriteria, device: deviceDescription) + } +} + + +extension Set where Element == DeviceDiscoveryDescriptor { var deviceTypes: [any BluetoothDevice.Type] { map { configuration in - configuration.anyDeviceType + configuration.deviceType } } - func parseDeviceDescription() -> Set { - Set(map { $0.parseDeviceDescription() }) + func parseDiscoveryDescription() -> Set { + Set(map { $0.parseDiscoveryDescription() }) } } diff --git a/Sources/SpeziBluetooth/Model/SemanticModel/SetupDeviceVisitor.swift b/Sources/SpeziBluetooth/Model/SemanticModel/SetupDeviceVisitor.swift index ff55e437..12f484b7 100644 --- a/Sources/SpeziBluetooth/Model/SemanticModel/SetupDeviceVisitor.swift +++ b/Sources/SpeziBluetooth/Model/SemanticModel/SetupDeviceVisitor.swift @@ -10,37 +10,48 @@ import CoreBluetooth private struct SetupServiceVisitor: ServiceVisitor { + private let bluetooth: Bluetooth private let peripheral: BluetoothPeripheral private let serviceId: CBUUID private let service: GATTService? + private let didInjectAnything: Box - init(peripheral: BluetoothPeripheral, serviceId: CBUUID, service: GATTService?) { + init(bluetooth: Bluetooth, peripheral: BluetoothPeripheral, serviceId: CBUUID, service: GATTService?, didInjectAnything: Box) { + self.bluetooth = bluetooth self.peripheral = peripheral self.serviceId = serviceId self.service = service + self.didInjectAnything = didInjectAnything } func visit(_ characteristic: Characteristic) { - characteristic.inject(peripheral: peripheral, serviceId: serviceId, service: service) + characteristic.inject(bluetooth: bluetooth, peripheral: peripheral, serviceId: serviceId, service: service) + didInjectAnything.value = true } func visit(_ action: DeviceAction) { - action.inject(peripheral: peripheral) + action.inject(bluetooth: bluetooth, peripheral: peripheral) + didInjectAnything.value = true } func visit(_ state: DeviceState) { - state.inject(peripheral: peripheral) + state.inject(bluetooth: bluetooth, peripheral: peripheral) + didInjectAnything.value = true } } private struct SetupDeviceVisitor: DeviceVisitor { + private let bluetooth: Bluetooth private let peripheral: BluetoothPeripheral + private let didInjectAnything: Box - init(peripheral: BluetoothPeripheral) { + init(bluetooth: Bluetooth, peripheral: BluetoothPeripheral, didInjectAnything: Box) { + self.bluetooth = bluetooth self.peripheral = peripheral + self.didInjectAnything = didInjectAnything } @@ -48,24 +59,38 @@ private struct SetupDeviceVisitor: DeviceVisitor { let blService = peripheral.assumeIsolated { $0.getService(id: service.id) } service.inject(peripheral: peripheral, service: blService) - var visitor = SetupServiceVisitor(peripheral: peripheral, serviceId: service.id, service: blService) + var visitor = SetupServiceVisitor( + bluetooth: bluetooth, + peripheral: peripheral, + serviceId: service.id, + service: blService, + didInjectAnything: didInjectAnything + ) service.wrappedValue.accept(&visitor) } func visit(_ action: DeviceAction) { - action.inject(peripheral: peripheral) + action.inject(bluetooth: bluetooth, peripheral: peripheral) + didInjectAnything.value = true } func visit(_ state: DeviceState) { - state.inject(peripheral: peripheral) + state.inject(bluetooth: bluetooth, peripheral: peripheral) + didInjectAnything.value = true } } extension BluetoothDevice { - func inject(peripheral: BluetoothPeripheral) { + func inject(peripheral: BluetoothPeripheral, using bluetooth: Bluetooth) -> Bool { peripheral.bluetoothQueue.assertIsolated("SetupDeviceVisitor must be called within the Bluetooth SerialExecutor!") - var visitor = SetupDeviceVisitor(peripheral: peripheral) + + // if we don't inject anything, we do not need to retain the device + let didInjectAnything = Box(false) + + var visitor = SetupDeviceVisitor(bluetooth: bluetooth, peripheral: peripheral, didInjectAnything: didInjectAnything) accept(&visitor) + + return didInjectAnything.value } } diff --git a/Sources/SpeziBluetooth/Modifier/AutoConnectModifier.swift b/Sources/SpeziBluetooth/Modifier/AutoConnectModifier.swift new file mode 100644 index 00000000..4fe3e007 --- /dev/null +++ b/Sources/SpeziBluetooth/Modifier/AutoConnectModifier.swift @@ -0,0 +1,75 @@ +// +// 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 + + +extension View { + /// Scan for nearby Bluetooth devices and auto connect. + /// + /// Scans for nearby Bluetooth devices till a device to auto connect to is discovered. + /// Device scanning is automatically started again if the device happens to disconnect. + /// + /// Scans on nearby devices based on the ``Discover`` declarations provided in the initializer. + /// + /// All discovered devices for a given type can be accessed through the ``Bluetooth/nearbyDevices(for:)`` method. + /// The first connected device can be accessed through the + /// [Environment(_:)](https://developer.apple.com/documentation/swiftui/environment/init(_:)-8slkf) in your SwiftUI view. + /// + /// - Parameters: + /// - enabled: Flag indicating if nearby device scanning is enabled. + /// - bluetooth: The Bluetooth Module to use for scanning. + /// - 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. + /// - Returns: The modified view. + public func autoConnect( // swiftlint:disable:this function_default_parameter_at_end + enabled: Bool = true, + with bluetooth: Bluetooth, + minimumRSSI: Int? = nil, + advertisementStaleInterval: TimeInterval? = nil + ) -> some View { + scanNearbyDevices(enabled: enabled && !bluetooth.hasConnectedDevices, scanner: bluetooth, state: BluetoothModuleDiscoveryState( + minimumRSSI: minimumRSSI, + advertisementStaleInterval: advertisementStaleInterval, + autoConnect: true + )) + } + + + /// Scan for nearby Bluetooth devices and auto connect. + /// + /// Scans for nearby Bluetooth devices till a device to auto connect to is discovered. + /// Device scanning is automatically started again if the device happens to disconnect. + /// + /// Scans on nearby devices based on the ``DiscoveryDescription`` provided in the initializer. + /// All discovered devices can be accessed through the ``BluetoothManager/nearbyPeripherals`` property. + /// + /// - Parameters: + /// - enabled: Flag indicating if nearby device scanning is enabled. + /// - bluetoothManager: The Bluetooth Manager to use for scanning. + /// - discovery: The set of device description describing **how** and **what** to discover. + /// - 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. + /// - Returns: The modified view. + public func autoConnect( // swiftlint:disable:this function_default_parameter_at_end + enabled: Bool = true, + with bluetoothManager: BluetoothManager, + discovery: Set, + minimumRSSI: Int? = nil, + advertisementStaleInterval: TimeInterval? = nil + ) -> some View { + scanNearbyDevices(enabled: enabled && !bluetoothManager.hasConnectedDevices, scanner: bluetoothManager, state: BluetoothManagerDiscoveryState( + configuredDevices: discovery, + minimumRSSI: minimumRSSI, + advertisementStaleInterval: advertisementStaleInterval, + autoConnect: true + )) + } +} diff --git a/Sources/SpeziBluetooth/Modifier/BluetoothScanner.swift b/Sources/SpeziBluetooth/Modifier/BluetoothScanner.swift new file mode 100644 index 00000000..ad2728c1 --- /dev/null +++ b/Sources/SpeziBluetooth/Modifier/BluetoothScanner.swift @@ -0,0 +1,43 @@ +// +// 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 BluetoothScanningState: Equatable { + /// 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 + + func updateOptions(minimumRSSI: Int?, advertisementStaleInterval: TimeInterval?) -> Self +} + + +/// Any kind of Bluetooth Scanner. +protocol BluetoothScanner: Identifiable 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 } + + /// Scan for nearby bluetooth devices. + /// + /// How devices are discovered and how they can be accessed is implementation defined. + /// + /// - Parameter state: The scanning state. + func scanNearbyDevices(_ state: ScanningState) async + + /// Update the `ScanningState` for an currently ongoing scanning session. + func updateScanningState(_ state: ScanningState) async + + /// Stop scanning for nearby bluetooth devices. + func stopScanning() async +} diff --git a/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift b/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift index ae7fb438..73ac328b 100644 --- a/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift +++ b/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift @@ -10,7 +10,7 @@ import SwiftUI private struct ConnectedDeviceEnvironmentModifier: ViewModifier { - @Environment(ConnectedDevices.self) + @Environment(ConnectedDevicesModel.self) var connectedDevices init() {} @@ -18,10 +18,16 @@ private struct ConnectedDeviceEnvironmentModifier: View func body(content: Content) -> some View { let connectedDeviceAny = connectedDevices[ObjectIdentifier(Device.self)] - let connectedDevice = connectedDeviceAny as? Device + let firstConnectedDevice = connectedDeviceAny.first as? Device + let connectedDevicesList = connectedDeviceAny.compactMap { device in + device as? Device + } + + let devicesList = ConnectedDevices(connectedDevicesList) content - .environment(connectedDevice) + .environment(firstConnectedDevice) + .environment(devicesList) } } @@ -29,7 +35,7 @@ private struct ConnectedDeviceEnvironmentModifier: View struct ConnectedDevicesEnvironmentModifier: ViewModifier { private let configuredDeviceTypes: [any BluetoothDevice.Type] - @Environment(ConnectedDevices.self) + @Environment(ConnectedDevicesModel.self) var connectedDevices diff --git a/Sources/SpeziBluetooth/Modifier/DeviceAutoConnectModifier.swift b/Sources/SpeziBluetooth/Modifier/DeviceAutoConnectModifier.swift deleted file mode 100644 index a07b18a3..00000000 --- a/Sources/SpeziBluetooth/Modifier/DeviceAutoConnectModifier.swift +++ /dev/null @@ -1,48 +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 SwiftUI - - -private struct DeviceAutoConnectModifier: ViewModifier { - private let enabled: Bool - private let scanner: Scanner - - private var shouldScan: Bool { - enabled && !scanner.hasConnectedDevices - } - - init(enabled: Bool, scanner: Scanner) { - self.enabled = enabled - self.scanner = scanner - } - - func body(content: Content) -> some View { - content - .scanNearbyDevices(enabled: shouldScan, with: scanner, autoConnect: true) - } -} - - -extension View { - /// Scan for nearby Bluetooth devices and auto connect. - /// - /// Scans for nearby Bluetooth devices till a device to auto connect to is discovered. - /// Device scanning is automatically started again if the device happens to disconnect. - /// - /// How nearby devices are accessed depends on the passed ``BluetoothScanner`` implementation. - /// - /// - Parameters: - /// - enabled: Flag indicating if nearby device scanning is enabled. - /// - scanner: The Bluetooth Manager to use for scanning. - /// - Returns: THe modified view. - public func autoConnect(enabled: Bool = false, with scanner: Scanner) -> some View { - // swiftlint:disable:previous function_default_parameter_at_end - modifier(DeviceAutoConnectModifier(enabled: enabled, scanner: scanner)) - } -} diff --git a/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift b/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift index d7f600a4..75eb846a 100644 --- a/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift +++ b/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift @@ -13,19 +13,24 @@ import SwiftUI private struct ScanNearbyDevicesModifier: ViewModifier { private let enabled: Bool private let scanner: Scanner - private let autoConnect: Bool + private let state: Scanner.ScanningState @Environment(\.scenePhase) private var scenePhase @Environment(\.surroundingScanModifiers) private var surroundingModifiers + @Environment(\.minimumRSSI) + private var minimumRSSI + @Environment(\.advertisementStaleInterval) + private var advertisementStaleInterval + @State private var modifierId = UUID() - init(enabled: Bool, scanner: Scanner, autoConnect: Bool) { + init(enabled: Bool, scanner: Scanner, state: Scanner.ScanningState) { self.enabled = enabled self.scanner = scanner - self.autoConnect = autoConnect + self.state = state } func body(content: Content) -> some View { @@ -47,9 +52,27 @@ private struct ScanNearbyDevicesModifier: ViewModifie onBackground() } } - .onChange(of: autoConnect, initial: false) { + .onChange(of: state, initial: false) { + if enabled { + updateScanningState(enabled: enabled) + } + } + .onChange(of: minimumRSSI) { + if enabled { + updateScanningState(enabled: enabled) + } + } + .onChange(of: advertisementStaleInterval) { + if enabled { + updateScanningState(enabled: enabled) + } + } + .onChange(of: surroundingModifiers.retrieveReducedScanningState(for: scanner)) { _, newValue in + guard let newValue else { + return + } Task { - await scanner.setAutoConnect(autoConnect) + await scanner.updateScanningState(newValue) } } } @@ -57,16 +80,16 @@ private struct ScanNearbyDevicesModifier: ViewModifie @MainActor private func onForeground() { if enabled { - surroundingModifiers.setModifierScanningState(enabled: true, with: scanner, modifierId: modifierId) + updateScanningState(enabled: true) Task { - await scanner.scanNearbyDevices(autoConnect: autoConnect) + await scanner.scanNearbyDevices(state) } } } @MainActor private func onBackground() { - surroundingModifiers.setModifierScanningState(enabled: false, with: scanner, modifierId: modifierId) + updateScanningState(enabled: false) if surroundingModifiers.hasPersistentInterest(for: scanner) { return // don't stop scanning if a surrounding modifier is expecting a scan to continue @@ -76,12 +99,28 @@ private struct ScanNearbyDevicesModifier: ViewModifie await scanner.stopScanning() } } + + @MainActor + private func updateScanningState(enabled: Bool) { + let state = state.updateOptions(minimumRSSI: minimumRSSI, advertisementStaleInterval: advertisementStaleInterval) + surroundingModifiers.setModifierScanningState(enabled: enabled, with: scanner, modifierId: modifierId, state: state) + } } extension View { + func scanNearbyDevices(enabled: Bool, scanner: Scanner, state: Scanner.ScanningState) -> some View { + modifier(ScanNearbyDevicesModifier(enabled: enabled, scanner: scanner, state: state)) + } + /// Scan for nearby Bluetooth devices. /// + /// Scans on nearby devices based on the ``Discover`` declarations provided in the initializer. + /// + /// All discovered devices for a given type can be accessed through the ``Bluetooth/nearbyDevices(for:)`` method. + /// The first connected device can be accessed through the + /// [Environment(_:)](https://developer.apple.com/documentation/swiftui/environment/init(_:)-8slkf) in your SwiftUI view. + /// /// Nearby device search is automatically paused when the view disappears or if the app enters background and /// is automatically started again when the view appears or the app enters the foreground again. /// Further, scanning is automatically started if Bluetooth is turned on by the user while the view was already presented. @@ -90,22 +129,67 @@ extension View { /// discovered for a short period in time. /// /// - Tip: If you want to continuously search for auto-connectable device in the background, - /// you might want to use the ``SwiftUI/View/autoConnect(enabled:with:)`` modifier instead. - /// - /// How nearby devices are accessed depends on the passed ``BluetoothScanner`` implementation. + /// you might want to use the ``SwiftUI/View/autoConnect(enabled:with:minimumRSSI:advertisementStaleInterval:)`` modifier instead. /// /// - Parameters: /// - enabled: Flag indicating if nearby device scanning is enabled. - /// - scanner: The Bluetooth Manager to use for scanning. + /// - bluetooth: The Bluetooth Module to use for scanning. + /// - 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. /// - autoConnect: If enabled, the bluetooth manager will automatically connect to the nearby device if only one is found. /// - Returns: The modified view. + public func scanNearbyDevices( // swiftlint:disable:this function_default_parameter_at_end + enabled: Bool = true, + with bluetooth: Bluetooth, + minimumRSSI: Int? = nil, + advertisementStaleInterval: TimeInterval? = nil, + autoConnect: Bool = false + ) -> some View { + scanNearbyDevices(enabled: enabled, scanner: bluetooth, state: BluetoothModuleDiscoveryState( + minimumRSSI: minimumRSSI, + advertisementStaleInterval: advertisementStaleInterval, + autoConnect: autoConnect + )) + } + + /// Scan for nearby Bluetooth devices. /// - /// ## Topics + /// Scans on nearby devices based on the ``DiscoveryDescription`` provided in the initializer. + /// All discovered devices can be accessed through the ``BluetoothManager/nearbyPeripherals`` property. /// - /// ### Bluetooth Scanner - /// - ``BluetoothScanner`` - public func scanNearbyDevices(enabled: Bool = true, with scanner: Scanner, autoConnect: Bool = false) -> some View { - // swiftlint:disable:previous function_default_parameter_at_end - modifier(ScanNearbyDevicesModifier(enabled: enabled, scanner: scanner, autoConnect: autoConnect)) + /// Nearby device search is automatically paused when the view disappears or if the app enters background and + /// is automatically started again when the view appears or the app enters the foreground again. + /// Further, scanning is automatically started if Bluetooth is turned on by the user while the view was already presented. + /// + /// The auto connect feature allows you to automatically connect to a bluetooth peripheral if it is the only device + /// discovered for a short period in time. + /// + /// - Tip: If you want to continuously search for auto-connectable device in the background, + /// you might want to use the ``SwiftUI/View/autoConnect(enabled:with:discovery:minimumRSSI:advertisementStaleInterval:)`` modifier instead. + /// + /// - Parameters: + /// - enabled: Flag indicating if nearby device scanning is enabled. + /// - bluetoothManager: The Bluetooth Manager to use for scanning. + /// - discovery: The set of device description describing **how** and **what** to discover. + /// - 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. + /// - autoConnect: If enabled, the bluetooth manager will automatically connect to the nearby device if only one is found. + /// - Returns: The modified view. + public func scanNearbyDevices( // swiftlint:disable:this function_default_parameter_at_end + enabled: Bool = true, + with bluetoothManager: BluetoothManager, + discovery: Set, + minimumRSSI: Int? = nil, + advertisementStaleInterval: TimeInterval? = nil, + autoConnect: Bool = false + ) -> some View { + scanNearbyDevices(enabled: enabled, scanner: bluetoothManager, state: BluetoothManagerDiscoveryState( + configuredDevices: discovery, + minimumRSSI: minimumRSSI, + advertisementStaleInterval: advertisementStaleInterval, + autoConnect: autoConnect + )) } } diff --git a/Sources/SpeziBluetooth/Resources/Localizable.xcstrings b/Sources/SpeziBluetooth/Resources/Localizable.xcstrings index a7631e47..fed94407 100644 --- a/Sources/SpeziBluetooth/Resources/Localizable.xcstrings +++ b/Sources/SpeziBluetooth/Resources/Localizable.xcstrings @@ -2,7 +2,14 @@ "sourceLanguage" : "en", "strings" : { "Control Point Error" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Control Point Error" + } + } + } }, "Control point request was sent to %@ on %@ but notifications weren't enabled for that characteristic." : { "localizations" : { @@ -54,17 +61,6 @@ } } }, - "The request characteristic was not present on the device." : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The request characteristic was not present on the device." - } - } - } - }, "The requested characteristic %@ on %@ was not present on the device." : { "localizations" : { "en" : { diff --git a/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md b/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md index cfb82fe2..96383eac 100644 --- a/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md +++ b/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md @@ -136,9 +136,10 @@ class ExampleDelegate: SpeziAppDelegate { Once you have the `Bluetooth` module configured within your Spezi app, you can access the module within your [`Environment`](https://developer.apple.com/documentation/swiftui/environment). -You can use the ``SwiftUI/View/scanNearbyDevices(enabled:with:autoConnect:)`` and ``SwiftUI/View/autoConnect(enabled:with:)`` +You can use the ``SwiftUI/View/scanNearbyDevices(enabled:with:minimumRSSI:advertisementStaleInterval:autoConnect:)`` +and ``SwiftUI/View/autoConnect(enabled:with:minimumRSSI:advertisementStaleInterval:)`` modifiers to scan for nearby devices and/or auto connect to the first available device. Otherwise, you can also manually start and stop scanning for nearby devices -using ``Bluetooth/scanNearbyDevices(autoConnect:)`` and ``Bluetooth/stopScanning()``. +using ``Bluetooth/scanNearbyDevices(minimumRSSI:advertisementStaleInterval:autoConnect:)`` and ``Bluetooth/stopScanning()``. To retrieve the list of nearby devices you may use ``Bluetooth/nearbyDevices(for:)``. @@ -188,6 +189,27 @@ struct MyView: View { } ``` +> Tip: Use ``ConnectedDevices`` to retrieve the full list of connected devices from the SwiftUI environment. + +#### Retrieving Devices + +The previous section explained how to discover nearby devices and retrieve the currently connected one from the environment. +This is great ad-hoc connection establishment with devices currently nearby. +However, this might not be the most efficient approach, if you want to connect to a specific, previously paired device. +In these situations you can use the ``Bluetooth/retrieveDevice(for:as:)`` method to retrieve a known device. + +Below is a short code example illustrating this method. + +```swift +let id: UUID = ... // a Bluetooth peripheral identifier (e.g., previously retrieved when pairing the device) + +let device = bluetooth.retrieveDevice(for: id, as: MyDevice.self) + +await device.connect() // assume declaration of @DeviceAction(\.connect) + +// Connect doesn't time out. Connection with the device will be established as soon as the device is in reach. +``` + ### Integration with Spezi Modules A Spezi [`Module`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module) is a great way of structuring your application into @@ -249,8 +271,11 @@ due to their async nature. ### Discovering nearby devices -- ``SwiftUI/View/scanNearbyDevices(enabled:with:autoConnect:)`` -- ``SwiftUI/View/autoConnect(enabled:with:)`` +- ``SwiftUI/View/scanNearbyDevices(enabled:with:minimumRSSI:advertisementStaleInterval:autoConnect:)`` +- ``SwiftUI/View/autoConnect(enabled:with:minimumRSSI:advertisementStaleInterval:)`` +- ``SwiftUI/EnvironmentValues/minimumRSSI`` +- ``SwiftUI/EnvironmentValues/advertisementStaleInterval`` +- ``ConnectedDevices`` ### Declaring a Bluetooth Device @@ -271,14 +296,12 @@ due to their async nature. - ``PeripheralState`` - ``BluetoothError`` - ``AdvertisementData`` +- ``ManufacturerIdentifier`` - ``WriteType`` ### Configuring Core Bluetooth +- ``DiscoveryDescription`` - ``DeviceDescription`` - ``ServiceDescription`` - ``CharacteristicDescription`` - -### Errors - -- ``ControlPointTimeoutError`` diff --git a/Sources/SpeziBluetooth/TestingSupport/Data+HexString.swift b/Sources/SpeziBluetooth/TestingSupport/Data+HexString.swift deleted file mode 100644 index 57986675..00000000 --- a/Sources/SpeziBluetooth/TestingSupport/Data+HexString.swift +++ /dev/null @@ -1,70 +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 - - -extension Data { - /// Create `Data` from a hex string. - /// - /// The hex string may be prefixed with `"0x"` or `"0X"`. - /// - Parameter hex: The hex string. - @_spi(TestingSupport) - public init?(hex: String) { - // while this seems complicated, and you can do it with shorter code, - // this doesn't incur any heap allocations for string. Pretty neat. - - var index = hex.startIndex - - let hexCount: Int - - if hex.hasPrefix("0x") || hex.hasPrefix("0X") { - index = hex.index(index, offsetBy: 2) - hexCount = hex.count - 2 - } else { - hexCount = hex.count - } - - var bytes: [UInt8] = [] - bytes.reserveCapacity(hexCount / 2 + hexCount % 2) - - if !hexCount.isMultiple(of: 2) { - guard let byte = UInt8(String(hex[index]), radix: 16) else { - return nil - } - bytes.append(byte) - - index = hex.index(after: index) - } - - - while index < hex.endIndex { - guard let byte = UInt8(hex[index ... hex.index(after: index)], radix: 16) else { - return nil - } - bytes.append(byte) - - index = hex.index(index, offsetBy: 2) - } - - guard hexCount / bytes.count == 2 else { - return nil - } - self.init(bytes) - } - - - /// Create hex string from Data. - /// - Returns: The hex formatted data string - @_spi(TestingSupport) - public func hexString() -> String { - map { character in - String(format: "%02hhx", character) - }.joined() - } -} diff --git a/Sources/SpeziBluetooth/Utils/Box.swift b/Sources/SpeziBluetooth/Utils/Box.swift index 0444be9d..9c62a26d 100644 --- a/Sources/SpeziBluetooth/Utils/Box.swift +++ b/Sources/SpeziBluetooth/Utils/Box.swift @@ -7,6 +7,8 @@ // import Observation +import Spezi +import SpeziFoundation @Observable @@ -37,3 +39,39 @@ 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/ChangeClosureState.swift b/Sources/SpeziBluetooth/Utils/ChangeClosureState.swift deleted file mode 100644 index 25d69a75..00000000 --- a/Sources/SpeziBluetooth/Utils/ChangeClosureState.swift +++ /dev/null @@ -1,39 +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 -// - - -/// A onChange closure instance. -struct OnChangeClosure { - /// The initial flag indicates if the closure should be called with the initial value - /// or strictly only if the value changes. - let initial: Bool - private let closure: (Value) async -> Void - - - init(initial: Bool, closure: @escaping (Value) async -> Void) { - self.initial = initial - self.closure = closure - } - - - func callAsFunction(_ value: Value) async { - await closure(value) - } -} - - -/// State model for an onChange closure property. -enum ChangeClosureState { - /// The is no onChange closure registered. - case none - /// The onChange closure value. - case value(OnChangeClosure) - /// The onChange closure was cleared (e.g., upon a disconnect). - /// This signals that there must not be any new onChange closure registrations to avoid reference cycles. - case cleared -} diff --git a/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift b/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift new file mode 100644 index 00000000..72d41593 --- /dev/null +++ b/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift @@ -0,0 +1,118 @@ +// +// 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 +import OrderedCollections + + +class ChangeSubscriptions: @unchecked Sendable { + private struct Registration { + let subscription: AsyncStream + let id: UUID + } + + private actor Dispatch: BluetoothActor { + let bluetoothQueue: DispatchSerialQueue + + 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) + } + + func notifySubscribers(with value: Value, ignoring: Set = []) { + for (id, continuation) in continuations where !ignoring.contains(id) { + continuation.yield(value) + } + } + + func notifySubscriber(id: UUID, with value: Value) { + continuations[id]?.yield(value) + } + + private func _newSubscription() -> Registration { + let id = UUID() + let stream = AsyncStream { continuation in + self.lock.withLock { + self.continuations[id] = continuation + } + + continuation.onTermination = { [weak self] _ in + guard let self else { + return + } + + lock.withLock { + _ = self.continuations.removeValue(forKey: id) + } + } + } + + return Registration(subscription: stream, id: id) + } + + func newSubscription() -> AsyncStream { + _newSubscription().subscription + } + + @discardableResult + func newOnChangeSubscription(perform action: @escaping (_ 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 + var currentValue: Value? + + for await element in registration.subscription { + guard self != nil else { + return + } + + await dispatch.isolated { _ in + await action(currentValue ?? element, element) + } + currentValue = element + } + + self?.lock.withLock { + _ = self?.taskHandles.removeValue(forKey: registration.id) + } + } + + lock.withLock { + taskHandles[registration.id] = task + } + + return registration.id + } + + deinit { + lock.withLock { + for continuation in continuations.values { + continuation.finish() + } + + for task in taskHandles.values { + task.cancel() + } + + continuations.removeAll() + taskHandles.removeAll() + } + } +} diff --git a/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift b/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift index b39a8cd9..578e82cf 100644 --- a/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift +++ b/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift @@ -6,45 +6,54 @@ // SPDX-License-Identifier: MIT // -import Foundation +import SwiftUI + + +/// Collection of connected devices. +/// +/// Use this type to retrieve the list of connected devices from the environment for configured ``BluetoothDevice``s. +/// +/// Below is a code example that list all connected devices of the type `MyDevice`. +/// ```swift +/// struct MyView: View { +/// @Environment(ConnectedDevices.self) +/// var connectedDevices +/// +/// var body: some View { +/// List { +/// Section("Connected Devices") { +/// ForEach(connectedDevices) { device in +/// Text("\(device.name ?? "unknown")") +/// } +/// } +/// } +/// } +/// } +/// ``` +@Observable +public final class ConnectedDevices { + private let devices: [Device] + init(_ devices: [Device] = []) { + self.devices = devices + } +} -@Observable -class ConnectedDevices { - /// We track the first connected device for every BluetoothDevice type. - @MainActor private var connectedDevices: [ObjectIdentifier: any BluetoothDevice] = [:] - @MainActor private var connectedDeviceIds: [ObjectIdentifier: UUID] = [:] - - - @MainActor - func update(with devices: [UUID: any BluetoothDevice]) { - // remove devices that disconnected - for (identifier, uuid) in connectedDeviceIds where devices[uuid] == nil { - connectedDeviceIds.removeValue(forKey: identifier) - connectedDevices.removeValue(forKey: identifier) - } - - // add newly connected devices that are not injected yet - for (uuid, device) in devices { - guard connectedDevices[device.typeIdentifier] == nil else { - continue // already present, we just inject the first device of a particular type into the environment - } - - // Newly connected device for a type that isn't present yet. Save both device and id. - connectedDevices[device.typeIdentifier] = device - connectedDeviceIds[device.typeIdentifier] = uuid - } + +extension ConnectedDevices: RandomAccessCollection { + public var startIndex: Int { + devices.startIndex } - @MainActor - subscript(_ identifier: ObjectIdentifier) -> (any BluetoothDevice)? { - connectedDevices[identifier] + public var endIndex: Int { + devices.endIndex } -} + public func index(after index: Int) -> Int { + devices.index(after: index) + } -extension BluetoothDevice { - fileprivate var typeIdentifier: ObjectIdentifier { - ObjectIdentifier(Self.self) + public subscript(position: Int) -> Device { + devices[position] } } diff --git a/Sources/SpeziBluetooth/Utils/ConnectedDevicesModel.swift b/Sources/SpeziBluetooth/Utils/ConnectedDevicesModel.swift new file mode 100644 index 00000000..a99ae703 --- /dev/null +++ b/Sources/SpeziBluetooth/Utils/ConnectedDevicesModel.swift @@ -0,0 +1,58 @@ +// +// 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 +import OrderedCollections + + +@Observable +class ConnectedDevicesModel { + /// We track the connected device for every BluetoothDevice type and index by peripheral identifier. + @MainActor private var connectedDevices: [ObjectIdentifier: OrderedDictionary] = [:] + + @MainActor + func update(with devices: [UUID: any BluetoothDevice]) { + // remove devices that disconnected + for (identifier, var devicesById) in connectedDevices { + var update = false + for id in devicesById.keys where devices[id] == nil { + devicesById.removeValue(forKey: id) + update = true + } + + if update { + connectedDevices[identifier] = devicesById.isEmpty ? nil : devicesById + } + } + + // add newly connected devices that are not injected yet + for (uuid, device) in devices { + guard connectedDevices[device.typeIdentifier]?[uuid] == nil else { + continue // already present + } + + // Newly connected device + connectedDevices[device.typeIdentifier, default: [:]].updateValue(device, forKey: uuid) + } + } + + @MainActor + subscript(_ identifier: ObjectIdentifier) -> [(any BluetoothDevice)] { + guard let values = connectedDevices[identifier]?.values else { + return [] + } + return Array(values) + } +} + + +extension BluetoothDevice { + fileprivate var typeIdentifier: ObjectIdentifier { + ObjectIdentifier(Self.self) + } +} diff --git a/Sources/SpeziBluetooth/Utils/Lazy.swift b/Sources/SpeziBluetooth/Utils/Lazy.swift index 25b85b44..6cc7574d 100644 --- a/Sources/SpeziBluetooth/Utils/Lazy.swift +++ b/Sources/SpeziBluetooth/Utils/Lazy.swift @@ -30,6 +30,10 @@ class Lazy { } + var isInitialized: Bool { + storedValue != nil + } + /// Support lazy initialization of lazy property. init() {} diff --git a/Sources/SpeziBluetooth/Utils/Reference.swift b/Sources/SpeziBluetooth/Utils/Reference.swift new file mode 100644 index 00000000..22095201 --- /dev/null +++ b/Sources/SpeziBluetooth/Utils/Reference.swift @@ -0,0 +1,33 @@ +// +// 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 +// + +protocol AnyWeakDeviceReference { + var anyValue: (any BluetoothDevice)? { get } + + var typeName: String { get } +} + + +struct WeakReference { + weak var value: Value? + + init(_ value: Value? = nil) { + self.value = value + } +} + + +extension WeakReference: AnyWeakDeviceReference where Value: BluetoothDevice { + var anyValue: (any BluetoothDevice)? { + value + } + + var typeName: String { + "\(Value.self)" + } +} diff --git a/Sources/BluetoothServices/BluetoothServices.docc/BluetoothServices.md b/Sources/SpeziBluetoothServices/BluetoothServices.docc/BluetoothServices.md similarity index 94% rename from Sources/BluetoothServices/BluetoothServices.docc/BluetoothServices.md rename to Sources/SpeziBluetoothServices/BluetoothServices.docc/BluetoothServices.md index f2acf2b0..059bf8c5 100644 --- a/Sources/BluetoothServices/BluetoothServices.docc/BluetoothServices.md +++ b/Sources/SpeziBluetoothServices/BluetoothServices.docc/BluetoothServices.md @@ -1,4 +1,4 @@ -# ``BluetoothServices`` +# ``SpeziBluetoothServices`` Reusable Bluetooth Service and Characteristic implementations. diff --git a/Sources/BluetoothServices/BluetoothServices.docc/Characteristics.md b/Sources/SpeziBluetoothServices/BluetoothServices.docc/Characteristics.md similarity index 100% rename from Sources/BluetoothServices/BluetoothServices.docc/Characteristics.md rename to Sources/SpeziBluetoothServices/BluetoothServices.docc/Characteristics.md diff --git a/Sources/BluetoothServices/BluetoothServices.docc/Services.md b/Sources/SpeziBluetoothServices/BluetoothServices.docc/Services.md similarity index 100% rename from Sources/BluetoothServices/BluetoothServices.docc/Services.md rename to Sources/SpeziBluetoothServices/BluetoothServices.docc/Services.md diff --git a/Sources/BluetoothServices/Characteristics/BloodPressureFeature.swift b/Sources/SpeziBluetoothServices/Characteristics/BloodPressureFeature.swift similarity index 98% rename from Sources/BluetoothServices/Characteristics/BloodPressureFeature.swift rename to Sources/SpeziBluetoothServices/Characteristics/BloodPressureFeature.swift index 52685b68..136d7008 100644 --- a/Sources/BluetoothServices/Characteristics/BloodPressureFeature.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/BloodPressureFeature.swift @@ -79,3 +79,6 @@ extension BloodPressureFeature: ByteCodable { rawValue.encode(to: &byteBuffer) } } + + +extension BloodPressureFeature: Codable {} diff --git a/Sources/BluetoothServices/Characteristics/BloodPressureMeasurement.swift b/Sources/SpeziBluetoothServices/Characteristics/BloodPressureMeasurement.swift similarity index 98% rename from Sources/BluetoothServices/Characteristics/BloodPressureMeasurement.swift rename to Sources/SpeziBluetoothServices/Characteristics/BloodPressureMeasurement.swift index 5131ae39..3e364b61 100644 --- a/Sources/BluetoothServices/Characteristics/BloodPressureMeasurement.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/BloodPressureMeasurement.swift @@ -31,7 +31,7 @@ public struct BloodPressureMeasurement { } /// The unit of a blood pressure measurement. - public enum Unit { + public enum Unit: String { /// Blood pressure for Systolic, Diastolic and Mean Arterial Pressure (MAP) is in units of mmHg. case mmHg /// Blood pressure for Systolic, Diastolic and Mean Arterial Pressure (MAP) is in units of kPa. @@ -266,3 +266,12 @@ extension BloodPressureMeasurement: ByteCodable { byteBuffer.setInteger(flags.rawValue, at: flagsIndex) // finally update the flags field } } + + +extension BloodPressureMeasurement.Unit: Codable {} + + +extension BloodPressureMeasurement.Status: Codable {} + + +extension BloodPressureMeasurement: Codable {} diff --git a/Sources/BluetoothServices/Characteristics/IntermediateCuffPressure.swift b/Sources/SpeziBluetoothServices/Characteristics/IntermediateCuffPressure.swift similarity index 94% rename from Sources/BluetoothServices/Characteristics/IntermediateCuffPressure.swift rename to Sources/SpeziBluetoothServices/Characteristics/IntermediateCuffPressure.swift index fc07650a..5051f803 100644 --- a/Sources/BluetoothServices/Characteristics/IntermediateCuffPressure.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/IntermediateCuffPressure.swift @@ -126,3 +126,14 @@ extension IntermediateCuffPressure: ByteCodable { representation.encode(to: &byteBuffer) } } + + +extension IntermediateCuffPressure: Codable { + public init(from decoder: any Decoder) throws { + self.representation = try BloodPressureMeasurement(from: decoder) + } + + public func encode(to encoder: any Encoder) throws { + try representation.encode(to: encoder) + } +} diff --git a/Sources/BluetoothServices/Characteristics/MeasurementInterval.swift b/Sources/SpeziBluetoothServices/Characteristics/MeasurementInterval.swift similarity index 96% rename from Sources/BluetoothServices/Characteristics/MeasurementInterval.swift rename to Sources/SpeziBluetoothServices/Characteristics/MeasurementInterval.swift index 6ed9ab20..a572bab6 100644 --- a/Sources/BluetoothServices/Characteristics/MeasurementInterval.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/MeasurementInterval.swift @@ -58,3 +58,6 @@ extension MeasurementInterval: ByteCodable { rawValue.encode(to: &byteBuffer) } } + + +extension MeasurementInterval: Codable {} diff --git a/Sources/BluetoothServices/Characteristics/PnPID.swift b/Sources/SpeziBluetoothServices/Characteristics/PnPID.swift similarity index 98% rename from Sources/BluetoothServices/Characteristics/PnPID.swift rename to Sources/SpeziBluetoothServices/Characteristics/PnPID.swift index fc4a78db..4c9833b9 100644 --- a/Sources/BluetoothServices/Characteristics/PnPID.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/PnPID.swift @@ -130,3 +130,9 @@ extension PnPID: ByteCodable { productVersion.encode(to: &byteBuffer) } } + + +extension VendorIDSource: Codable {} + + +extension PnPID: Codable {} diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/GenericOperand/CharacteristicAccessor+GenericRecordAccess.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/CharacteristicAccessor+GenericRecordAccess.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/RecordAccess/GenericOperand/CharacteristicAccessor+GenericRecordAccess.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/CharacteristicAccessor+GenericRecordAccess.swift diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessFilterCriteria.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessFilterCriteria.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessFilterCriteria.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessFilterCriteria.swift diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessFilterType.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessFilterType.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessFilterType.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessFilterType.swift diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessGeneralResponse.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessGeneralResponse.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessGeneralResponse.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessGeneralResponse.swift diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessGenericOperand.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessGenericOperand.swift similarity index 96% rename from Sources/BluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessGenericOperand.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessGenericOperand.swift index 59033c55..93b30e01 100644 --- a/Sources/BluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessGenericOperand.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessGenericOperand.swift @@ -117,11 +117,17 @@ extension RecordAccessOperationContent where Operand == RecordAccessGenericOpera } /// Records that are greater than or equal to the specified filter criteria value. + /// + /// - Parameter filterCriteria: The filter criteria. + /// - Returns: The operation content. public static func greaterThanOrEqualTo(_ filterCriteria: RecordAccessFilterCriteria) -> RecordAccessOperationContent { RecordAccessOperationContent(operator: .greaterThanOrEqual, operand: .filterCriteria(filterCriteria)) } /// Records that are within the closed range of the specified filter criteria value. + /// + /// - Parameter filterCriteria: The filter criteria. + /// - Returns: The operation content. public static func withinInclusiveRangeOf(_ filterCriteria: RecordAccessRangeFilterCriteria) -> RecordAccessOperationContent { RecordAccessOperationContent(operator: .withinInclusiveRangeOf, operand: .rangeFilterCriteria(filterCriteria)) } diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessControlPoint+Operations.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessControlPoint+Operations.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessControlPoint+Operations.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessControlPoint+Operations.swift diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessControlPoint.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessControlPoint.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessControlPoint.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessControlPoint.swift diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessOpCode.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOpCode.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessOpCode.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOpCode.swift diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessOperand.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOperand.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessOperand.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOperand.swift diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessOperationContent.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOperationContent.swift similarity index 89% rename from Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessOperationContent.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOperationContent.swift index 7f62bd72..270b7c7f 100644 --- a/Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessOperationContent.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOperationContent.swift @@ -24,7 +24,11 @@ public struct RecordAccessOperationContent { let `operator`: RecordAccessOperator let operand: Operand? - init(operator: RecordAccessOperator, operand: Operand? = nil) { + /// Create a new operation content. + /// - Parameters: + /// - operator: The operator. + /// - operand: The operand. + public init(operator: RecordAccessOperator, operand: Operand? = nil) { self.operator = `operator` self.operand = operand } diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessOperator.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOperator.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessOperator.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOperator.swift diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessResponseCode.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessResponseCode.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessResponseCode.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessResponseCode.swift diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessResponseFormatError.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessResponseFormatError.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessResponseFormatError.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessResponseFormatError.swift diff --git a/Sources/BluetoothServices/Characteristics/TemperatureMeasurement.swift b/Sources/SpeziBluetoothServices/Characteristics/TemperatureMeasurement.swift similarity index 97% rename from Sources/BluetoothServices/Characteristics/TemperatureMeasurement.swift rename to Sources/SpeziBluetoothServices/Characteristics/TemperatureMeasurement.swift index 4340a09a..bf74fc85 100644 --- a/Sources/BluetoothServices/Characteristics/TemperatureMeasurement.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/TemperatureMeasurement.swift @@ -28,7 +28,7 @@ public struct TemperatureMeasurement { } /// The unit of a temperature measurement. - public enum Unit { + public enum Unit: String { /// The temperature value is measured in celsius. case celsius /// The temperature value is measured in fahrenheit. @@ -144,3 +144,9 @@ extension TemperatureMeasurement: ByteCodable { byteBuffer.setInteger(flags.rawValue, at: flagsIndex) // finally update the flags field } } + + +extension TemperatureMeasurement.Unit: Codable {} + + +extension TemperatureMeasurement: Codable {} diff --git a/Sources/SpeziBluetoothServices/Characteristics/TemperatureType.swift b/Sources/SpeziBluetoothServices/Characteristics/TemperatureType.swift new file mode 100644 index 00000000..2ca84c3e --- /dev/null +++ b/Sources/SpeziBluetoothServices/Characteristics/TemperatureType.swift @@ -0,0 +1,70 @@ +// +// 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 ByteCoding +import NIO + + +/// The location of a temperature measurement. +/// +/// Refer to GATT Specification Supplement, 3.219 Temperature Type. +public struct TemperatureType { + /// Reserved for future use. + public static let reserved = TemperatureType(rawValue: 0x00) + /// Armpit. + public static let armpit = TemperatureType(rawValue: 0x01) + /// Body (general). + public static let body = TemperatureType(rawValue: 0x02) + /// Ear (usually earlobe). + public static let ear = TemperatureType(rawValue: 0x03) + /// Finger. + public static let finger = TemperatureType(rawValue: 0x04) + /// Gastrointestinal Tract. + public static let gastrointestinalTract = TemperatureType(rawValue: 0x05) + /// Mouth. + public static let mouth = TemperatureType(rawValue: 0x06) + /// Rectum. + public static let rectum = TemperatureType(rawValue: 0x07) + /// Toe. + public static let toe = TemperatureType(rawValue: 0x08) + /// Tympanum (ear drum). + public static let tympanum = TemperatureType(rawValue: 0x09) + + /// The raw value. + public let rawValue: UInt8 + + /// Create temperature type from raw value. + /// - Parameter rawValue: The raw value temperature type. + public init(rawValue: UInt8) { + self.rawValue = rawValue + } +} + + +extension TemperatureType: RawRepresentable {} + + +extension TemperatureType: Hashable, Sendable {} + + +extension TemperatureType: ByteCodable { + public init?(from byteBuffer: inout ByteBuffer) { + guard let value = UInt8(from: &byteBuffer) else { + return nil + } + + self.init(rawValue: value) + } + + public func encode(to byteBuffer: inout ByteBuffer) { + rawValue.encode(to: &byteBuffer) + } +} + + +extension TemperatureType: Codable {} diff --git a/Sources/BluetoothServices/Characteristics/Time/CurrentTime.swift b/Sources/SpeziBluetoothServices/Characteristics/Time/CurrentTime.swift similarity index 97% rename from Sources/BluetoothServices/Characteristics/Time/CurrentTime.swift rename to Sources/SpeziBluetoothServices/Characteristics/Time/CurrentTime.swift index 04bd3250..fff223b4 100644 --- a/Sources/BluetoothServices/Characteristics/Time/CurrentTime.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/Time/CurrentTime.swift @@ -87,3 +87,9 @@ extension CurrentTime: ByteCodable { adjustReason.encode(to: &byteBuffer) } } + + +extension CurrentTime.AdjustReason: Codable {} + + +extension CurrentTime: Codable {} diff --git a/Sources/BluetoothServices/Characteristics/Time/DateTime.swift b/Sources/SpeziBluetoothServices/Characteristics/Time/DateTime.swift similarity index 98% rename from Sources/BluetoothServices/Characteristics/Time/DateTime.swift rename to Sources/SpeziBluetoothServices/Characteristics/Time/DateTime.swift index 64ac3dd8..5a280338 100644 --- a/Sources/BluetoothServices/Characteristics/Time/DateTime.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/Time/DateTime.swift @@ -214,3 +214,9 @@ extension DateTime: ByteCodable { seconds.encode(to: &byteBuffer) } } + + +extension DateTime.Month: Codable {} + + +extension DateTime: Codable {} diff --git a/Sources/BluetoothServices/Characteristics/Time/DayDateTime.swift b/Sources/SpeziBluetoothServices/Characteristics/Time/DayDateTime.swift similarity index 99% rename from Sources/BluetoothServices/Characteristics/Time/DayDateTime.swift rename to Sources/SpeziBluetoothServices/Characteristics/Time/DayDateTime.swift index b3058d2f..4573b7d2 100644 --- a/Sources/BluetoothServices/Characteristics/Time/DayDateTime.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/Time/DayDateTime.swift @@ -112,3 +112,6 @@ extension DayDateTime: ByteCodable { dayOfWeek.encode(to: &byteBuffer) } } + + +extension DayDateTime: Codable {} diff --git a/Sources/BluetoothServices/Characteristics/Time/DayOfWeek.swift b/Sources/SpeziBluetoothServices/Characteristics/Time/DayOfWeek.swift similarity index 98% rename from Sources/BluetoothServices/Characteristics/Time/DayOfWeek.swift rename to Sources/SpeziBluetoothServices/Characteristics/Time/DayOfWeek.swift index 949961b7..097ed992 100644 --- a/Sources/BluetoothServices/Characteristics/Time/DayOfWeek.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/Time/DayOfWeek.swift @@ -65,3 +65,6 @@ extension DayOfWeek: ByteCodable { rawValue.encode(to: &byteBuffer) } } + + +extension DayOfWeek: Codable {} diff --git a/Sources/BluetoothServices/Characteristics/Time/ExactTime256.swift b/Sources/SpeziBluetoothServices/Characteristics/Time/ExactTime256.swift similarity index 99% rename from Sources/BluetoothServices/Characteristics/Time/ExactTime256.swift rename to Sources/SpeziBluetoothServices/Characteristics/Time/ExactTime256.swift index 2ff3c045..f3ea757c 100644 --- a/Sources/BluetoothServices/Characteristics/Time/ExactTime256.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/Time/ExactTime256.swift @@ -133,3 +133,6 @@ extension ExactTime256: ByteCodable { fractions256.encode(to: &byteBuffer) } } + + +extension ExactTime256: Codable {} diff --git a/Sources/BluetoothServices/Characteristics/WeightMeasurement.swift b/Sources/SpeziBluetoothServices/Characteristics/WeightMeasurement.swift similarity index 97% rename from Sources/BluetoothServices/Characteristics/WeightMeasurement.swift rename to Sources/SpeziBluetoothServices/Characteristics/WeightMeasurement.swift index c463ed33..c70439c3 100644 --- a/Sources/BluetoothServices/Characteristics/WeightMeasurement.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/WeightMeasurement.swift @@ -29,7 +29,7 @@ public struct WeightMeasurement { } /// Units for a weight measurement. - public enum Unit { + public enum Unit: String { /// SI units (Weight and Mass in units of kilogram (kg) and Height in units of meter). case si // swiftlint:disable:this identifier_name /// Imperial units. (Weight and Mass in units of pound (lb) and Height in units of inch (in)). @@ -240,3 +240,12 @@ extension WeightMeasurement: ByteCodable { byteBuffer.setInteger(flags.rawValue, at: flagsIndex) // finally update the flags field } } + + +extension WeightMeasurement.Unit: Codable {} + + +extension WeightMeasurement.AdditionalInfo: Codable {} + + +extension WeightMeasurement: Codable {} diff --git a/Sources/BluetoothServices/Characteristics/WeightScaleFeature.swift b/Sources/SpeziBluetoothServices/Characteristics/WeightScaleFeature.swift similarity index 99% rename from Sources/BluetoothServices/Characteristics/WeightScaleFeature.swift rename to Sources/SpeziBluetoothServices/Characteristics/WeightScaleFeature.swift index 629fc3f2..048c4b97 100644 --- a/Sources/BluetoothServices/Characteristics/WeightScaleFeature.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/WeightScaleFeature.swift @@ -231,3 +231,6 @@ extension WeightScaleFeature: ByteCodable { rawValue.encode(to: &byteBuffer) } } + + +extension WeightScaleFeature: Codable {} diff --git a/Sources/BluetoothServices/Services/BatteryService.swift b/Sources/SpeziBluetoothServices/Services/BatteryService.swift similarity index 100% rename from Sources/BluetoothServices/Services/BatteryService.swift rename to Sources/SpeziBluetoothServices/Services/BatteryService.swift diff --git a/Sources/BluetoothServices/Services/BloodPressureService.swift b/Sources/SpeziBluetoothServices/Services/BloodPressureService.swift similarity index 100% rename from Sources/BluetoothServices/Services/BloodPressureService.swift rename to Sources/SpeziBluetoothServices/Services/BloodPressureService.swift diff --git a/Sources/BluetoothServices/Services/CurrentTimeService.swift b/Sources/SpeziBluetoothServices/Services/CurrentTimeService.swift similarity index 100% rename from Sources/BluetoothServices/Services/CurrentTimeService.swift rename to Sources/SpeziBluetoothServices/Services/CurrentTimeService.swift diff --git a/Sources/BluetoothServices/Services/DeviceInformationService.swift b/Sources/SpeziBluetoothServices/Services/DeviceInformationService.swift similarity index 100% rename from Sources/BluetoothServices/Services/DeviceInformationService.swift rename to Sources/SpeziBluetoothServices/Services/DeviceInformationService.swift diff --git a/Sources/BluetoothServices/Services/HealthThermometerService.swift b/Sources/SpeziBluetoothServices/Services/HealthThermometerService.swift similarity index 100% rename from Sources/BluetoothServices/Services/HealthThermometerService.swift rename to Sources/SpeziBluetoothServices/Services/HealthThermometerService.swift diff --git a/Sources/BluetoothServices/Services/WeightScaleService.swift b/Sources/SpeziBluetoothServices/Services/WeightScaleService.swift similarity index 100% rename from Sources/BluetoothServices/Services/WeightScaleService.swift rename to Sources/SpeziBluetoothServices/Services/WeightScaleService.swift diff --git a/Sources/BluetoothServices/TestingSupport/CBUUID+Characteristics.swift b/Sources/SpeziBluetoothServices/TestingSupport/CBUUID+Characteristics.swift similarity index 100% rename from Sources/BluetoothServices/TestingSupport/CBUUID+Characteristics.swift rename to Sources/SpeziBluetoothServices/TestingSupport/CBUUID+Characteristics.swift diff --git a/Sources/BluetoothServices/TestingSupport/EventLog.swift b/Sources/SpeziBluetoothServices/TestingSupport/EventLog.swift similarity index 99% rename from Sources/BluetoothServices/TestingSupport/EventLog.swift rename to Sources/SpeziBluetoothServices/TestingSupport/EventLog.swift index 16714991..deaf0a27 100644 --- a/Sources/BluetoothServices/TestingSupport/EventLog.swift +++ b/Sources/SpeziBluetoothServices/TestingSupport/EventLog.swift @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // +@_spi(TestingSupport) import ByteCoding @preconcurrency import CoreBluetooth import NIO diff --git a/Sources/BluetoothServices/TestingSupport/TestService.swift b/Sources/SpeziBluetoothServices/TestingSupport/TestService.swift similarity index 100% rename from Sources/BluetoothServices/TestingSupport/TestService.swift rename to Sources/SpeziBluetoothServices/TestingSupport/TestService.swift diff --git a/Sources/TestPeripheral/TestPeripheral.swift b/Sources/TestPeripheral/TestPeripheral.swift index e3db31ba..884a5377 100644 --- a/Sources/TestPeripheral/TestPeripheral.swift +++ b/Sources/TestPeripheral/TestPeripheral.swift @@ -6,12 +6,12 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) -import BluetoothServices import ByteCoding import CoreBluetooth import OSLog import SpeziBluetooth +@_spi(TestingSupport) +import SpeziBluetoothServices @main diff --git a/Sources/TestPeripheral/TestService.swift b/Sources/TestPeripheral/TestService.swift index 14d1552b..4f42d7e0 100644 --- a/Sources/TestPeripheral/TestService.swift +++ b/Sources/TestPeripheral/TestService.swift @@ -6,10 +6,10 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) -import BluetoothServices import CoreBluetooth import OSLog +@_spi(TestingSupport) +import SpeziBluetoothServices class TestService { diff --git a/Tests/BluetoothServicesTests/BloodPressureTests.swift b/Tests/BluetoothServicesTests/BloodPressureTests.swift index 5e314711..5e549f03 100644 --- a/Tests/BluetoothServicesTests/BloodPressureTests.swift +++ b/Tests/BluetoothServicesTests/BloodPressureTests.swift @@ -6,12 +6,12 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) -@testable import BluetoothServices import CoreBluetooth import NIO @_spi(TestingSupport) @testable import SpeziBluetooth +@_spi(TestingSupport) +@testable import SpeziBluetoothServices import XCTByteCoding import XCTest diff --git a/Tests/BluetoothServicesTests/BluetoothServicesTests.swift b/Tests/BluetoothServicesTests/BluetoothServicesTests.swift index 630c6014..1c38f843 100644 --- a/Tests/BluetoothServicesTests/BluetoothServicesTests.swift +++ b/Tests/BluetoothServicesTests/BluetoothServicesTests.swift @@ -6,12 +6,12 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) -@testable import BluetoothServices import CoreBluetooth import NIO @_spi(TestingSupport) @testable import SpeziBluetooth +@_spi(TestingSupport) +@testable import SpeziBluetoothServices import XCTByteCoding import XCTest diff --git a/Tests/BluetoothServicesTests/CurrentTimeTests.swift b/Tests/BluetoothServicesTests/CurrentTimeTests.swift index bd832686..e3619427 100644 --- a/Tests/BluetoothServicesTests/CurrentTimeTests.swift +++ b/Tests/BluetoothServicesTests/CurrentTimeTests.swift @@ -6,12 +6,12 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) -@testable import BluetoothServices import CoreBluetooth import NIO @_spi(TestingSupport) @testable import SpeziBluetooth +@_spi(TestingSupport) +@testable import SpeziBluetoothServices import XCTByteCoding import XCTest @@ -120,6 +120,17 @@ final class CurrentTimeTests: XCTestCase { try testIdentity(from: CurrentTime(time: exactTime, adjustReason: [.manualTimeUpdate, .changeOfTimeZone])) } + func testCurrentTimeCodable() throws { + // test that we are coding from a single value container + let encoded = try JSONEncoder().encode(UInt8(0x01)) + let reason = try JSONDecoder().decode(CurrentTime.AdjustReason.self, from: encoded) + XCTAssertEqual(reason, .manualTimeUpdate) + + let encodedReason = try JSONEncoder().encode(CurrentTime.AdjustReason.manualTimeUpdate) + let rawValue = try JSONDecoder().decode(UInt8.self, from: encodedReason) + XCTAssertEqual(rawValue, 0x01) + } + func testDateConversions() throws { let baseNanoSeconds = Int(255 * (1.0 / 256.0) * 1000_000_000) var components = DateComponents(year: 2024, month: 5, day: 17, hour: 16, minute: 11, second: 26) diff --git a/Tests/BluetoothServicesTests/DeviceInformationTests.swift b/Tests/BluetoothServicesTests/DeviceInformationTests.swift index 0a12b260..a3ff3b7c 100644 --- a/Tests/BluetoothServicesTests/DeviceInformationTests.swift +++ b/Tests/BluetoothServicesTests/DeviceInformationTests.swift @@ -6,12 +6,12 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) -@testable import BluetoothServices import CoreBluetooth import NIO @_spi(TestingSupport) @testable import SpeziBluetooth +@_spi(TestingSupport) +@testable import SpeziBluetoothServices import XCTByteCoding import XCTest diff --git a/Tests/BluetoothServicesTests/HealthThermometerTests.swift b/Tests/BluetoothServicesTests/HealthThermometerTests.swift index fa5621b2..72b5bf1d 100644 --- a/Tests/BluetoothServicesTests/HealthThermometerTests.swift +++ b/Tests/BluetoothServicesTests/HealthThermometerTests.swift @@ -6,12 +6,12 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) -@testable import BluetoothServices import CoreBluetooth import NIO @_spi(TestingSupport) @testable import SpeziBluetooth +@_spi(TestingSupport) +@testable import SpeziBluetoothServices import XCTByteCoding import XCTest @@ -35,8 +35,15 @@ final class HealthThermometerTests: XCTestCase { } func testTemperatureType() throws { - for type in TemperatureType.allCases { - try testIdentity(from: type) - } + try testIdentity(from: TemperatureType.reserved) + try testIdentity(from: TemperatureType.armpit) + try testIdentity(from: TemperatureType.body) + try testIdentity(from: TemperatureType.ear) + try testIdentity(from: TemperatureType.finger) + try testIdentity(from: TemperatureType.gastrointestinalTract) + try testIdentity(from: TemperatureType.mouth) + try testIdentity(from: TemperatureType.rectum) + try testIdentity(from: TemperatureType.toe) + try testIdentity(from: TemperatureType.tympanum) } } diff --git a/Tests/BluetoothServicesTests/RecordAccessControlPointTests.swift b/Tests/BluetoothServicesTests/RecordAccessControlPointTests.swift index 9d848784..1b5f371f 100644 --- a/Tests/BluetoothServicesTests/RecordAccessControlPointTests.swift +++ b/Tests/BluetoothServicesTests/RecordAccessControlPointTests.swift @@ -6,14 +6,15 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) -@testable import BluetoothServices import CoreBluetooth import NIO @_spi(TestingSupport) @testable import SpeziBluetooth +@_spi(TestingSupport) +@testable import SpeziBluetoothServices import XCTByteCoding import XCTest +import XCTestExtensions typealias RACP = RecordAccessControlPoint @@ -102,31 +103,31 @@ final class RecordAccessControlPointTests: XCTestCase { $controlPoint.onRequest { _ in RACP(opCode: .abortOperation, operator: .null) } - await XCTAssertThrowsErrorAsync(try await $controlPoint.abort()) + try await XCTAssertThrowsErrorAsync(await $controlPoint.abort()) // unexpected response operator $controlPoint.onRequest { _ in RACP(opCode: .responseCode, operator: .allRecords) } - await XCTAssertThrowsErrorAsync(try await $controlPoint.abort()) + try await XCTAssertThrowsErrorAsync(await $controlPoint.abort()) // unexpected general response operand format $controlPoint.onRequest { _ in RACP(opCode: .responseCode, operator: .null, operand: .numberOfRecords(1234)) } - await XCTAssertThrowsErrorAsync(try await $controlPoint.abort()) + try await XCTAssertThrowsErrorAsync(await $controlPoint.abort()) // non matching request opcode $controlPoint.onRequest { _ in RACP(opCode: .responseCode, operator: .null, operand: .generalResponse(.init(requestOpCode: .reportStoredRecords, response: .success))) } - await XCTAssertThrowsErrorAsync(try await $controlPoint.abort()) + try await XCTAssertThrowsErrorAsync(await $controlPoint.abort()) // erroneous request $controlPoint.onRequest { _ in RACP(opCode: .responseCode, operator: .null, operand: .generalResponse(.init(requestOpCode: .abortOperation, response: .invalidOperand))) } - await XCTAssertThrowsErrorAsync(try await $controlPoint.abort()) + try await XCTAssertThrowsErrorAsync(await $controlPoint.abort()) } func testRACPReportNumberOfStoredRecordsRequest() async throws { @@ -143,25 +144,25 @@ final class RecordAccessControlPointTests: XCTestCase { $controlPoint.onRequest { _ in RACP(opCode: .abortOperation, operator: .null) } - await XCTAssertThrowsErrorAsync(try await $controlPoint.reportNumberOfStoredRecords(.allRecords)) + try await XCTAssertThrowsErrorAsync(await $controlPoint.reportNumberOfStoredRecords(.allRecords)) // unexpected response operator $controlPoint.onRequest { _ in RACP(opCode: .responseCode, operator: .allRecords) } - await XCTAssertThrowsErrorAsync(try await $controlPoint.reportNumberOfStoredRecords(.allRecords)) + try await XCTAssertThrowsErrorAsync(await $controlPoint.reportNumberOfStoredRecords(.allRecords)) // unexpected general response operand format $controlPoint.onRequest { _ in RACP(opCode: .responseCode, operator: .null, operand: .filterCriteria(.sequenceNumber(123))) } - await XCTAssertThrowsErrorAsync(try await $controlPoint.reportNumberOfStoredRecords(.allRecords)) + try await XCTAssertThrowsErrorAsync(await $controlPoint.reportNumberOfStoredRecords(.allRecords)) // non matching request opcode $controlPoint.onRequest { _ in RACP(opCode: .responseCode, operator: .null, operand: .generalResponse(.init(requestOpCode: .reportStoredRecords, response: .success))) } - await XCTAssertThrowsErrorAsync(try await $controlPoint.reportNumberOfStoredRecords(.allRecords)) + try await XCTAssertThrowsErrorAsync(await $controlPoint.reportNumberOfStoredRecords(.allRecords)) // erroneous request $controlPoint.onRequest { _ in @@ -171,28 +172,12 @@ final class RecordAccessControlPointTests: XCTestCase { operand: .generalResponse(.init(requestOpCode: .reportNumberOfStoredRecords, response: .invalidOperand)) ) } - await XCTAssertThrowsErrorAsync(try await $controlPoint.reportNumberOfStoredRecords(.allRecords)) + try await XCTAssertThrowsErrorAsync(await $controlPoint.reportNumberOfStoredRecords(.allRecords)) // invalid operator $controlPoint.onRequest { _ in RACP(opCode: .numberOfStoredRecordsResponse, operator: .allRecords, operand: .numberOfRecords(1234)) } - await XCTAssertThrowsErrorAsync(try await $controlPoint.reportNumberOfStoredRecords(.allRecords)) - } -} - - -func XCTAssertThrowsErrorAsync( - _ expression: @autoclosure () async throws -> T, - _ message: @autoclosure () -> String = "", - file: StaticString = #filePath, - line: UInt = #line, - _ errorHandler: (Error) -> Void = { _ in } -) async { - do { - _ = try await expression() - XCTFail(message(), file: file, line: line) - } catch { - errorHandler(error) + try await XCTAssertThrowsErrorAsync(await $controlPoint.reportNumberOfStoredRecords(.allRecords)) } } diff --git a/Tests/BluetoothServicesTests/WeightScaleTests.swift b/Tests/BluetoothServicesTests/WeightScaleTests.swift index 1068e27d..cc221d6f 100644 --- a/Tests/BluetoothServicesTests/WeightScaleTests.swift +++ b/Tests/BluetoothServicesTests/WeightScaleTests.swift @@ -6,12 +6,12 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) -@testable import BluetoothServices import CoreBluetooth import NIO @_spi(TestingSupport) @testable import SpeziBluetooth +@_spi(TestingSupport) +@testable import SpeziBluetoothServices import XCTByteCoding import XCTest diff --git a/Tests/UITests/TestApp/BluetoothManagerView.swift b/Tests/UITests/TestApp/BluetoothManagerView.swift index c413ab83..4b3f809d 100644 --- a/Tests/UITests/TestApp/BluetoothManagerView.swift +++ b/Tests/UITests/TestApp/BluetoothManagerView.swift @@ -6,13 +6,12 @@ // SPDX-License-Identifier: MIT // -import BluetoothViews import SpeziBluetooth import SwiftUI struct BluetoothManagerView: View { - @State private var bluetooth = BluetoothManager(devices: []) // discovery any devices! + @State private var bluetooth = BluetoothManager() var body: some View { List { @@ -23,12 +22,12 @@ struct BluetoothManagerView: View { DeviceRowView(peripheral: peripheral) } } header: { - LoadingSectionHeaderView(verbatim: "Devices", loading: bluetooth.isScanning) + Text(verbatim: "Devices") } footer: { Text(verbatim: "This is a list of nearby Bluetooth peripherals.") } } - .scanNearbyDevices(with: bluetooth) + .scanNearbyDevices(with: bluetooth, discovery: []) // discovery any devices! .navigationTitle("Nearby Devices") } } diff --git a/Tests/UITests/TestApp/BluetoothModuleView.swift b/Tests/UITests/TestApp/BluetoothModuleView.swift index 77aba65c..da6e012d 100644 --- a/Tests/UITests/TestApp/BluetoothModuleView.swift +++ b/Tests/UITests/TestApp/BluetoothModuleView.swift @@ -6,8 +6,8 @@ // SPDX-License-Identifier: MIT // -import BluetoothViews import SpeziBluetooth +import SpeziViews import SwiftUI @@ -16,9 +16,13 @@ struct BluetoothModuleView: View { private var bluetooth @Environment(TestDevice.self) private var device: TestDevice? + @Environment(ConnectedDevices.self) + private var connectedDevices + + @Binding private var pairedDeviceId: UUID? var body: some View { - List { + List { // swiftlint:disable:this closure_body_length BluetoothStateSection(state: bluetooth.state, isScanning: bluetooth.isScanning) let nearbyDevices = bluetooth.nearbyDevices(for: TestDevice.self) @@ -28,11 +32,36 @@ struct BluetoothModuleView: View { DeviceRowView(peripheral: device) } } header: { - LoadingSectionHeaderView(verbatim: "Devices", loading: bluetooth.isScanning) + Text(verbatim: "Devices") } footer: { Text(verbatim: "This is a list of nearby test peripherals. Auto connect is enabled.") } + if !connectedDevices.isEmpty { + Section { + ForEach(connectedDevices) { device in + AsyncButton { + pairedDeviceId = device.id + await device.disconnect() + } label: { + VStack { + Text(verbatim: "Pair \(type(of: device))") + if let name = device.name { + Text(name) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + .accessibilityLabel("Pair \(type(of: device))") + } + } header: { + Text("Connected Devices") + } footer: { + Text("This tests the retrieval of connected devices using ConnectedDevices.") + } + } + if let device { NavigationLink("Test Interactions") { TestDeviceView(device: device) @@ -42,12 +71,17 @@ struct BluetoothModuleView: View { .scanNearbyDevices(with: bluetooth, autoConnect: true) .navigationTitle("Nearby Devices") } + + + init(pairedDeviceId: Binding) { + self._pairedDeviceId = pairedDeviceId + } } #Preview { NavigationStack { - BluetoothModuleView() + BluetoothModuleView(pairedDeviceId: .constant(nil)) .previewWith { Bluetooth { Discover(TestDevice.self, by: .advertisedService("FFF0")) diff --git a/Tests/UITests/TestApp/RetrievePairedDevicesView.swift b/Tests/UITests/TestApp/RetrievePairedDevicesView.swift new file mode 100644 index 00000000..c344c440 --- /dev/null +++ b/Tests/UITests/TestApp/RetrievePairedDevicesView.swift @@ -0,0 +1,91 @@ +// +// 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 SpeziBluetooth +import SpeziViews +import SwiftUI + + +struct RetrievePairedDevicesView: View { + @Environment(Bluetooth.self) + private var bluetooth + + @Binding private var pairedDeviceId: UUID? + @Binding private var retrievedDevice: TestDevice? + + var body: some View { + Group { // swiftlint:disable:this closure_body_length + if let pairedDeviceId { + List { // swiftlint:disable:this closure_body_length + Section { + ListRow("Device") { + Text("Paired") + } + if let retrievedDevice { + ListRow("State") { + Text(retrievedDevice.state.description) + } + } + AsyncButton("Unpair Device") { + await retrievedDevice?.disconnect() + retrievedDevice = nil + self.pairedDeviceId = nil + } + if let retrievedDevice { + switch retrievedDevice.state { + case .disconnected: + AsyncButton("Connect Device") { + await retrievedDevice.connect() + } + case .connecting, .connected: + AsyncButton("Disconnect Device") { + await retrievedDevice.disconnect() + } + case .disconnecting: + EmptyView() + } + } else { + AsyncButton("Retrieve Device") { + retrievedDevice = await bluetooth.retrieveDevice(for: pairedDeviceId) + } + } + } + + if let retrievedDevice, case .connected = retrievedDevice.state { + DeviceInformationView(retrievedDevice) + } + } + } else { + ContentUnavailableView( + "No Device Paired", + systemImage: "sensor", + description: Text("Select a connected device in the Test Peripheral view to pair.") + ) + } + } + .navigationTitle("Paired Device") + } + + + init(pairedDeviceId: Binding, retrievedDevice: Binding) { + self._pairedDeviceId = pairedDeviceId + self._retrievedDevice = retrievedDevice + } +} + + +#Preview { + NavigationStack { + RetrievePairedDevicesView(pairedDeviceId: .constant(nil), retrievedDevice: .constant(nil)) + .previewWith { + Bluetooth { + Discover(TestDevice.self, by: .advertisedService("FFF0")) + } + } + } +} diff --git a/Tests/UITests/TestApp/TestApp.swift b/Tests/UITests/TestApp/TestApp.swift index 291919f5..df01ab16 100644 --- a/Tests/UITests/TestApp/TestApp.swift +++ b/Tests/UITests/TestApp/TestApp.swift @@ -7,6 +7,9 @@ // import Spezi +@_spi(Internal) +import SpeziBluetooth +import SpeziViews import SwiftUI struct NearbyDevices: View { @@ -15,11 +18,36 @@ struct NearbyDevices: View { } } +struct DeviceCountButton: View { + @Environment(Bluetooth.self) + private var bluetooth + + @State private var lastReadCount: Int? + + var body: some View { + Section { + AsyncButton("Query Count") { + lastReadCount = await bluetooth._initializedDevicesCount() + } + .onDisappear { + lastReadCount = nil + } + } footer: { + if let lastReadCount { + Text("Currently initialized devices: \(lastReadCount)") + } + } + } +} + @main struct UITestsApp: App { @UIApplicationDelegateAdaptor(TestAppDelegate.self) var appDelegate - + + @State private var pairedDeviceId: UUID? + @State private var retrievedDevice: TestDevice? + var body: some Scene { WindowGroup { @@ -29,8 +57,13 @@ struct UITestsApp: App { NearbyDevices() } NavigationLink("Test Peripheral") { - BluetoothModuleView() + BluetoothModuleView(pairedDeviceId: $pairedDeviceId) + } + NavigationLink("Paired Device") { + RetrievePairedDevicesView(pairedDeviceId: $pairedDeviceId, retrievedDevice: $retrievedDevice) } + + DeviceCountButton() } .navigationTitle("Spezi Bluetooth") } diff --git a/Tests/UITests/TestApp/TestAppDelegate.swift b/Tests/UITests/TestApp/TestAppDelegate.swift index 775ebbf0..a58f5ce3 100644 --- a/Tests/UITests/TestApp/TestAppDelegate.swift +++ b/Tests/UITests/TestApp/TestAppDelegate.swift @@ -6,10 +6,10 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) -import BluetoothServices import Spezi import SpeziBluetooth +@_spi(TestingSupport) +import SpeziBluetoothServices import SwiftUI diff --git a/Tests/UITests/TestApp/TestDevice.swift b/Tests/UITests/TestApp/TestDevice.swift index 435d1e12..d3b4a9c0 100644 --- a/Tests/UITests/TestApp/TestDevice.swift +++ b/Tests/UITests/TestApp/TestDevice.swift @@ -6,10 +6,10 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) -import BluetoothServices import Foundation import SpeziBluetooth +@_spi(TestingSupport) +import SpeziBluetoothServices final class TestDevice: BluetoothDevice, Identifiable, SomePeripheral, @unchecked Sendable { @@ -40,13 +40,28 @@ final class TestDevice: BluetoothDevice, Identifiable, SomePeripheral, @unchecke let testState = State() + private(set) var passedRetainCountCheck = false + + required init() {} + + func configure() { + let count = CFGetRetainCount(self) - required init() { - deviceInformation.$modelNumber.onChange(initial: true) { @MainActor _ in - self.testState.didReceiveModel = true + deviceInformation.$modelNumber.onChange(initial: true) { @MainActor [weak self] _ in + self?.testState.didReceiveModel = true } - deviceInformation.$manufacturerName.onChange { @MainActor _ in - self.testState.didReceiveManufacturer = true // this should never be called + deviceInformation.$manufacturerName.onChange { @MainActor [weak self] _ in + self?.testState.didReceiveManufacturer = true // this should never be called + } + $state.onChange { state in // test DeviceState code path as well, even if its just logging! + print("State is now \(state)") + } + + let newCount = CFGetRetainCount(self) + if count == newCount { + passedRetainCountCheck = true + } else { + print("Failed retain count check, was \(count) now is \(newCount)") } } diff --git a/Tests/UITests/TestApp/TestDeviceView.swift b/Tests/UITests/TestApp/TestDeviceView.swift index a3a95772..c1c4740a 100644 --- a/Tests/UITests/TestApp/TestDeviceView.swift +++ b/Tests/UITests/TestApp/TestDeviceView.swift @@ -7,7 +7,7 @@ // @_spi(TestingSupport) -import BluetoothServices +import SpeziBluetoothServices @_spi(TestingSupport) import SpeziBluetooth import SwiftUI diff --git a/Tests/UITests/TestApp/Views/DeviceInformationView.swift b/Tests/UITests/TestApp/Views/DeviceInformationView.swift index ee44fc92..6ceb362a 100644 --- a/Tests/UITests/TestApp/Views/DeviceInformationView.swift +++ b/Tests/UITests/TestApp/Views/DeviceInformationView.swift @@ -6,7 +6,9 @@ // SPDX-License-Identifier: MIT // -import BluetoothServices +@_spi(TestingSupport) +import ByteCoding +import SpeziBluetoothServices @_spi(TestingSupport) import SpeziBluetooth import SpeziViews @@ -68,6 +70,16 @@ struct DeviceInformationView: View { Text(regulatoryCertificationDataList.hexString()) } } + + ListRow("Retain Count Check") { + if device.passedRetainCountCheck { + Text("Passed") + .foregroundStyle(.green) + } else { + Text("Failed") + .foregroundStyle(.red) + } + } } header: { Text("Device Information") } footer: { diff --git a/Tests/UITests/TestApp/Views/DeviceRowView.swift b/Tests/UITests/TestApp/Views/DeviceRowView.swift index 59a21fd6..cb7c7744 100644 --- a/Tests/UITests/TestApp/Views/DeviceRowView.swift +++ b/Tests/UITests/TestApp/Views/DeviceRowView.swift @@ -29,12 +29,7 @@ struct DeviceRowView: View { Button(action: peripheralAction) { VStack { HStack { - if let name = peripheral.name { - Text(verbatim: "\(name)") - } else { - Text(verbatim: "unknown") - .italic() - } + Text(verbatim: "\(Peripheral.self)") Spacer() Text(verbatim: "\(peripheral.rssi) dB") .foregroundColor(.secondary) diff --git a/Tests/UITests/TestApp/Views/TestServiceView.swift b/Tests/UITests/TestApp/Views/TestServiceView.swift index b56d7863..fe9a358e 100644 --- a/Tests/UITests/TestApp/Views/TestServiceView.swift +++ b/Tests/UITests/TestApp/Views/TestServiceView.swift @@ -7,11 +7,12 @@ // @_spi(TestingSupport) -import BluetoothServices -import BluetoothViews +import ByteCoding import CoreBluetooth @_spi(TestingSupport) import SpeziBluetooth +@_spi(TestingSupport) +import SpeziBluetoothServices import SpeziViews import SwiftUI diff --git a/Tests/UITests/TestAppUITests/BluetoothManagerTests.swift b/Tests/UITests/TestAppUITests/BluetoothManagerTests.swift index 609d9b2b..5ff5e291 100644 --- a/Tests/UITests/TestAppUITests/BluetoothManagerTests.swift +++ b/Tests/UITests/TestAppUITests/BluetoothManagerTests.swift @@ -17,6 +17,7 @@ final class BluetoothManagerTests: XCTestCase { continueAfterFailure = false } + @MainActor func testSpeziBluetooth() throws { let app = XCUIApplication() app.launch() diff --git a/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift b/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift index b9d8c3f7..d952efca 100644 --- a/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift +++ b/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift @@ -6,9 +6,9 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) -import BluetoothServices import CoreBluetooth +@_spi(TestingSupport) +import SpeziBluetoothServices import XCTest import XCTestExtensions @@ -20,6 +20,7 @@ final class SpeziBluetoothTests: XCTestCase { continueAfterFailure = false } + @MainActor func testTestPeripheral() throws { // swiftlint:disable:this function_body_length let app = XCUIApplication() app.launch() @@ -33,8 +34,9 @@ final class SpeziBluetoothTests: XCTestCase { try app.assertMinimalSimulatorInformation() // wait till the device is automatically connected. - XCTAssert(app.staticTexts["Spezi"].waitForExistence(timeout: 1.0)) // our peripheral name + XCTAssert(app.staticTexts["TestDevice"].waitForExistence(timeout: 5.0)) XCTAssert(app.staticTexts["connected"].waitForExistence(timeout: 10.0)) + XCTAssert(app.buttons["Pair TestDevice"].exists) // tests retrieval via ConnectedDevices XCTAssert(app.buttons["Test Interactions"].exists) app.buttons["Test Interactions"].tap() @@ -44,6 +46,9 @@ final class SpeziBluetoothTests: XCTestCase { XCTAssert(app.staticTexts["Manufacturer, Apple Inc."].exists) XCTAssert(app.staticTexts["Model"].exists) // we just check for existence of the model row + // check that onChange registrations in configure() didn't create any unwanted retain cycles + XCTAssert(app.staticTexts["Retain Count Check, Passed"].exists) + // CHECK onChange behavior XCTAssert(app.staticTexts["Manufacturer: false, Model: true"].waitForExistence(timeout: 0.5)) XCTAssert(app.buttons["Fetch"].exists) @@ -135,6 +140,67 @@ final class SpeziBluetoothTests: XCTestCase { sleep(5) // check that it stays disconnected XCTAssert(app.staticTexts["disconnected"].waitForExistence(timeout: 2.0)) + XCTAssertFalse(app.staticTexts["Connected TestDevice"].waitForExistence(timeout: 0.5)) + } + + @MainActor + func testPairedDevice() throws { + let app = XCUIApplication() + app.launch() + + XCTAssert(app.staticTexts["Spezi Bluetooth"].waitForExistence(timeout: 2)) + + XCTAssert(app.buttons["Test Peripheral"].exists) + app.buttons["Test Peripheral"].tap() + + XCTAssert(app.navigationBars.staticTexts["Nearby Devices"].waitForExistence(timeout: 2.0)) + try app.assertMinimalSimulatorInformation() + + // wait till the device is automatically connected. + XCTAssert(app.staticTexts["TestDevice"].waitForExistence(timeout: 5.0)) + XCTAssert(app.staticTexts["connected"].waitForExistence(timeout: 10.0)) + + XCTAssert(app.buttons["Pair TestDevice"].exists) // tests retrieval via ConnectedDevices + app.buttons["Pair TestDevice"].tap() + + XCTAssert(app.navigationBars.buttons["Spezi Bluetooth"].exists) + app.navigationBars.buttons["Spezi Bluetooth"].tap() + + XCTAssert(app.buttons["Query Count"].waitForExistence(timeout: 2.0)) + app.buttons["Query Count"].tap() + XCTAssert(app.staticTexts["Currently initialized devices: 0"].waitForExistence(timeout: 0.5)) // ensure devices got deallocated + + + XCTAssert(app.buttons["Paired Device"].exists) + app.buttons["Paired Device"].tap() + + XCTAssert(app.staticTexts["Device, Paired"].waitForExistence(timeout: 2.0)) + + XCTAssert(app.buttons["Retrieve Device"].exists) + app.buttons["Retrieve Device"].tap() + + XCTAssert(app.staticTexts["State, disconnected"].waitForExistence(timeout: 0.5)) + XCTAssert(app.buttons["Connect Device"].exists) + 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.buttons["Disconnect Device"].exists) + app.buttons["Disconnect Device"].tap() + + XCTAssert(app.staticTexts["State, disconnected"].waitForExistence(timeout: 0.5)) + + XCTAssert(app.buttons["Unpair Device"].exists) + app.buttons["Unpair Device"].tap() + + XCTAssert(app.navigationBars.buttons["Spezi Bluetooth"].exists) + app.navigationBars.buttons["Spezi Bluetooth"].tap() + + XCTAssert(app.buttons["Query Count"].waitForExistence(timeout: 2.0)) + app.buttons["Query Count"].tap() + XCTAssert(app.staticTexts["Currently initialized devices: 0"].waitForExistence(timeout: 0.5)) // ensure devices got deallocated } } diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 6b1b6781..e37ea55b 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -12,7 +12,7 @@ 2F64EA8B2A86B3DE006789D0 /* XCTestExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 2F64EA8A2A86B3DE006789D0 /* XCTestExtensions */; }; 2F6D139A28F5F386007C25D6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2F6D139928F5F386007C25D6 /* Assets.xcassets */; }; 2FA43E922AE057CA009B1B2C /* BluetoothManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA43E912AE057CA009B1B2C /* BluetoothManagerTests.swift */; }; - A91E672E2B75A500009A1E02 /* BluetoothViews in Frameworks */ = {isa = PBXBuildFile; productRef = A91E672D2B75A500009A1E02 /* BluetoothViews */; }; + A909BBA72C29712C00969FC4 /* RetrievePairedDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A909BBA62C29712C00969FC4 /* RetrievePairedDevicesView.swift */; }; A92802B72B5081F200874D0A /* SpeziBluetooth in Frameworks */ = {isa = PBXBuildFile; productRef = A92802B62B5081F200874D0A /* SpeziBluetooth */; }; A92802B92B50823600874D0A /* BluetoothManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A92802B82B50823600874D0A /* BluetoothManagerView.swift */; }; A92802BD2B51CBBE00874D0A /* DeviceRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A92802BC2B51CBBE00874D0A /* DeviceRowView.swift */; }; @@ -24,8 +24,9 @@ A95542B42B5E3E210066646D /* BluetoothModuleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95542B32B5E3E210066646D /* BluetoothModuleView.swift */; }; A95542B92B5E3F490066646D /* SearchingNearbyDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95542B82B5E3F490066646D /* SearchingNearbyDevicesView.swift */; }; A95542BD2B5E40DF0066646D /* TestDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95542BC2B5E40DF0066646D /* TestDevice.swift */; }; - A97851BF2B79E01D007BCBE3 /* BluetoothServices in Frameworks */ = {isa = PBXBuildFile; productRef = A97851BE2B79E01D007BCBE3 /* BluetoothServices */; }; - A9C17DEA2B5F1EAA00976924 /* BluetoothServices in Frameworks */ = {isa = PBXBuildFile; productRef = A9C17DE92B5F1EAA00976924 /* BluetoothServices */; }; + A9AAC7ED2C26D6890034088B /* SpeziBluetoothServices in Frameworks */ = {isa = PBXBuildFile; productRef = A9AAC7EC2C26D6890034088B /* SpeziBluetoothServices */; }; + A9AAC7EF2C26D6920034088B /* SpeziBluetoothServices in Frameworks */ = {isa = PBXBuildFile; productRef = A9AAC7EE2C26D6920034088B /* SpeziBluetoothServices */; }; + A9AAC7F22C26D73C0034088B /* SpeziViews in Frameworks */ = {isa = PBXBuildFile; productRef = A9AAC7F12C26D73C0034088B /* SpeziViews */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,6 +61,7 @@ 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TestAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 2FA43E912AE057CA009B1B2C /* BluetoothManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BluetoothManagerTests.swift; sourceTree = ""; }; 2FB0758A299DDB9000C0B37F /* TestApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestApp.xctestplan; sourceTree = ""; }; + A909BBA62C29712C00969FC4 /* RetrievePairedDevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetrievePairedDevicesView.swift; sourceTree = ""; }; A92802B82B50823600874D0A /* BluetoothManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothManagerView.swift; sourceTree = ""; }; A92802BA2B5085BE00874D0A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; A92802BC2B51CBBE00874D0A /* DeviceRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRowView.swift; sourceTree = ""; }; @@ -79,8 +81,8 @@ buildActionMask = 2147483647; files = ( A92802B72B5081F200874D0A /* SpeziBluetooth in Frameworks */, - A91E672E2B75A500009A1E02 /* BluetoothViews in Frameworks */, - A9C17DEA2B5F1EAA00976924 /* BluetoothServices in Frameworks */, + A9AAC7ED2C26D6890034088B /* SpeziBluetoothServices in Frameworks */, + A9AAC7F22C26D73C0034088B /* SpeziViews in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -89,7 +91,7 @@ buildActionMask = 2147483647; files = ( 2F64EA8B2A86B3DE006789D0 /* XCTestExtensions in Frameworks */, - A97851BF2B79E01D007BCBE3 /* BluetoothServices in Frameworks */, + A9AAC7EF2C26D6920034088B /* SpeziBluetoothServices in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -120,15 +122,16 @@ 2F6D139428F5F384007C25D6 /* TestApp */ = { isa = PBXGroup; children = ( - A95542B72B5E3F260066646D /* Views */, A92802BA2B5085BE00874D0A /* Info.plist */, A92802B82B50823600874D0A /* BluetoothManagerView.swift */, A95542B32B5E3E210066646D /* BluetoothModuleView.swift */, + A909BBA62C29712C00969FC4 /* RetrievePairedDevicesView.swift */, 2F64EA872A86B36C006789D0 /* TestApp.swift */, 2F64EA812A86B346006789D0 /* TestAppDelegate.swift */, A95542BC2B5E40DF0066646D /* TestDevice.swift */, - 2F6D139928F5F386007C25D6 /* Assets.xcassets */, A93B82D92B78D0D200C5DF3D /* TestDeviceView.swift */, + 2F6D139928F5F386007C25D6 /* Assets.xcassets */, + A95542B72B5E3F260066646D /* Views */, ); path = TestApp; sourceTree = ""; @@ -180,8 +183,8 @@ name = TestApp; packageProductDependencies = ( A92802B62B5081F200874D0A /* SpeziBluetooth */, - A9C17DE92B5F1EAA00976924 /* BluetoothServices */, - A91E672D2B75A500009A1E02 /* BluetoothViews */, + A9AAC7EC2C26D6890034088B /* SpeziBluetoothServices */, + A9AAC7F12C26D73C0034088B /* SpeziViews */, ); productName = Example; productReference = 2F6D139228F5F384007C25D6 /* TestApp.app */; @@ -203,7 +206,7 @@ name = TestAppUITests; packageProductDependencies = ( 2F64EA8A2A86B3DE006789D0 /* XCTestExtensions */, - A97851BE2B79E01D007BCBE3 /* BluetoothServices */, + A9AAC7EE2C26D6920034088B /* SpeziBluetoothServices */, ); productName = ExampleUITests; productReference = 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */; @@ -217,7 +220,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1500; - LastUpgradeCheck = 1520; + LastUpgradeCheck = 1540; TargetAttributes = { 2F6D139128F5F384007C25D6 = { CreatedOnToolsVersion = 14.1; @@ -239,6 +242,7 @@ mainGroup = 2F6D138928F5F384007C25D6; packageReferences = ( 2F64EA892A86B3DE006789D0 /* XCRemoteSwiftPackageReference "XCTestExtensions" */, + A9AAC7F02C26D73C0034088B /* XCRemoteSwiftPackageReference "SpeziViews" */, ); productRefGroup = 2F6D139328F5F384007C25D6 /* Products */; projectDirPath = ""; @@ -280,6 +284,7 @@ A93B82D42B78C20700C5DF3D /* BluetoothStateSection.swift in Sources */, A93B82D62B78C2D100C5DF3D /* DeviceInformationView.swift in Sources */, A95542BD2B5E40DF0066646D /* TestDevice.swift in Sources */, + A909BBA72C29712C00969FC4 /* RetrievePairedDevicesView.swift in Sources */, A92802BD2B51CBBE00874D0A /* DeviceRowView.swift in Sources */, A92802B92B50823600874D0A /* BluetoothManagerView.swift in Sources */, 2F64EA882A86B36C006789D0 /* TestApp.swift in Sources */, @@ -348,6 +353,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -370,6 +376,7 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Debug; @@ -411,6 +418,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -426,6 +434,7 @@ SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_STRICT_CONCURRENCY = complete; VALIDATE_PRODUCT = YES; XROS_DEPLOYMENT_TARGET = 1.0; }; @@ -438,9 +447,9 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 637867499T; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 637867499T; + DEVELOPMENT_TEAM = 484YT3X9X7; ENABLE_HARDENED_RUNTIME = NO; ENABLE_PREVIEWS = YES; ENABLE_TESTING_SEARCH_PATHS = YES; @@ -603,6 +612,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -625,6 +635,7 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = TEST; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Test; @@ -741,7 +752,15 @@ repositoryURL = "https://github.com/StanfordBDHG/XCTestExtensions.git"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 0.4.10; + minimumVersion = 0.4.11; + }; + }; + A9AAC7F02C26D73C0034088B /* XCRemoteSwiftPackageReference "SpeziViews" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/StanfordSpezi/SpeziViews.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.4.0; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -752,21 +771,22 @@ package = 2F64EA892A86B3DE006789D0 /* XCRemoteSwiftPackageReference "XCTestExtensions" */; productName = XCTestExtensions; }; - A91E672D2B75A500009A1E02 /* BluetoothViews */ = { - isa = XCSwiftPackageProductDependency; - productName = BluetoothViews; - }; A92802B62B5081F200874D0A /* SpeziBluetooth */ = { isa = XCSwiftPackageProductDependency; productName = SpeziBluetooth; }; - A97851BE2B79E01D007BCBE3 /* BluetoothServices */ = { + A9AAC7EC2C26D6890034088B /* SpeziBluetoothServices */ = { + isa = XCSwiftPackageProductDependency; + productName = SpeziBluetoothServices; + }; + A9AAC7EE2C26D6920034088B /* SpeziBluetoothServices */ = { isa = XCSwiftPackageProductDependency; - productName = BluetoothServices; + productName = SpeziBluetoothServices; }; - A9C17DE92B5F1EAA00976924 /* BluetoothServices */ = { + A9AAC7F12C26D73C0034088B /* SpeziViews */ = { isa = XCSwiftPackageProductDependency; - productName = BluetoothServices; + package = A9AAC7F02C26D73C0034088B /* XCRemoteSwiftPackageReference "SpeziViews" */; + productName = SpeziViews; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme index e7449281..1ee434a9 100644 --- a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme +++ b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme @@ -1,6 +1,6 @@