diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 9038d311..5a7cf716 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -21,8 +21,8 @@ jobs: uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: runsonlabels: '["macOS", "self-hosted"]' - scheme: SpeziBluetooth - artifactname: SpeziBluetooth.xcresult + scheme: SpeziBluetooth-Package + artifactname: SpeziBluetooth-Package.xcresult ios: name: Build and Test iOS uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 @@ -36,4 +36,4 @@ jobs: needs: [packageios, ios] uses: StanfordSpezi/.github/.github/workflows/create-and-upload-coverage-report.yml@v2 with: - coveragereports: SpeziBluetooth.xcresult TestApp.xcresult + coveragereports: SpeziBluetooth-Package.xcresult TestApp.xcresult diff --git a/.swiftlint.yml b/.swiftlint.yml index 0a7fab12..85d084b9 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -367,9 +367,6 @@ only_rules: # The variable should be placed on the left, the constant on the right of a comparison operator. - yoda_condition -attributes: - attributes_with_arguments_always_on_line_above: false - deployment_target: # Availability checks or attributes shouldn’t be using older versions that are satisfied by the deployment target. iOSApplicationExtension_deployment_target: 16.0 iOS_deployment_target: 16.0 diff --git a/Package.swift b/Package.swift index 47706169..39a02b72 100644 --- a/Package.swift +++ b/Package.swift @@ -18,11 +18,13 @@ let package = Package( .iOS(.v17) ], products: [ - .library(name: "SpeziBluetooth", targets: ["SpeziBluetooth"]) + .library(name: "SpeziBluetooth", targets: ["SpeziBluetooth"]), + .library(name: "XCTBluetooth", targets: ["XCTBluetooth"]) ], dependencies: [ - .package(url: "https://github.com/StanfordSpezi/Spezi", .upToNextMinor(from: "0.8.0")), - .package(url: "https://github.com/apple/swift-nio.git", from: "2.59.0") + .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.0.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") ], targets: [ .target( @@ -30,16 +32,25 @@ let package = Package( dependencies: [ .product(name: "Spezi", package: "Spezi"), .product(name: "NIO", package: "swift-nio"), - .product(name: "NIOFoundationCompat", package: "swift-nio") + .product(name: "NIOFoundationCompat", package: "swift-nio"), + .product(name: "OrderedCollections", package: "swift-collections") ], resources: [ .process("Resources") ] ), + .target( + name: "XCTBluetooth", + dependencies: [ + .target(name: "SpeziBluetooth") + ] + ), .testTarget( name: "SpeziBluetoothTests", dependencies: [ - .target(name: "SpeziBluetooth") + .target(name: "SpeziBluetooth"), + .target(name: "XCTBluetooth"), + .product(name: "NIO", package: "swift-nio") ] ) ] diff --git a/README.md b/README.md index 09a071cf..bc4d67e3 100644 --- a/README.md +++ b/README.md @@ -16,32 +16,38 @@ SPDX-License-Identifier: MIT [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FStanfordSpezi%2FSpeziBluetooth%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/StanfordSpezi/SpeziBluetooth) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FStanfordSpezi%2FSpeziBluetooth%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/StanfordSpezi/SpeziBluetooth) -Connect and communicate with Bluetooth devices. +Connect and communicate with Bluetooth devices using modern programming paradigms. ## Overview -The Spezi Bluetooth module provides a convenient way to handle state management with a Bluetooth device, retrieve data from different services and characteristics, and write data to a combination of services and characteristics. +The Spezi Bluetooth module provides a convenient way to handle state management with a Bluetooth device, +retrieve data from different services and characteristics, +and write data to a combination of services and characteristics. + +This package uses Apples [CoreBluetooth](https://developer.apple.com/documentation/corebluetooth) framework under the hood. > [!NOTE] -> You will need a basic understanding of the Bluetooth Terminology and the underlying software model to understand the structure and API of the Spezi Bluetooth module. You can find a good overview in the [Wikipedia Bluetooth Low Energy (LE) Software Model section](https://en.wikipedia.org/wiki/Bluetooth_Low_Energy#Software_model) or the [Developer’s Guide -to Bluetooth Technology](https://www.bluetooth.com/blog/a-developers-guide-to-bluetooth/). +> You will need a basic understanding of the Bluetooth Terminology and the underlying software model to + understand the structure and API of the Spezi Bluetooth module. You can find a good overview in the + [Wikipedia Bluetooth Low Energy (LE) Software Model section](https://en.wikipedia.org/wiki/Bluetooth_Low_Energy#Software_model) + or the [Developer’s Guide to Bluetooth Technology](https://www.bluetooth.com/blog/a-developers-guide-to-bluetooth/). ## Setup -### 1. Add Spezi Bluetooth as a Dependency +### Add Spezi Bluetooth as a Dependency You need to add the Spezi Bluetooth Swift package to [your app in Xcode](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app#) or [Swift package](https://developer.apple.com/documentation/xcode/creating-a-standalone-swift-package-with-xcode#Add-a-dependency-on-another-Swift-package). > [!IMPORTANT] -> If your application is not yet configured to use Spezi, follow the [Spezi setup article](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/initial-setup) to setup the core Spezi infrastructure. +> If your application is not yet configured to use Spezi, follow the [Spezi setup article](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/initial-setup) to set up the core Spezi infrastructure. -### 2. Register the Module +### Register the Module The [`Bluetooth`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth) module needs to be registered in a Spezi-based application using the [`configuration`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/speziappdelegate/configuration) in a @@ -50,8 +56,9 @@ The [`Bluetooth`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/doc class ExampleAppDelegate: SpeziAppDelegate { override var configuration: Configuration { Configuration { - Bluetooth(services: [/* ... */]) - // ... + Bluetooth { + // discover devices ... + } } } } @@ -63,118 +70,127 @@ class ExampleAppDelegate: SpeziAppDelegate { ## Example -`MyDeviceModule` demonstrates the capabilities of the Spezi Bluetooth module. -This class integrates the [`Bluetooth`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth) module to create a `MyDevice` instance injected in the SwiftUI environment to send string messages over Bluetooth and collect them in a messages array. +### Create your Bluetooth device -> [!NOTE] -> The type uses the Spezi dependency injection of the `Bluetooth` module, the most common usage of the [`Bluetooth`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth) module. [You can learn more about the Spezi dependency injection mechanisms in the Spezi documentation](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module-dependency). +The [`Bluetooth`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth) +module allows to declarative define your Bluetooth device using a [`BluetoothDevice`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetoothdevice) implementation and property wrappers +like [`Service`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/service) and [`Characteristic`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/characteristic). -```swift -import Spezi -import SpeziBluetooth +The below code examples demonstrate how you can implement your own Bluetooth device. +First of all we define our Bluetooth service by implementing a [`BluetoothService`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetoothservice). +We use the [`Characteristic`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/characteristic) property wrapper to declare its characteristics. +Note that the value types needs to be optional and conform to [`ByteEncodable`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/byteencodable), [`ByteDecodable`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bytedecoable) or [`ByteCodable`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bytecodable) respectively. -public class MyDeviceModule: DefaultInitializable, Module { - /// Spezi dependency injection of the `Bluetooth` module; see https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module-dependency for more details. - @Dependency private var bluetooth: Bluetooth - /// Injecting the `MyDevice` class in the SwiftUI environment as documented at https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/interactions-with-swiftui - @Model private var myDevice: MyDevice - - - public required init() {} - - - /// Configuration method to create the `MyDevice` and pass in the Bluetooth module. - public func configure() { - self.myDevice = MyDevice(bluetooth: bluetooth) - } +```swift +class DeviceInformationService: BluetoothService { + @Characteristic(id: "2A29") + var manufacturer: String? + @Characteristic(id: "2A26") + var firmwareRevision: String? } ``` -The next step is to define the Bluetooth services and caracteristics that you want to read from or get notified about: +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 + property wrappers can also be used within a [`BluetoothService`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetoothservice) type. + ```swift -enum MyDeviceBluetoothConstants { - /// UUID for the example characteristic. - static let exampleCharacteristic = CBUUID(string: "a7779a75-f00a-05b4-147b-abf02f0d9b17") - /// Configuration for the example Bluetooth service. - static let exampleService = BluetoothService( - serviceUUID: CBUUID(string: "a7779a75-f00a-05b4-147b-abf02f0d9b17"), - characteristicUUIDs: [exampleCharacteristic] - ) +class MyDevice: BluetoothDevice { + @DeviceState(\.id) + var id: UUID + @DeviceState(\.name) + var name: String? + @DeviceState(\.state) + var state: PeripheralState + + @Service(id: "180A") + var deviceInformation = DeviceInformationService() + + @DeviceAction(\.connect) + var connect + @DeviceAction(\.disconnect) + var disconnect + + init() {} // required initializer } ``` -You will have to ensure that the [`Bluetooth`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth) module is correctly setup with the right services, e.g., as shown in the following example: +### Configure the Bluetooth Module + +We use the above `BluetoothDevice` implementation to configure the [`Bluetooth`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth) module within the +[SpeziAppDelegate](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/speziappdelegate). + ```swift -class ExampleAppDelegate: SpeziAppDelegate { +import Spezi + +class ExampleDelegate: SpeziAppDelegate { override var configuration: Configuration { Configuration { - Bluetooth(services: [MyDeviceBluetoothConstants.exampleService]) - // ... + Bluetooth { + // Define which devices type to discover by what criteria . + // In this case we search for some custom FFF0 characteristic that is advertised. + Discover(MyDevice.self, by: .advertisedService("FFF0")) + } } } } ``` -The `MyDevice` type showcases the interaction with the ``BluetoothService`` and the implementation of the ``BluetoothMessageHandler`` protocol. -It does all the message handling, and is responsible for parsing the information. +### Using the Bluetooth Module -> [!NOTE] -> We highly recommend to use SwiftNIO [`ByteBuffer`](https://swiftpackageindex.com/apple/swift-nio/2.61.1/documentation/niocore/bytebuffer)s to parse more complex data coming in from the wire. You can learn more about creating a `ByteBuffer` from a Foundation `Data` instance using [NIOFoundationCompat](https://swiftpackageindex.com/apple/swift-nio/2.61.1/documentation/niofoundationcompat/niocore/bytebuffer). +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:)) +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()). + +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. + +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. ```swift -import Foundation -import Observation -import OSLog - - -@Observable -public class MyDevice: BluetoothMessageHandler { - /// Spezi dependency injection of the `Bluetooth` module; see https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module-dependency for more details. - private let bluetooth: Bluetooth - private let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "Example") - - /// Array of messages received from the Bluetooth connection. - private(set) public var messages: [String] = [] - - - /// The current Bluetooth connection state. - public var bluetoothState: BluetoothState { - bluetooth.state - } - - - required init(bluetooth: Bluetooth) { - self.bluetooth = bluetooth - bluetooth.add(messageHandler: self) - } - - - /// Sends a string message over Bluetooth. - /// - /// - Parameter information: The string message to be sent. - public func send(information: String) async throws { - try await bluetooth.write( - Data(information.utf8), - service: MyDeviceBluetoothConstants.exampleService.serviceUUID, - characteristic: MyDeviceBluetoothConstants.exampleCharacteristic - ) - } - - // Example implementation of the ``BluetoothMessageHandler`` requirements. - public func recieve(_ data: Data, service: CBUUID, characteristic: CBUUID) { - switch service { - case MyDeviceBluetoothConstants.exampleService.serviceUUID: - guard MyDeviceBluetoothConstants.exampleCharacteristic == characteristic else { - logger.debug("Unknown characteristic Id: \(MyDeviceBluetoothConstants.exampleCharacteristic)") - return +import SpeziBluetooth +import SwiftUI + +struct MyView: View { + @Environment(Bluetooth.self) + var bluetooth + @Environment(MyDevice.self) + var myDevice: MyDevice? + + var body: some View { + List { + if let myDevice { + Section { + Text("Device") + Spacer() + Text("\(myDevice.state.description)") + } + } + + Section { + ForEach(bluetooth.nearbyDevices(for: MyDevice.self), id: \.id) { device in + Text("\(device.name ?? "unknown")") + } + } header: { + HStack { + Text("Devices") + .padding(.trailing, 10) + if bluetooth.isScanning { + ProgressView() + } + } } - - // Convert the received data into a string and append it to the messages array. - messages.append(String(decoding: data, as: UTF8.self)) - default: - logger.debug("Unknown Service: \(service.uuidString)") } + .scanNearbyDevices(with: bluetooth, autoConnect: true) } } ``` diff --git a/Sources/SpeziBluetooth/Bluetooth.swift b/Sources/SpeziBluetooth/Bluetooth.swift index 7b8bd7cc..5e6c1e76 100644 --- a/Sources/SpeziBluetooth/Bluetooth.swift +++ b/Sources/SpeziBluetooth/Bluetooth.swift @@ -6,148 +6,336 @@ // SPDX-License-Identifier: MIT // -import Combine -@_exported import class CoreBluetooth.CBUUID -import Foundation -import NIO -import NIOFoundationCompat -import Observation +import OSLog import Spezi -import UIKit -/// Enable applications to connect to Bluetooth devices. +/// Connect and communicate with Bluetooth devices using modern programming paradigms. /// -/// > Important: If your application is not yet configured to use Spezi, follow the [Spezi setup article](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/setup) to setup the core Spezi infrastructure. +/// This module allows to connect and communicate with Bluetooth devices using modern programming paradigms. +/// Under the hood this module uses Apple's [CoreBluetooth](https://developer.apple.com/documentation/corebluetooth) +/// through the ``BluetoothManager``. +/// +/// ### Create your Bluetooth device +/// +/// The Bluetooth module allows to declarative define your Bluetooth device using a ``BluetoothDevice`` implementation and property wrappers +/// like ``Service`` and ``Characteristic``. +/// +/// The below code examples demonstrate how you can implement your own Bluetooth device. +/// +/// First of all we define our Bluetooth service by implementing a ``BluetoothService``. +/// We use the ``Characteristic`` property wrapper to declare its characteristics. +/// Note that the value types needs to be optional and conform to ``ByteEncodable``, ``ByteDecodable`` or ``ByteCodable`` respectively. +/// +/// ```swift +/// class DeviceInformationService: BluetoothService { +/// @Characteristic(id: "2A29") +/// var manufacturer: String? +/// @Characteristic(id: "2A26") +/// var firmwareRevision: String? +/// } +/// ``` +/// +/// We can use this Bluetooth service now in the `MyDevice` implementation as follows. +/// +/// - Tip: We use the ``DeviceState`` and ``DeviceAction`` property wrappers to get access to the device state and its actions. Those two +/// property wrappers can also be used within a ``BluetoothService`` type. /// -/// The module needs to be registered in a Spezi-based application using the [`configuration`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/speziappdelegate/configuration) -/// in a [`SpeziAppDelegate`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/speziappdelegate): /// ```swift -/// class ExampleAppDelegate: SpeziAppDelegate { +/// class MyDevice: BluetoothDevice { +/// @DeviceState(\.id) +/// var id: UUID +/// @DeviceState(\.name) +/// var name: String? +/// @DeviceState(\.state) +/// var state: PeripheralState +/// +/// @Service(id: "180A") +/// var deviceInformation = DeviceInformationService() +/// +/// @DeviceAction(\.connect) +/// var connect +/// @DeviceAction(\.disconnect) +/// var disconnect +/// +/// init() {} // required initializer +/// } +/// ``` +/// +/// ### Configure the Bluetooth Module +/// +/// We use the above `BluetoothDevice` implementation to configure the `Bluetooth` module within the +/// [SpeziAppDelegate](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/speziappdelegate). +/// +/// ```swift +/// import Spezi +/// +/// class ExampleDelegate: SpeziAppDelegate { /// override var configuration: Configuration { /// Configuration { -/// Bluetooth(services: [/* ... */]) -/// // ... +/// Bluetooth { +/// // Define which devices type to discover by what criteria . +/// // In this case we search for some custom FFF0 characteristic that is advertised. +/// Discover(MyDevice.self, by: .advertisedService("FFF0")) +/// } /// } /// } /// } /// ``` -/// > Tip: You can learn more about a [`Module` in the Spezi documentation](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module). /// -/// You will have to ensure that the ``Bluetooth`` module is correctly set up with the right services, e.g., as shown in the example shown in the documentation. +/// ### Using the Bluetooth Module /// -/// ## Usage +/// 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). /// -/// The most common usage of the ``Bluetooth`` module is using it as a dependency using the [`@Dependency`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module/dependency) property wrapper within an other Spezi [`Module`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module). +/// You can use the ``SwiftUI/View/scanNearbyDevices(enabled:with:autoConnect:)`` and ``SwiftUI/View/autoConnect(enabled:with:)`` +/// 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()``. /// -/// [You can learn more about the Spezi dependency injection mechanisms in the Spezi documentation](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module-dependency). +/// To retrieve the list of nearby devices you may use ``nearbyDevices(for:)``. /// -/// The following example demonstrates the usage of this mechanism. -/// ```swift -/// class BluetoothExample: Module, BluetoothMessageHandler { -/// @Dependency private var bluetooth: Bluetooth +/// > 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. /// -/// /// The current Bluetooth connection state. -/// var bluetoothState: BluetoothState { -/// bluetooth.state -/// } -/// +/// ```swift +/// import SpeziBluetooth +/// import SwiftUI /// -/// // ... +/// struct MyView: View { +/// @Environment(Bluetooth.self) +/// var bluetooth +/// @Environment(MyDevice.self) +/// var myDevice: MyDevice? /// -/// -/// /// Configuration method to register the `BluetoothExample` as a ``BluetoothMessageHandler`` for the Bluetooth module. -/// func configure() { -/// bluetooth.add(messageHandler: self) -/// } -/// -/// -/// /// Sends a string message over Bluetooth. -/// /// -/// /// - Parameter information: The string message to be sent. -/// func send(information: String) async throws { -/// try await bluetooth.write( -/// Data(information.utf8), -/// service: Self.exampleService.serviceUUID, -/// characteristic: Self.exampleCharacteristic -/// ) -/// } -/// -/// func recieve(_ data: Data, service: CBUUID, characteristic: CBUUID) { -/// // ... +/// var body: some View { +/// List { +/// if let myDevice { +/// Section { +/// Text("Device") +/// Spacer() +/// Text("\(myDevice.state.description)") +/// } +/// } +/// +/// Section { +/// ForEach(bluetooth.nearbyDevices(for: MyDevice.self), id: \.id) { device in +/// Text("\(device.name ?? "unknown")") +/// } +/// } header: { +/// HStack { +/// Text("Devices") +/// .padding(.trailing, 10) +/// if bluetooth.isScanning { +/// ProgressView() +/// } +/// } +/// } +/// } +/// .scanNearbyDevices(with: bluetooth, autoConnect: true) /// } /// } /// ``` /// -/// > Tip: You can find a more extensive example in the main documentation. +/// ## Topics +/// +/// ### Configure the Bluetooth Module +/// - ``init(minimumRSSI:advertisementStaleInterval:_:)`` +/// +/// ### Bluetooth State +/// - ``state`` +/// - ``isScanning`` +/// +/// ### Nearby Devices +/// - ``nearbyDevices(for:)`` +/// - ``scanNearbyDevices(autoConnect:)`` +/// - ``stopScanning()`` @Observable -public class Bluetooth: Module, DefaultInitializable { +public class Bluetooth: Module, EnvironmentAccessible, BluetoothScanner { + static let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "Bluetooth") + private let bluetoothManager: BluetoothManager - - - /// Represents the current state of the Bluetooth connection. + private let deviceConfigurations: Set + + private var logger: Logger { + Self.logger + } + + + /// Represents the current state of Bluetooth. public var state: BluetoothState { bluetoothManager.state } - - - /// Initializes the Bluetooth module with provided services. - /// - /// - Parameters: - /// - services: List of Bluetooth services to manage. - public init(services: [BluetoothService]) { - bluetoothManager = BluetoothManager(services: services) + + /// Whether or not we are currently scanning for nearby devices. + public var isScanning: Bool { + bluetoothManager.isScanning } - - /// Default initializer with no services specified. - public required convenience init() { - self.init(services: []) + + @_documentation(visibility: internal) + public var hasConnectedDevices: Bool { + connectedDevicesModel.hasConnectedDevices } - - /// Sends a ByteBuffer to the connected Bluetooth device. + + + @MainActor private var nearbyDevices: [UUID: BluetoothDevice] = [:] + + /// Stores the connected device instance for every configured ``BluetoothDevice`` type. + @Model @ObservationIgnored private var connectedDevicesModel = ConnectedDevices() + /// Injects the ``BluetoothDevice`` instances from the `ConnectedDevices` model into the SwiftUI environment. + @Modifier @ObservationIgnored private var devicesInjector: ConnectedDevicesEnvironmentModifier + + + /// Configure the Bluetooth Module. + /// + /// Configures the Bluetooth Module with the provided set of ``DiscoveryConfiguration``s. + /// Below is a short code example on how you would discover a `ExampleDevice` by its advertised service id. + /// + /// ```swift + /// Bluetooth { + /// Discover(ExampleDevice.self, by: .advertisedService("...")) + /// } + /// ``` /// /// - Parameters: - /// - byteBuffer: Data in ByteBuffer format to send. - /// - service: UUID of the Bluetooth service. - /// - characteristic: UUID of the Bluetooth characteristic. - public func write(_ byteBuffer: inout ByteBuffer, service: CBUUID, characteristic: CBUUID) async throws { - guard let data = byteBuffer.readData(length: byteBuffer.readableBytes) else { + /// - 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: + public init( + minimumRSSI: Int = BluetoothManager.Defaults.defaultMinimumRSSI, + advertisementStaleInterval: TimeInterval = BluetoothManager.Defaults.defaultStaleTimeout, + @DiscoveryConfigurationBuilder _ devices: () -> Set + ) { + let configuration = devices() + let deviceTypes = configuration.deviceTypes + + self.bluetoothManager = BluetoothManager( + devices: configuration.parseDeviceDescription(), + minimumRSSI: minimumRSSI, + advertisementStaleInterval: advertisementStaleInterval + ) + self.deviceConfigurations = configuration + self._devicesInjector = Modifier(wrappedValue: ConnectedDevicesEnvironmentModifier(configuredDeviceTypes: deviceTypes)) + + observeNearbyDevices() // register observation tracking + } + + private func observeNearbyDevices() { + withObservationTracking { + _ = bluetoothManager.discoveredPeripherals + } onChange: { [weak self] in + Task { @MainActor [weak self] in + self?.handleNearbyDevicesChange() + } + self?.observeNearbyDevices() + } + } + + 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.discoveredPeripherals[uuid] else { return } - - try await write(data, service: service, characteristic: characteristic) + + withObservationTracking { + _ = peripheral.state + } onChange: { [weak self] in + Task { @MainActor [weak self] in + self?.handlePeripheralStateChange() + } + + self?.observePeripheralState(of: uuid) + } } - - /// Sends data to the connected Bluetooth device. - /// - /// - Parameters: - /// - data: Data to send. - /// - service: UUID of the Bluetooth service. - /// - characteristic: UUID of the Bluetooth characteristic. - public func write(_ data: Data, service: CBUUID, characteristic: CBUUID) async throws { - let writeTask = Task { - try bluetoothManager.write(data: data, service: service, characteristic: characteristic) + + @MainActor + private func handleNearbyDevicesChange() { + let discoveredDevices = bluetoothManager.discoveredPeripherals + + var checkForConnected = false + + // remove all delete keys + for key in nearbyDevices.keys where discoveredDevices[key] == nil { + checkForConnected = true + nearbyDevices.removeValue(forKey: key) + } + + // add devices for new keys + for (uuid, peripheral) in discoveredDevices where nearbyDevices[uuid] == nil { + guard let configuration = deviceConfigurations.find(for: peripheral.advertisementData, logger: logger) else { + logger.warning("Ignoring peripheral \(peripheral.debugDescription) that cannot be mapped to a device class.") + continue + } + + let device = configuration.anyDeviceType.init() + device.inject(peripheral: peripheral) + nearbyDevices[uuid] = device + + checkForConnected = true + observePeripheralState(of: uuid) + } + + if checkForConnected { + // ensure that we get notified about, e.g., a connected peripheral that is instantly removed + handlePeripheralStateChange() } - try await writeTask.value } - - /// Requests a read of a combination of service and characteristic - public func read(service: CBUUID, characteristic: CBUUID) throws { - try bluetoothManager.read(service: service, characteristic: characteristic) + + @MainActor + private func handlePeripheralStateChange() { + // check for active connected device + let connectedDevices = bluetoothManager.discoveredPeripherals + .filter { _, value in + value.state == .connected + } + .compactMap { key, _ in + (key, nearbyDevices[key]) // map them to their devices class + } + .reduce(into: [:]) { result, tuple in + result[tuple.0] = tuple.1 + } + + self.connectedDevicesModel.update(with: connectedDevices) } - - /// Adds a new message handler to process incoming Bluetooth messages. + + /// Retrieve nearby devices. /// - /// - Parameter messageHandler: The message handler to add. - public func add(messageHandler: BluetoothMessageHandler) { - bluetoothManager.add(messageHandler: messageHandler) + /// Use this method to retrieve nearby discovered Bluetooth peripherals. 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. + @MainActor + public func nearbyDevices(for device: Device.Type = Device.self) -> [Device] { + nearbyDevices.values.compactMap { device in + device as? Device + } } - - /// Removes a specified message handler. + + /// Scan for nearby bluetooth devices. + /// + /// Scans on nearby devices based on the ``Discover`` declarations provided in the initializer. /// - /// - Parameter messageHandler: The message handler to remove. - public func remove(messageHandler: BluetoothMessageHandler) { - bluetoothManager.remove(messageHandler: messageHandler) + /// All discovered devices for a given type can be accessed through the ``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. + /// + /// - Tip: Scanning for nearby devices can easily be managed via the ``SwiftUI/View/scanNearbyDevices(enabled:with:autoConnect:)`` + /// modifier. + /// + /// - Parameter 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) async { + await bluetoothManager.scanNearbyDevices(autoConnect: autoConnect) + } + + /// Stop scanning for nearby bluetooth devices. + public func stopScanning() async { + await bluetoothManager.stopScanning() } } diff --git a/Sources/SpeziBluetooth/BluetoothError.swift b/Sources/SpeziBluetooth/BluetoothError.swift deleted file mode 100644 index 664f1856..00000000 --- a/Sources/SpeziBluetooth/BluetoothError.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// 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 Foundation - - -/// Represents errors that can occur during Bluetooth operations. -public enum BluetoothError: String, Error, CustomStringConvertible, LocalizedError { - /// Error indicating that the device is not connected. - case notConnected - /// Error indicating that the device connection has timed out. - case deviceTimedOut - /// The characteristic you requested was not readable. - case notAReadableCharacteristic - - - /// Provides a human-readable description of the error. - public var description: String { - errorDescription ?? "BluetoothError: \(rawValue)" - } - - /// Provides a detailed description of the error. - public var errorDescription: String? { - switch self { - case .notConnected: - String(localized: "BLUETOOTH_ERROR_NOT_CONNECTED", bundle: .module) - case .deviceTimedOut: - String(localized: "BLUETOOTH_ERROR_DEVICE_TIME_OUT", bundle: .module) - case .notAReadableCharacteristic: - String(localized: "BLUETOOTH_ERROR_NOT_READABLE", bundle: .module) - } - } -} diff --git a/Sources/SpeziBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/BluetoothManager.swift deleted file mode 100644 index 71b074ff..00000000 --- a/Sources/SpeziBluetooth/BluetoothManager.swift +++ /dev/null @@ -1,399 +0,0 @@ -// -// 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 Combine -import CoreBluetooth -import NIO -import Observation -import OSLog - - -/// Manages the Bluetooth connections, state, and data transfer. -@Observable -class BluetoothManager: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { - // We use an implicity unwrapped optional here as we can gurantee that the value will be available after the initialization of the - // `BluetoothManager` and we refer to the `self` in the initializer of the `CBCentralManager`. - // swiftlint:disable:next implicitly_unwrapped_optional - @ObservationIgnored private var centralManager: CBCentralManager! - @ObservationIgnored private var discoveredPeripheral: CBPeripheral? - @ObservationIgnored private var transferCharacteristics: [CBCharacteristic] = [] - private let minimumRSSI: Int - - @ObservationIgnored private var messageHandlers: [BluetoothMessageHandler] - private let services: [BluetoothService] - private let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "BluetoothManager") - private let messageHandlerQueue = DispatchQueue(label: "edu.stanford.spezi.bluetooth", qos: .userInitiated, attributes: .concurrent) - - /// Represents the current state of Bluetooth connection. - private(set) var state: BluetoothState - - - private var serviceIDs: [CBUUID] { - services.map(\.serviceUUID) - } - - private var characteristicUUIDs: [CBUUID] { - services.flatMap(\.characteristicUUIDs) - } - - - /// Initializes the BluetoothManager with provided services and optional message handlers. - /// - /// - Parameters: - /// - services: List of Bluetooth services to manage. - /// - messageHandlers: List of handlers for processing incoming Bluetooth messages. - /// - minimumRSSI: Minimum RSSI value to consider when discovering peripherals. - init(services: [BluetoothService], messageHandlers: [BluetoothMessageHandler] = [], minimumRSSI: Int = -65) { - self.minimumRSSI = minimumRSSI - self.services = services - self.messageHandlers = messageHandlers - self.state = .poweredOff - - super.init() - - centralManager = CBCentralManager(delegate: self, queue: nil, options: [CBCentralManagerOptionShowPowerAlertKey: true]) - } - - - /// Sends data to the connected peripheral. - /// - /// - Parameters: - /// - data: Data to send. - /// - service: UUID of the service. - /// - characteristic: UUID of the characteristic. - func write(data: Data, service: CBUUID, characteristic: CBUUID) throws { - guard let discoveredPeripheral = discoveredPeripheral, - let transferCharacteristic = transferCharacteristics.first(where: { $0.uuid == characteristic }), - transferCharacteristic.service?.uuid == service else { - throw BluetoothError.notConnected - } - - let hexDescription = data.reduce(into: "") { - $0.append(String(format: "%02x", $1)) - } - logger.debug("Write \(data.count) bytes: \(hexDescription)") - - discoveredPeripheral.writeValue(data, for: transferCharacteristic, type: .withResponse) - } - - /// Requests a read of a combination of service and characteristic - func read(service: CBUUID, characteristic: CBUUID) throws { - guard let discoveredPeripheral = discoveredPeripheral, - let readCharacteristic = transferCharacteristics.first(where: { $0.uuid == characteristic }), - readCharacteristic.service?.uuid == service else { - throw BluetoothError.notConnected - } - - guard readCharacteristic.properties.contains(.read) else { - throw BluetoothError.notAReadableCharacteristic - } - - discoveredPeripheral.readValue(for: readCharacteristic) - } - - - /// Adds a new message handler to the list. - /// - /// - Parameter messageHandler: The handler to add. - func add(messageHandler: BluetoothMessageHandler) { - messageHandlers.append(messageHandler) - } - - /// Removes a specified message handler from the list. - /// - /// - Parameter messageHandler: The handler to remove. - func remove(messageHandler: BluetoothMessageHandler) { - messageHandlers.removeAll(where: { $0 === messageHandler }) - } - - - // MARK: - Helper Methods - - /// We will first check if we are already connected to our counterpart - /// Otherwise, scan for peripherals - specifically for our service's 128bit CBUUID - private func retrievePeripheral() { - self.state = .scanning - - let connectedPeripherals = centralManager.retrieveConnectedPeripherals(withServices: services.map(\.serviceUUID)) - - logger.debug("Found connected Peripherals with transfer service: \(connectedPeripherals.debugDescription)") - - if let connectedPeripheral = connectedPeripherals.last { - logger.debug("Connecting to peripheral \(connectedPeripheral)") - self.discoveredPeripheral = connectedPeripheral - centralManager.connect(connectedPeripheral, options: nil) - } else { - // We were not connected to our counterpart, so start scanning - centralManager.scanForPeripherals( - withServices: serviceIDs, - options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] - ) - } - } - - - /// Call this when things either go wrong, or you're done with the connection. - /// This cancels any subscriptions if there are any, or straight disconnects if not. - /// (didUpdateNotificationStateForCharacteristic will cancel the connection if a subscription is involved) - private func cleanup() { - self.state = .disconnected - - // Don't do anything if we're not connected - guard let discoveredPeripheral = discoveredPeripheral, - case .connected = discoveredPeripheral.state else { - return - } - - for service in discoveredPeripheral.services ?? [] { - for characteristic in service.characteristics ?? [] { - if characteristicUUIDs.contains(characteristic.uuid) && characteristic.isNotifying { - // It is notifying, so unsubscribe - self.discoveredPeripheral?.setNotifyValue(false, for: characteristic) - } - } - } - - // If we've gotten this far, we're connected, but we're not subscribed, so we just disconnect - centralManager.cancelPeripheralConnection(discoveredPeripheral) - } - - - // MARK: - CBCentralManagerDelegate - func centralManagerDidUpdateState(_ central: CBCentralManager) { - switch central.state { - case .poweredOn: - // Start working with the peripheral - logger.info("CBManager is powered on") - retrievePeripheral() - case .poweredOff: - logger.info("CBManager is not powered on") - self.state = .poweredOff - case .resetting: - logger.info("CBManager is resetting") - self.state = .poweredOff - case .unauthorized: - switch CBManager.authorization { - case .denied: - logger.log("You are not authorized to use Bluetooth") - case .restricted: - logger.log("Bluetooth is restricted") - default: - logger.log("Unexpected authorization") - } - self.state = .unauthorized - case .unknown: - logger.log("CBManager state is unknown") - self.state = .poweredOff - case .unsupported: - logger.log("Bluetooth is not supported on this device") - self.state = .poweredOff - @unknown default: - logger.log("A previously unknown central manager state occurred") - self.state = .poweredOff - } - } - - func centralManager( - _ central: CBCentralManager, - didDiscover peripheral: CBPeripheral, - advertisementData: [String: Any], - // We have to use NSNumber to confrom to the `CBCentralManagerDelegate` delegate methods. - // swiftlint:disable:next legacy_objc_type - rssi: NSNumber - ) { - // This callback comes whenever a peripheral that is advertising the transfer serviceUUID is discovered. - // We check the RSSI, to make sure it's close enough that we're interested in it, and if it is, - // we start the connection process - - // Reject if the signal strength is too low to attempt data transfer. - // Change the minimum RSSI value depending on your app’s use case. - guard rssi.intValue >= minimumRSSI else { - logger.info("Discovered perhiperal not in expected range, at \(rssi.intValue)") - return - } - - logger.info("Discovered \(peripheral.name ?? "unknown device") at \(rssi.intValue)") - - // Device is in range - have we already seen it? - if discoveredPeripheral != peripheral { - // Save a local copy of the peripheral, so CoreBluetooth doesn't get rid of it. - discoveredPeripheral = peripheral - - // And finally, connect to the peripheral. - logger.info("Connecting to perhiperal \(peripheral)") - centralManager.connect(peripheral, options: nil) - } - } - - func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { - // If the connection fails for whatever reason, we need to deal with it. - logger.error("Failed to connect to \(peripheral): \(String(describing: error))") - cleanup() - self.state = .disconnected - } - - func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { - // We've connected to the peripheral, now we need to discover the services and characteristics to find the 'transfer' characteristic. - logger.log("Peripheral Connected") - self.state = .connected - - // Stop scanning - centralManager.stopScan() - logger.log("Scanning stopped") - - // Make sure we get the discovery callbacks - peripheral.delegate = self - - // Search only for services that match our UUID - peripheral.discoverServices(serviceIDs) - } - - - func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { - // Once the disconnection happens, we need to clean up our local copy of the peripheral - logger.log("Perhiperal Disconnected") - discoveredPeripheral = nil - transferCharacteristics = [] - self.state = .disconnected - - retrievePeripheral() - } - - - // MARK: - CBPeripheralDelegate - - func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { - // The peripheral letting us know when services have been invalidated. - for service in invalidatedServices where serviceIDs.contains(service.uuid) { - logger.log("Transfer service is invalidated - rediscover services") - peripheral.discoverServices(serviceIDs) - } - } - - func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { - // The Transfer Service was discovered - if let error = error { - logger.error("Error discovering services: \(error.localizedDescription)") - cleanup() - return - } - - // Discover the characteristic we want... - - // Loop through the newly filled peripheral.services array, just in case there's more than one. - guard let peripheralServices = peripheral.services else { - return - } - - for service in peripheralServices { - if let characteristicIDs = services.first(where: { $0.serviceUUID == service.uuid })?.characteristicUUIDs { - peripheral.discoverCharacteristics(characteristicIDs, for: service) - } - } - } - - func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { - // The Transfer characteristic was discovered. - // Once this has been found, we want to subscribe to it, which lets the peripheral know we want the data it contains - - // Deal with errors (if any). - if let error = error { - logger.error("Error discovering characteristics: \(error.localizedDescription)") - cleanup() - return - } - - // Again, we loop through the array, just in case and check if it's the right one - guard let serviceCharacteristics = service.characteristics, - let serviceConfiguration = services.first(where: { $0.serviceUUID == service.uuid }) else { - return - } - - for characteristic in serviceCharacteristics where serviceConfiguration.characteristicUUIDs.contains(characteristic.uuid) { - // If it is, subscribe to it - transferCharacteristics.append(characteristic) - peripheral.setNotifyValue(true, for: characteristic) - } - - // Once this is complete, we just need to wait for the data to come in. - } - - func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { - // This callback lets us know more data has arrived via notification on the characteristic - - // Deal with errors (if any) - if let error = error { - logger.error("Error discovering characteristics: \(error.localizedDescription)") - cleanup() - return - } - - guard let serviceId = characteristic.service?.uuid ?? serviceId(forCharacteristic: characteristic.uuid) else { - logger.error("Error identifying service id for characteristic \(characteristic.uuid)") - return - } - - guard let data = characteristic.value else { - return - } - - for messageHandler in messageHandlers { - messageHandlerQueue.async { - Task { - await messageHandler.recieve(data, service: serviceId, characteristic: characteristic.uuid) - } - } - } - } - - func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { - // he peripheral letting us know whether our subscribe/unsubscribe happened or not - - // Deal with errors (if any) - if let error = error { - logger.error("Error changing notification state: \(error.localizedDescription)") - return - } - - // Exit if it's not the transfer characteristic - guard characteristicUUIDs.contains(characteristic.uuid) else { - return - } - - if characteristic.isNotifying { - // Notification has started - logger.log("Notification began on \(characteristic.uuid.uuidString)") - - if characteristic.properties.contains(.read) { - discoveredPeripheral?.readValue(for: characteristic) - } - } else { - // Notification has stopped, so disconnect from the peripheral - logger.log("Notification stopped on \(characteristic.uuid.uuidString). Disconnecting") - cleanup() - } - } - - func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { - // This is called when peripheral is ready to accept more data when using write without response - logger.log("Peripheral is ready") - self.state = .connected - } - - - private func serviceId(forCharacteristic characteristic: CBUUID) -> CBUUID? { - services.first(where: { $0.characteristicUUIDs.contains(characteristic) })?.serviceUUID - } - - - deinit { - centralManager.stopScan() - self.state = .poweredOff - logger.log("Scanning stopped") - } -} diff --git a/Sources/SpeziBluetooth/BluetoothMessageHandler.swift b/Sources/SpeziBluetooth/BluetoothMessageHandler.swift deleted file mode 100644 index ddc409bc..00000000 --- a/Sources/SpeziBluetooth/BluetoothMessageHandler.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// 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 CoreBluetooth -import Foundation - - -/// Protocol defining methods for handling Bluetooth messages. -public protocol BluetoothMessageHandler: AnyObject { - /// Handles the receipt of Bluetooth data from a specified service and characteristic. - /// - /// - Parameters: - /// - data: The received Bluetooth data. - /// - service: The UUID of the Bluetooth service from which the data was received. - /// - characteristic: The UUID of the characteristic from which the data was received. - func recieve(_ data: Data, service: CBUUID, characteristic: CBUUID) async -} diff --git a/Sources/SpeziBluetooth/BluetoothService.swift b/Sources/SpeziBluetooth/BluetoothService.swift deleted file mode 100644 index 3ce40618..00000000 --- a/Sources/SpeziBluetooth/BluetoothService.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// 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 CoreBluetooth - - -/// Represents a Bluetooth service with its associated characteristics. -public struct BluetoothService { - /// The unique identifier for the Bluetooth service. - public let serviceUUID: CBUUID - - /// A list of unique identifiers for the characteristics associated with the service. - public let characteristicUUIDs: [CBUUID] - - /// Initializes a new Bluetooth service with the specified service UUID and characteristic UUIDs. - /// - /// - Parameters: - /// - serviceUUID: The unique identifier for the Bluetooth service. - /// - characteristicUUIDs: A list of unique identifiers for the characteristics associated with the service. - public init(serviceUUID: CBUUID, characteristicUUIDs: [CBUUID]) { - self.serviceUUID = serviceUUID - self.characteristicUUIDs = characteristicUUIDs - } -} diff --git a/Sources/SpeziBluetooth/BluetoothState.swift b/Sources/SpeziBluetooth/BluetoothState.swift deleted file mode 100644 index a78f7d25..00000000 --- a/Sources/SpeziBluetooth/BluetoothState.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// 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 -// - - -/// Represents the various states of a Bluetooth module. -public enum BluetoothState: String { - /// The Bluetooth module is turned off. - case poweredOff - - /// The application does not have permission to use Bluetooth features. - case unauthorized - - /// The Bluetooth module is not connected to any device. - case disconnected - - /// The Bluetooth module is actively scanning for nearby devices. - case scanning - - /// The Bluetooth module is successfully connected to a device. - case connected -} diff --git a/Sources/SpeziBluetooth/Bridging/BluetoothScanner.swift b/Sources/SpeziBluetooth/Bridging/BluetoothScanner.swift new file mode 100644 index 00000000..7946a625 --- /dev/null +++ b/Sources/SpeziBluetooth/Bridging/BluetoothScanner.swift @@ -0,0 +1,31 @@ +// +// 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 { + /// The current state of the bluetooth scanner. + var state: BluetoothState { get } + + /// Whether or not we are currently scanning for nearby devices. + var isScanning: Bool { get } + + /// Indicates if there is at least one connected peripheral. + 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 + + /// Stop scanning for nearby bluetooth devices. + func stopScanning() async +} diff --git a/Sources/SpeziBluetooth/Coding/Bool+ByteCodable.swift b/Sources/SpeziBluetooth/Coding/Bool+ByteCodable.swift new file mode 100644 index 00000000..0cf78b3e --- /dev/null +++ b/Sources/SpeziBluetooth/Coding/Bool+ByteCodable.swift @@ -0,0 +1,31 @@ +// +// 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 NIO + + +extension Bool: ByteCodable { + /// Decode a `Bool` from a Boolean characteristic (see GATT Specification Supplement, 3.36 Boolean). + /// + /// Be aware of the difference to Boolean fields (see GATT Specification Supplement, 3.36.1 Boolean). + public init?(from byteBuffer: inout ByteBuffer) { + guard let bytes = byteBuffer.readBytes(length: 1), + let byte = bytes.first else { + return nil + } + + self = byte > 0 + } + + /// Encodes a `Bool` to a Boolean characteristic (see GATT Specification Supplement, 3.36 Boolean). + /// + /// Be aware of the difference to Boolean fields (see GATT Specification Supplement, 3.36.1 Boolean). + public func encode(to byteBuffer: inout ByteBuffer) { + byteBuffer.writeBytes([self ? 1 : 0]) + } +} diff --git a/Sources/SpeziBluetooth/Coding/ByteCodable.swift b/Sources/SpeziBluetooth/Coding/ByteCodable.swift new file mode 100644 index 00000000..30986e56 --- /dev/null +++ b/Sources/SpeziBluetooth/Coding/ByteCodable.swift @@ -0,0 +1,84 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import NIOCore +import NIOFoundationCompat + + +/// A type that is decodable from a `ByteBuffer`. +/// +/// Conforming types can be decoded from a `ByteBuffer´ assuming it holds +/// properly formatted binary data. +/// +/// - Note: For reference, the types Bluetooth supports are illustrated in +/// Bluetooth Core Specification, Volume 1, Part E, 3.9 Type Names. +public protocol ByteDecodable { + /// Decode the type from the `ByteBuffer`. + /// + /// Initialize a new instance using the byte representation provided by the `ByteBuffer`. + /// This call should move the `readerIndex` forwards. + /// + /// - Note: Returns nil if no valid byte representation could be found. + /// - Parameter byteBuffer: The ByteBuffer to read from. + init?(from byteBuffer: inout ByteBuffer) +} + + +/// A type that is decodable to a `ByteBuffer. +/// +/// Conforming types can be encoded into a `ByteBuffer`. +/// +/// - Note: For reference, the types Bluetooth supports are illustrated in +/// Bluetooth Core Specification, Volume 1, Part E, 3.9 Type Names. +public protocol ByteEncodable { + /// Encode into the `ByteBuffer`. + /// + /// Encode the byte representation of this type into the provided `ByteBuffer`. + /// This call should move the `writerIndex` forwards. + /// + /// - Parameter byteBuffer: The ByteBuffer to write into. + func encode(to byteBuffer: inout ByteBuffer) +} + + +/// A type that is encodable to and decodable from a byte representation. +/// +/// Conforming types can be encoded into or decodable from a `ByteBuffer`. +/// +/// - Note: For reference, the types Bluetooth supports are illustrated in +/// Bluetooth Core Specification, Volume 1, Part E, 3.9 Type Names. +public typealias ByteCodable = ByteEncodable & ByteDecodable + + +extension ByteDecodable { + /// Decode the type from `Data`. + /// + /// Initialize a new instance using the byte representation provided. + /// + /// - Note: Returns nil if no valid byte representation could be found. + /// - Parameter data: The data to decode. + public init?(data: Data) { + var buffer = ByteBuffer(data: data) + self.init(from: &buffer) + } +} + + +extension ByteEncodable { + /// Encode to data. + /// + /// Encode the byte representation of this type. + /// + /// - Returns: The encoded data. + public func encode() -> Data { + var buffer = ByteBuffer() + encode(to: &buffer) + return buffer.getData(at: 0, length: buffer.readableBytes) ?? Data() + } +} diff --git a/Sources/SpeziBluetooth/Coding/Data+ByteCodable.swift b/Sources/SpeziBluetooth/Coding/Data+ByteCodable.swift new file mode 100644 index 00000000..f9019bac --- /dev/null +++ b/Sources/SpeziBluetooth/Coding/Data+ByteCodable.swift @@ -0,0 +1,32 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import NIO + + +extension Data: ByteCodable { + /// Decode a data blob. + /// + /// Copies all bytes from the ByteBuffer into a `Data` instance. + /// - Parameter byteBuffer: The ByteBuffer to decode from. + public init?(from byteBuffer: inout ByteBuffer) { + guard let data = byteBuffer.readData(length: byteBuffer.readableBytes) else { + return nil + } + self = data + } + + /// Encode a data blob. + /// + /// Copies the data instance into the ByteBuffer. + /// - Parameter byteBuffer: The ByteBuffer to write to. + public func encode(to byteBuffer: inout ByteBuffer) { + byteBuffer.writeData(self) + } +} diff --git a/Sources/SpeziBluetooth/Coding/FixedWithInteger+ByteCodable.swift b/Sources/SpeziBluetooth/Coding/FixedWithInteger+ByteCodable.swift new file mode 100644 index 00000000..bb48c675 --- /dev/null +++ b/Sources/SpeziBluetooth/Coding/FixedWithInteger+ByteCodable.swift @@ -0,0 +1,62 @@ +// +// 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 NIO + + +/// `ByteCodable` types that are a `FixedWithInteger`. +/// +/// - Note: For reference, the basic types in Bluetooth are illustrated in +/// Bluetooth Core Specification, Volume 1, Part E, 3.9.1 Basic types. +protocol FixedWidthByteCodable: FixedWidthInteger, ByteCodable {} + + +extension FixedWidthByteCodable { + /// Decodes a fixed-width integer from its byte representation. + /// + /// Decodes a `FixedWidthInteger` from a `ByteBuffer`. + /// + /// This covers `uint8`, `uint16`, `uint32` and `uint64` of `uint#` types of Bluetooth. + /// Further, it covers `int8`, `int16`, `int32`, and `int64` of `int#` types of Bluetooth. + /// + /// - Note: For reference, the basic types in Bluetooth are illustrated in + /// Bluetooth Core Specification, Volume 1, Part E, 3.9.1 Basic types. + /// - Parameter byteBuffer: The bytebuffer to decode from. + public init?(from byteBuffer: inout ByteBuffer) { + guard let value = byteBuffer.readInteger(endianness: .little, as: Self.self) else { + return nil + } + self = value + } + + /// Encodes a fixed-width integer to its byte representation. + /// + /// Encodes a `FixedWidthInteger` into a `ByteBuffer`. + /// + /// This covers `uint8`, `uint16`, `uint32` and `uint64` of `uint#` types of Bluetooth. + /// Further, it covers `int8`, `int16`, `int32`, and `int64` of `int#` types of Bluetooth. + /// + /// - Note: For reference, the basic types in Bluetooth are illustrated in + /// Bluetooth Core Specification, Volume 1, Part E, 3.9.1 Basic types. + /// - Parameter byteBuffer: The bytebuffer to decode from. + public func encode(to byteBuffer: inout ByteBuffer) { + byteBuffer.writeInteger(self, endianness: .little) + } +} + + +extension UInt8: FixedWidthByteCodable {} +extension UInt16: FixedWidthByteCodable {} +extension UInt32: FixedWidthByteCodable {} +extension UInt64: FixedWidthByteCodable {} + + +extension Int8: FixedWidthByteCodable {} +extension Int16: FixedWidthByteCodable {} +extension Int32: FixedWidthByteCodable {} +extension Int64: FixedWidthByteCodable {} diff --git a/Sources/SpeziBluetooth/Coding/String+ByteCodable.swift b/Sources/SpeziBluetooth/Coding/String+ByteCodable.swift new file mode 100644 index 00000000..32c67a35 --- /dev/null +++ b/Sources/SpeziBluetooth/Coding/String+ByteCodable.swift @@ -0,0 +1,47 @@ +// +// 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 NIO + + +extension String: ByteCodable { + /// Decodes an utf8 string from its byte representation. + /// + /// Decodes an utf8 string from a `ByteBuffer`. + /// + /// - Note: This implementation assumes that all bytes in the ByteBuffer are representing + /// the string. + /// + /// This implements decoding the `utf8s` variable-length string type of Bluetooth. + /// This implementation does not account for fixed-length strings (e.g., utf8s{#} and utf8s{#z} representations). + /// + /// - Note: For reference, the variable-length types in Bluetooth are illustrated in + /// Bluetooth Core Specification, Volume 1, Part E, 3.9.3 Variable length types. + /// - Parameter byteBuffer: The bytebuffer to decode from. + public init?(from byteBuffer: inout ByteBuffer) { + guard let string = byteBuffer.readString(length: byteBuffer.readableBytes) else { + return nil + } + + self = string + } + + /// Encodes an utf8 string to its byte representation. + /// + /// Encodes an utf8 string into a `ByteBuffer`. + /// + /// This implements decoding the `utf8s` variable-length string type of Bluetooth. + /// This implementation does not account for fixed-length strings (e.g., utf8s{#} and utf8s{#z} representations). + /// + /// - Note: For reference, the variable-length types in Bluetooth are illustrated in + /// Bluetooth Core Specification, Volume 1, Part E, 3.9.3 Variable length types. + /// - Parameter byteBuffer: The bytebuffer to decode from. + public func encode(to byteBuffer: inout ByteBuffer) { + byteBuffer.writeString(self) + } +} diff --git a/Sources/SpeziBluetooth/Configuration/Discover.swift b/Sources/SpeziBluetooth/Configuration/Discover.swift new file mode 100644 index 00000000..3c479dd3 --- /dev/null +++ b/Sources/SpeziBluetooth/Configuration/Discover.swift @@ -0,0 +1,38 @@ +// +// 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 +// + + +/// Declare how a bluetooth device is discovered. +/// +/// Declares by which ``DiscoveryCriteria`` a given ``BluetoothDevice`` implementation is discovered. +/// +/// - Important: The discovery criteria must be unique across all discovery configurations. Not doing so will result in undefined behavior. +/// +/// ## Topics +/// +/// ### Discovering a device +/// +/// - ``init(_:by:)`` +/// +/// ### Semantic Model +/// - ``DiscoveryConfiguration`` +/// - ``DiscoveryConfigurationBuilder`` +public struct Discover { + let deviceType: Device.Type + let discoveryCriteria: DiscoveryCriteria + + + /// Create a discovery for a given device type. + /// - Parameters: + /// - device: The type of a ``BluetoothDevice`` implementation. + /// - discoveryCriteria: The criteria by which the device is discovered. + public init(_ device: Device.Type, by discoveryCriteria: DiscoveryCriteria) { + self.deviceType = device + self.discoveryCriteria = discoveryCriteria + } +} diff --git a/Sources/SpeziBluetooth/Configuration/DiscoveryConfiguration.swift b/Sources/SpeziBluetooth/Configuration/DiscoveryConfiguration.swift new file mode 100644 index 00000000..f47286f1 --- /dev/null +++ b/Sources/SpeziBluetooth/Configuration/DiscoveryConfiguration.swift @@ -0,0 +1,38 @@ +// +// 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 { + 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 new file mode 100644 index 00000000..03bad5cb --- /dev/null +++ b/Sources/SpeziBluetooth/Configuration/DiscoveryConfigurationBuilder.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 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/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift new file mode 100644 index 00000000..29012278 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -0,0 +1,693 @@ +// +// 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 CoreBluetooth +import NIO +import Observation +import OrderedCollections +import OSLog + + +private struct IsRunningBluetoothQueue { + init() {} +} + + +/// Connect and communicate with Bluetooth devices. +/// +/// This module allows to connect and communicate with Bluetooth devices using modern programming paradigms. +/// Under the hood this module uses Apple's [CoreBluetooth](https://developer.apple.com/documentation/corebluetooth). +/// +/// ### Configure the Bluetooth Manager +/// +/// 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`. +/// +/// Below is a short code example to discover devices with a Heart Rate service. +/// +/// ```swift +/// let manager = BluetoothManager(devices [ +/// DeviceDescription(discoverBy: .advertisedService("180D"), services: [ +/// ServiceDescription(serviceId: "180D", characteristics: [ +/// "2A37", // heart rate measurement +/// "2A38", // body sensor location +/// "2A39" // heart rate control point +/// ]) +/// ]) +/// ]) +/// +/// manager.scanNearbyDevices() +/// // ... +/// manager.stopScanning() +/// ``` +/// +/// ### Searching for nearby devices +/// +/// You can scan for nearby devices using the ``scanNearbyDevices(autoConnect:)`` and stop scanning with ``stopScanning()``. +/// All discovered peripherals will be populated through the ``nearbyPeripherals`` or ``nearbyPeripheralsView`` 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:)`` +/// modifiers within your SwiftUI view to automatically manage device scanning and/or auto connect to the +/// first available device. +/// +/// ## Topics +/// +/// ### Create a Bluetooth Manager +/// +/// - ``init(devices:minimumRSSI:advertisementStaleInterval:)`` +/// +/// ### Bluetooth State +/// +/// - ``state`` +/// - ``isScanning`` +/// +/// ### Discovering nearby Peripherals +/// - ``nearbyPeripherals`` +/// - ``nearbyPeripheralsView`` +/// - ``scanNearbyDevices(autoConnect:)`` +/// - ``stopScanning()`` +@Observable +public class BluetoothManager { + private let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "BluetoothManager") + /// The dispatch queue for all Bluetooth related functionality. This is serial (not `.concurrent`) to ensure synchronization. + private let dispatchQueue = DispatchQueue(label: "edu.stanford.spezi.bluetooth", qos: .userInitiated) + private let isRunningBluetoothQueueKey = DispatchSpecificKey() + + /// 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 @ObservationIgnored private var centralManager: CBCentralManager + @ObservationIgnored private var centralDelegate: Delegate? // swiftlint:disable:this weak_delegate + @ObservationIgnored private var isScanningObserver: KVOStateObserver? + + /// Represents the current state of the Bluetooth Manager. + public private(set) var state: BluetoothState + /// Whether or not we are currently scanning for nearby devices. + public private(set) var isScanning = false + /// The list of discovered and connected bluetooth devices indexed by their identifier UUID. + /// The state is isolated to our `dispatchQueue`. + private(set) var discoveredPeripherals: OrderedDictionary = [:] + /// 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? + + @ObservationIgnored private var autoConnect = false + @ObservationIgnored private var autoConnectItem: DispatchWorkItem? + @ObservationIgnored private var staleTimer: DiscoveryStaleTimer? + + /// Checks and determines the device candidate for auto-connect. + /// + /// Checks if there is exactly one, disconnected peripheral that can be used for the auto-connect feature. + private var autoConnectDeviceCandidate: BluetoothPeripheral? { + guard discoveredPeripherals.count == 1, + let firstDevice = discoveredPeripherals.values.first, + firstDevice.state == .disconnected, + firstDevice.id != lastManuallyDisconnectedDevice else { + return nil + } + + return firstDevice + } + + /// The list of nearby bluetooth devices. + /// + /// This array contains all discovered bluetooth peripherals and those with which we are currently connected. + public var nearbyPeripherals: [BluetoothPeripheral] { + Array(discoveredPeripherals.values) + } + + /// The list of nearby bluetooth devices as a view. + /// + /// This is similar to the ``nearbyPeripherals``. However, it doesn't copy all elements into its own array + /// but exposes the `Values` type of the underlying Dictionary implementation. + public var nearbyPeripheralsView: OrderedDictionary.Values { + discoveredPeripherals.values + } + + /// 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 discoveryIds.isEmpty ? nil : discoveryIds + } + + private var isRunningWithinQueue: Bool { + DispatchQueue.getSpecific(key: isRunningBluetoothQueueKey) != nil + } + + + /// 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 + ) { + self.configuredDevices = devices + self.minimumRSSI = minimumRSSI + self.advertisementStaleInterval = max(1, advertisementStaleInterval) + + self.state = .unknown + + self.centralDelegate = Delegate(self) + + // This helps us later to identity that we are running within the bluetooth dispatch queue! + self.dispatchQueue.setSpecific(key: isRunningBluetoothQueueKey, value: IsRunningBluetoothQueue()) + + // The Bluetooth permission alert shows every time when a CBCentralManager is initialized. + // If we already have permissions the a power alert will be shown if the user has Bluetooth disabled. + // To have those alerts shown at the right time (and repeatedly), we lazily initialize the CBCentralManager and also deinit it + // once we don't use it anymore (we are not scanning and no device is currently connected). + // All this state handling happens here within the closures passed to the `Lazy` property wrapper. + _centralManager = Lazy { [weak self] in + let central = CBCentralManager( + delegate: self?.centralDelegate, + queue: self?.dispatchQueue, + options: [CBCentralManagerOptionShowPowerAlertKey: true] + ) + + if let self = self { + self.isScanningObserver = KVOStateObserver(receiver: self, entity: central, property: \.isScanning) + } + + self?.logger.debug("Initialized CBCentralManager.") + return central + } onCleanup: { [weak self] in + self?.logger.debug("Destroyed CBCentralManager.") + self?.isScanningObserver = nil + } + } + + /// Scan for nearby bluetooth devices. + /// + /// Scans on nearby devices based on the ``DeviceDescription`` provided in the initializer. + /// All discovered devices can be accessed through the ``nearbyPeripherals`` or ``nearbyPeripheralsView`` property. + /// + /// - Tip: Scanning for nearby devices can easily be managed via the ``SwiftUI/View/scanNearbyDevices(enabled:with:autoConnect:)`` + /// modifier. + /// + /// - Parameter 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) async { + await withCheckedContinuation { continuation in + dispatchQueue.async { + self._scanNearbyDevices(autoConnect: autoConnect) + continuation.resume() + } + } + } + + /// Stop scanning for nearby bluetooth devices. + public func stopScanning() async { + await withCheckedContinuation { continuation in + dispatchQueue.async { + self._stopScanning() + continuation.resume() + } + } + } + + private func _scanNearbyDevices(autoConnect: Bool) { + assert(isRunningWithinQueue, "\(#function) was run outside the bluetooth queue. This introduces data races.") + + guard !isScanning else { + return + } + + logger.debug("Starting scanning for nearby devices ...") + + shouldBeScanning = true + self.autoConnect = autoConnect + + if case .poweredOn = centralManager.state { + centralManager.scanForPeripherals( + withServices: serviceDiscoveryIds, + options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] + ) + isScanning = centralManager.isScanning // ensure this is propagated instantly + } + } + + /// Reactive scan upon powered on. + private func handlePoweredOn() { + if shouldBeScanning && !isScanning { + _scanNearbyDevices(autoConnect: autoConnect) + } + } + + private func _stopScanning(deinit isDeinit: Bool = false) { + assert(isDeinit || isRunningWithinQueue, "\(#function) was run outside the bluetooth queue. This introduces data races.") + + if isScanning { // transitively checks for state == .poweredOn + centralManager.stopScan() + isScanning = centralManager.isScanning // ensure this is synced + logger.debug("Scanning stopped") + } + + if shouldBeScanning { + shouldBeScanning = false + checkForCentralDeinit() + } + } + + private func handleStoppedScanning() { + assert(isRunningWithinQueue, "\(#function) was run outside the bluetooth queue. This introduces data races.") + + self.autoConnect = false + + let devices = nearbyPeripheralsView.filter { device in + device.state == .disconnected + } + + for device in devices { + clearDiscoveredPeripheral(forKey: device.id) + } + + if devices.isEmpty { // otherwise deinit was already called + checkForCentralDeinit() + } + } + + private func clearDiscoveredPeripheral(forKey id: UUID) { + assert(isRunningWithinQueue, "\(#function) was run outside the bluetooth queue. This introduces data races.") + + discoveredPeripherals.removeValue(forKey: id) + + checkForCentralDeinit() + } + + /// De-initializes the Bluetooth Central if we currently don't use it. + private func checkForCentralDeinit() { + assert(isRunningWithinQueue, "\(#function) was run outside the bluetooth queue. This introduces data races.") + + if !shouldBeScanning && discoveredPeripherals.isEmpty { + _centralManager.destroy() + self.state = .unknown + self.lastManuallyDisconnectedDevice = nil + } + } + + func connect(peripheral: BluetoothPeripheral) async { + logger.debug("Trying to connect to \(peripheral.cbPeripheral.debugIdentifier) ...") + + await withCheckedContinuation { continuation in + dispatchQueue.async { [weak self] in + guard let self = self else { + return + } + + let cancelled = self.cancelStaleTask(for: peripheral) + + self.centralManager.connect(peripheral.cbPeripheral, options: nil) + + if cancelled { + self.scheduleStaleTaskForOldestActivityDevice(ignore: peripheral) + } + + continuation.resume() + } + } + } + + func disconnect(peripheral: BluetoothPeripheral) { + logger.debug("Disconnecting peripheral \(peripheral.cbPeripheral.debugIdentifier) ...") + // stale timer is handled in the delegate method + centralManager.cancelPeripheralConnection(peripheral.cbPeripheral) + lastManuallyDisconnectedDevice = peripheral.id + } + + func findDeviceDescription(for advertisementData: AdvertisementData) -> DeviceDescription? { + configuredDevices.find(for: advertisementData, logger: logger) + } + + // MARK: - Auto Connect + + private func kickOffAutoConnect() { + assert(isRunningWithinQueue, "\(#function) was run outside the bluetooth queue. This introduces data races.") + + guard autoConnect else { + return // auto connect is disabled + } + + guard autoConnectItem == nil && autoConnectDeviceCandidate != nil else { + return + } + + let item = DispatchWorkItem { [weak self] in + guard let self = self else { + return + } + + self.autoConnectItem = nil + + guard let candidate = self.autoConnectDeviceCandidate else { + return + } + + Task { + await candidate.connect() + } + } + + autoConnectItem = item + dispatchQueue.asyncAfter(deadline: .now() + .seconds(Defaults.defaultAutoConnectDebounce), execute: item) + } + + // MARK: - Stale Advertisement Timeout + + /// 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) { + assert(isRunningWithinQueue, "\(#function) was run outside the bluetooth queue. This introduces data races.") + + let timer = DiscoveryStaleTimer(device: device.id) { [weak self] in + self?.handleStaleTask() + } + + self.staleTimer = timer + timer.schedule(for: timeout, in: dispatchQueue) + } + + private func scheduleStaleTaskForOldestActivityDevice(ignore device: BluetoothPeripheral? = nil) { + if let oldestActivityDevice = oldestActivityDevice(ignore: device) { + let intervalSinceLastActivity = Date.now.timeIntervalSince(oldestActivityDevice.lastActivity) + let nextTimeout = max(0, advertisementStaleInterval - intervalSinceLastActivity) + + scheduleStaleTask(for: oldestActivityDevice, withTimeout: nextTimeout) + } + } + + private func cancelStaleTask(for device: BluetoothPeripheral) -> Bool { + assert(isRunningWithinQueue, "\(#function) was run outside the bluetooth queue. This introduces data races.") + + 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? { + assert(isRunningWithinQueue, "\(#function) was run outside the bluetooth queue. This introduces data races.") + + // when we are just interested in the min device, this operation is a bit cheaper then sorting the whole list + return nearbyPeripheralsView + .filter { $0.state == .disconnected && $0.id != device?.id } + .min { lhs, rhs in + lhs.lastActivity < rhs.lastActivity + } + } + + private func handleStaleTask() { + assert(isRunningWithinQueue, "\(#function) was run outside the bluetooth queue. This introduces data races.") + staleTimer = nil // reset the timer + + let staleDevices = nearbyPeripheralsView.filter { device in + device.isConsideredStale(interval: advertisementStaleInterval) + } + + 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) + } + + + // schedule the next timeout for devices in the list + scheduleStaleTaskForOldestActivityDevice() + } + + + deinit { + _stopScanning(deinit: true) + staleTimer?.cancel() + autoConnectItem?.cancel() + + self.state = .poweredOff + + discoveredPeripherals = [:] + self.centralDelegate = nil + + logger.debug("BluetoothManager destroyed") + } +} + + +extension BluetoothManager: KVOReceiver { + func observeChange(of keyPath: KeyPath, value: V) async { + switch keyPath { + case \CBCentralManager.isScanning: + dispatchQueue.async { + self.isScanning = value as! Bool // swiftlint:disable:this force_cast + if !self.isScanning { + self.handleStoppedScanning() + } + } + default: + break + } + } +} + + +extension BluetoothManager: BluetoothScanner { + @_documentation(visibility: internal) + public var hasConnectedDevices: Bool { + !discoveredPeripherals.isEmpty + } +} + + +// MARK: Defaults +extension BluetoothManager { + /// Set of default values used within the Bluetooth Manager + public enum Defaults { + /// The default timeout after which stale advertisements are removed. + public static let defaultStaleTimeout: TimeInterval = 10 + /// The minimum rssi of a peripheral to consider it for discovery. + public 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 = 2 + } +} + + +// MARK: Delegate +extension BluetoothManager { + private class Delegate: NSObject, CBCentralManagerDelegate { + private let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "BluetoothManagerDelegate") + + private weak var manager: BluetoothManager? + + + init(_ manager: BluetoothManager) { + self.manager = manager + super.init() + } + + + func centralManagerDidUpdateState(_ central: CBCentralManager) { // swiftlint:disable:this cyclomatic_complexity + guard let manager else { + return + } + + switch central.state { + case .poweredOn: + // Start working with the peripheral + logger.info("CBManager is powered on") + manager.state = .poweredOn + manager.handlePoweredOn() + case .poweredOff: + logger.info("CBManager is not powered on") + manager.state = .poweredOff + case .resetting: + logger.info("CBManager is resetting") + manager.state = .poweredOff + case .unauthorized: + switch CBManager.authorization { + case .denied: + logger.log("You are not authorized to use Bluetooth") + case .restricted: + logger.log("Bluetooth is restricted") + default: + logger.log("Unexpected authorization") + } + manager.state = .unauthorized + case .unknown: + logger.log("CBManager state is unknown") + manager.state = .unknown + case .unsupported: + logger.log("Bluetooth is not supported on this device") + manager.state = .unsupported + @unknown default: + logger.log("A previously unknown central manager state occurred") + manager.state = .unsupported + } + } + + func centralManager( + _ central: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], + // We have to use NSNumber to conform to the `CBCentralManagerDelegate` delegate methods. + // swiftlint:disable:next legacy_objc_type + rssi: NSNumber + ) { + guard let manager, 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 + guard let device = manager.discoveredPeripherals[peripheral.identifier], + device.state == .disconnected else { + return + } + + // device is now out of range, just clear it immediately. + manager.clearDiscoveredPeripheral(forKey: device.id) + return // logging this would just be to verbose, so we don't. + } + + let data = AdvertisementData(advertisementData: advertisementData) + + + // check if we already seen this device! + if let device = manager.discoveredPeripherals[peripheral.identifier] { + 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() + } + + manager.kickOffAutoConnect() + return + } + + logger.debug("Discovered peripheral \(peripheral.debugIdentifier) at \(rssi.intValue) dB (data: \(advertisementData))") + + let device = BluetoothPeripheral(manager: manager, peripheral: peripheral, advertisementData: data, rssi: rssi.intValue) + manager.discoveredPeripherals[peripheral.identifier] = device // save local-copy, such CB doesn't deallocate it + + + 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) + } + + manager.kickOffAutoConnect() + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + guard let manager else { + return + } + + guard let device = manager.discoveredPeripherals[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.") + Task { + await device.handleConnect() + } + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + guard let manager else { + return + } + + // Documentation reads: "Because connection attempts don’t time out, a failed connection usually indicates a transient issue, + // in which case you may attempt connecting to the peripheral again." + + guard let device = manager.discoveredPeripherals[peripheral.identifier] else { + logger.warning("Unknown peripheral \(peripheral.debugIdentifier) failed with error: \(String(describing: error))") + manager.centralManager.cancelPeripheralConnection(peripheral) + return + } + + if let error { + logger.error("Failed to connect to \(peripheral): \(error)") + } else { + logger.error("Failed to connect to \(peripheral)") + } + + // just to make sure + manager.centralManager.cancelPeripheralConnection(device.cbPeripheral) + + discardDevice(device: device) + } + + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + guard let manager else { + return + } + + guard let device = manager.discoveredPeripherals[peripheral.identifier] else { + logger.error("Received didDisconnect for unknown peripheral \(peripheral.debugIdentifier).") + return + } + + if let error { + logger.debug("Peripheral \(peripheral.debugIdentifier) disconnected due to an error: \(error)") + } else { + logger.debug("Peripheral \(peripheral.debugIdentifier) disconnected.") + } + + discardDevice(device: device) + } + + + private func discardDevice(device: BluetoothPeripheral) { + guard let manager else { + return + } + + assert(manager.isRunningWithinQueue, "\(#function) was run outside the bluetooth queue. This introduces data races.") + + if !manager.isScanning { + device.handleDisconnect() + manager.clearDiscoveredPeripheral(forKey: device.id) + } else { + // we will keep discarded devices for 500ms before the stale timer kicks off + let interval = max(0, manager.advertisementStaleInterval - 0.5) + device.handleDisconnect(disconnectActivityInterval: interval) + + // We just schedule the new timer if there is a device to schedule one for. + manager.scheduleStaleTaskForOldestActivityDevice() + } + } + } +} // swiftlint:disable:this file_length diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift new file mode 100644 index 00000000..fe9d864a --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift @@ -0,0 +1,746 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth +import Foundation +import OSLog + + +/// A nearby Bluetooth peripheral. +/// +/// This class represents a nearby Bluetooth peripheral. +/// You may connect to the peripheral and read or write its characteristic data. +/// +/// ## Topics +/// +/// ### Peripheral State +/// - ``id`` +/// - ``name`` +/// - ``state`` +/// - ``rssi`` +/// - ``advertisementData`` +/// - ``services`` +/// +/// ### Managing Connection +/// - ``connect()`` +/// - ``disconnect()`` +/// +/// ### Reading a value +/// - ``read(characteristic:)`` +/// +/// ### Writing a value +/// - ``write(data:for:)`` +/// - ``writeWithoutResponse(data:for:)`` +/// +/// ### Notifications +/// - ``registerNotifications(service:characteristic:_:)`` +/// - ``registerNotifications(for:_:)`` +/// - ``CharacteristicNotification`` +/// - ``BluetoothNotificationHandler`` +/// +/// ### Retrieving the latest signal strength +/// - ``readRSSI()`` +public actor BluetoothPeripheral { + private let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "BluetoothDevice") + + private weak var manager: BluetoothManager? + private let peripheral: CBPeripheral + + private let delegate: Delegate + private let stateObserver: KVOStateObserver + + /// Ongoing accessed indexed by characteristic uuid. + private var ongoingAccesses: [CBCharacteristic: CharacteristicAccessContinuation] = [:] + /// Continuation for the current write without response access. + private var writeWithoutResponseAccess: [CheckedContinuation] = [] + /// Continuation for a currently ongoing rssi read access. + private var rssiReadAccess: [CheckedContinuation] = [] + + private var notificationHandlers: [CharacteristicLocator: [UUID: BluetoothNotificationHandler]] = [:] + + /// Observable state container for local state. + private let stateContainer: PeripheralStateContainer + + nonisolated var cbPeripheral: CBPeripheral { + peripheral + } + + /// The name of the peripheral. + public nonisolated var name: String? { + stateContainer.name + } + + /// The current signal strength. + public nonisolated var rssi: Int { + stateContainer.rssi + } + + /// The advertisement data of the last bluetooth advertisement. + public nonisolated var advertisementData: AdvertisementData { + stateContainer.advertisementData + } + + /// The current peripheral device state. + public nonisolated var state: PeripheralState { + stateContainer.state + } + + /// The list of discovered services. + /// + /// Services are discovered automatically upon connection + public nonisolated var services: [CBService]? { // swiftlint:disable:this discouraged_optional_collection + stateContainer.services + } + + nonisolated var lastActivity: Date { + if case .disconnected = state { + stateContainer.lastActivity + } else { + // we are currently connected or connecting/disconnecting, therefore last activity is defined as "now" + .now + } + } + + + init(manager: BluetoothManager, peripheral: CBPeripheral, advertisementData: AdvertisementData, rssi: Int) { + self.manager = manager + self.peripheral = peripheral + + self.stateContainer = PeripheralStateContainer( + name: peripheral.name, + rssi: rssi, + advertisementData: advertisementData, + state: peripheral.state + ) + + let delegate = Delegate() + let observer = KVOStateObserver(entity: peripheral, property: \.state) + + self.delegate = delegate + self.stateObserver = observer + + // we have this separate initDevice methods as otherwise above access to `delegate` and `stateObserver` properties + // would become non-isolated accesses (due to usage of self beforehand). + delegate.initDevice(self) + observer.initReceiver(self) + + peripheral.delegate = delegate + } + + /// Establish a connection to the peripheral. + /// + /// Make a connection to the peripheral. + /// + /// - Note: This method returns as soon as the request to connect was processed locally. It does + /// not wait till the connection was completed successfully. + /// + /// - Note: You might want to verify via the ``AdvertisementData/isConnectable`` property that the device is connectable. + public func connect() async { + guard let manager else { + logger.warning("Tried to connect an orphaned bluetooth peripheral!") + return + } + + await manager.connect(peripheral: self) + } + + /// Disconnect the ongoing connection to the peripheral. + /// + /// Cancels an active or pending connection to a peripheral. + public func disconnect() { + guard let manager else { + logger.warning("Tried to disconnect an orphaned bluetooth peripheral!") + return + } + + removeAllNotifications() + + manager.disconnect(peripheral: self) + } + + func handleConnect() { + guard let manager else { + logger.warning("Tried handling connection attempt for an orphaned bluetooth peripheral!") + return + } + + if let description = manager.findDeviceDescription(for: advertisementData), + let services = description.services { + stateContainer.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 + stateContainer.requestedCharacteristics = nil + } + + self.stateContainer.state = .init(from: peripheral.state) // ensure that it is updated instantly. + + logger.debug("Discovering services for \(self.peripheral.debugIdentifier) ...") + peripheral.discoverServices(stateContainer.requestedCharacteristics.map { Array($0.keys) }) + } + + /// Handles a disconnect or failed connection attempt. + nonisolated func handleDisconnect(disconnectActivityInterval: TimeInterval = 0) { + self.stateContainer.state = .init(from: peripheral.state) // ensure that it is updated instantly. + self.stateContainer.lastActivity = Date.now - disconnectActivityInterval + + Task { + await clearAccesses() + } + } + + func clearAccesses() { + for continuation in writeWithoutResponseAccess { + continuation.resume() + } + writeWithoutResponseAccess.removeAll() + + for continuation in rssiReadAccess { + continuation.resume(throwing: BluetoothError.notPresent) + } + rssiReadAccess.removeAll() + + let ongoingAccesses = ongoingAccesses + self.ongoingAccesses.removeAll() + + for (_, access) in ongoingAccesses { + switch access { + case let .read(continuations, queued): + for continuation in continuations { + continuation.resume(throwing: BluetoothError.notPresent) + } + for queue in queued { + queue.resume() + } + case let .write(continuation, queued): + continuation.resume(throwing: BluetoothError.notPresent) + for queue in queued { + queue.resume() + } + } + } + } + + nonisolated func update(advertisement: AdvertisementData, rssi: Int) { + self.stateContainer.lastActivity = .now // fine to be non-isolated. We always just write the latest data + + // this could be a problem to be non-isolated, however, we know this will always come from the Bluetooth queue that is serial. + stateContainer.advertisementData = advertisement + stateContainer.rssi = rssi + } + + /// Determines if the device is considered stale. + /// + /// This is the case if the device is not connected and the last activity is longer in the past than + /// the provided interval. + /// - Parameter interval: The time interval after which the device is considered stale. + /// - Returns: True if the device is considered stale given the above criteria. + nonisolated func isConsideredStale(interval: TimeInterval) -> Bool { + state == .disconnected && lastActivity.addingTimeInterval(interval) < .now + } + + /// Register a notification handler for a characteristic. + /// + /// This method registers a notification handler for the provided characteristic. + /// + /// - Note: Make sure that you don't create a retain cycle if the provided closure captures `self`. + /// + /// - Parameters: + /// - characteristic: The characteristic to register notifications for. + /// - handler: The notification handler. + /// - @Returns: Returns the ``CharacteristicNotification`` that can be used to cancel and deregister the notification handler. + public func registerNotifications( + for characteristic: CBCharacteristic, + _ handler: @escaping BluetoothNotificationHandler + ) throws -> CharacteristicNotification { + guard let service = characteristic.service else { + throw BluetoothError.notPresent + } + + return registerNotifications(service: service.uuid, characteristic: characteristic.uuid, handler) + } + + /// Register a notification handler for a characteristic. + /// + /// This method registers a notification handler for the provide service and characteristic id. + /// + /// - Tip: It is not required that the device is connected. Notifications will be automatically enabled for the + /// respective characteristic upon device discovery. + /// + /// - Note: Make sure that you don't create a retain cycle if the provided closure captures `self`. + /// + /// - Parameters: + /// - service: The service uuid. + /// - characteristic: The characteristic uuid. + /// - handler: The notification handler. + /// - @Returns: Returns the ``CharacteristicNotification`` that can be used to cancel and deregister the notification handler. + public func registerNotifications( + service: CBUUID, + characteristic: CBUUID, + _ handler: @escaping BluetoothNotificationHandler + ) -> CharacteristicNotification { + let locator = CharacteristicLocator(serviceId: service, characteristicId: characteristic) + let id = UUID() // notification handler id, used internally + + notificationHandlers[locator, default: [:]] + .updateValue(handler, forKey: id) + + + // if setting notify doesn't work here, we do it upon discovery of the characteristics + trySettingNotifyValue(true, serviceId: service, characteristicId: characteristic) + + return CharacteristicNotification(peripheral: self, locator: locator, handlerId: id) + } + + func deregisterNotification(_ notification: CharacteristicNotification) { + deregisterNotification(locator: notification.locator, handlerId: notification.handlerId) + } + + func deregisterNotification(locator: CharacteristicLocator, handlerId: UUID) { + notificationHandlers[locator]?.removeValue(forKey: handlerId) + + trySettingNotifyValue(false, serviceId: locator.serviceId, characteristicId: locator.characteristicId) + } + + private func trySettingNotifyValue(_ notify: Bool, serviceId: CBUUID, characteristicId: CBUUID) { + if let service = services?.first(where: { $0.uuid == serviceId }), + let characteristic = service.characteristics?.first(where: { $0.uuid == characteristicId }), + characteristic.properties.contains(.notify) { + peripheral.setNotifyValue(notify, for: characteristic) + } + } + + /// Call this when things either go wrong, or you're done with the connection. + /// This cancels any subscriptions if there are any, or straight disconnects if not. + /// (didUpdateNotificationStateForCharacteristic will cancel the connection if a subscription is involved) + private func removeAllNotifications() { + guard case .connected = peripheral.state else { + return + } + + // we need to unsubscribe before we cancel the connection + for service in peripheral.services ?? [] { + for characteristic in service.characteristics ?? [] where characteristic.isNotifying { + peripheral.setNotifyValue(false, for: characteristic) + } + } + } + + /// Write the value of a characteristic expecting a confirmation. + /// + /// Writes the value of a characteristic expecting a confirmation from the peripheral. + /// + /// - Note: The write operation is specified in Bluetooth Core Specification, Volume 3, + /// Part G, 4.9.3 Write Characteristic Value. + /// + /// - Parameters: + /// - data: The value to write. + /// - characteristic: The characteristic to which the value is written. + /// - Returns: The response from the device. + /// - Throws: Throws an `CBError` or `CBATTError` if the write fails. + public func write(data: Data, for characteristic: CBCharacteristic) async throws { + while ongoingAccesses[characteristic] != nil { + await queueRWAccess(for: characteristic) + } + + try await withCheckedThrowingContinuation { continuation in + // using updateValue as of https://github.com/apple/swift/issues/63156. Revert to subscript access with Swift 5.10 + ongoingAccesses.updateValue(.write(continuation), forKey: characteristic) + peripheral.writeValue(data, for: characteristic, type: .withResponse) + } + } + + /// Write the value of a characteristic without expecting a confirmation. + /// + /// Writes the value of a characteristic without expecting a confirmation from the peripheral. + /// + /// - Note: The write operation is specified in Bluetooth Core Specification, Volume 3, + /// Part G, 4.9.1 Write Without Response. + /// + /// - Parameters: + /// - data: The value to write. + /// - characteristic: The characteristic to which the value is written. + public func writeWithoutResponse(data: Data, for characteristic: CBCharacteristic) async { + guard writeWithoutResponseAccess.isEmpty else { + await withCheckedContinuation { continuation in + writeWithoutResponseAccess.append(continuation) + } + return + } + + await withCheckedContinuation { continuation in + writeWithoutResponseAccess.append(continuation) + peripheral.writeValue(data, for: characteristic, type: .withoutResponse) + } + } + + /// Read the value of a characteristic. + /// + /// Read the value for the specified characteristic. + /// + /// - Parameter characteristic: The characteristic for which you want to read the value. + /// - Returns: The value that the peripheral was returned. + /// - Throws: Throws an `CBError` or `CBATTError` if the read fails. + public func read(characteristic: CBCharacteristic) async throws -> Data { + // if there is already a read for this characteristic, we just piggy back onto it + if case .read(var continuations, let queued) = ongoingAccesses[characteristic] { + return try await withCheckedThrowingContinuation { continuation in + continuations.append(continuation) + // using updateValue as of https://github.com/apple/swift/issues/63156. Revert to subscript access with Swift 5.10 + ongoingAccesses.updateValue(.read(continuations, queued: queued), forKey: characteristic) + } + } + + while ongoingAccesses[characteristic] != nil { + // otherwise there is a write and we wait for its completion before we read again + await queueRWAccess(for: characteristic) + } + + return try await withCheckedThrowingContinuation { continuation in + // using updateValue as of https://github.com/apple/swift/issues/63156. Revert to subscript access with Swift 5.10 + ongoingAccesses.updateValue(.read([continuation]), forKey: characteristic) + peripheral.readValue(for: characteristic) + } + } + + /// Retrieve the current RSSI value. + /// + /// Retrieves the current RSSI value for the peripheral while its connected. + /// - Returns: The read rssi value. + /// - Throws: Throws an `CBError` or `CBATTError` if the read fails. + public func readRSSI() async throws -> Int { + guard rssiReadAccess.isEmpty else { + return try await withCheckedThrowingContinuation { continuation in + rssiReadAccess.append(continuation) + } + } + + return try await withCheckedThrowingContinuation { continuation in + rssiReadAccess.append(continuation) + peripheral.readRSSI() + } + } + + private func queueRWAccess(for characteristic: CBCharacteristic) async { + guard let access = ongoingAccesses[characteristic] else { + return + } + + switch access { + case .read(let readContinuation, var queued): + await withCheckedContinuation { continuation in + queued.append(continuation) + // using updateValue as of https://github.com/apple/swift/issues/63156. Revert to subscript access with Swift 5.10 + ongoingAccesses.updateValue(.read(readContinuation, queued: queued), forKey: characteristic) + } + case .write(let writeContinuation, var queued): + await withCheckedContinuation { continuation in + queued.append(continuation) + // using updateValue as of https://github.com/apple/swift/issues/63156. Revert to subscript access with Swift 5.10 + ongoingAccesses.updateValue(.write(writeContinuation, queued: queued), forKey: characteristic) + } + } + } +} + + +extension BluetoothPeripheral: Identifiable { + /// The internally managed identifier for the peripheral. + public nonisolated var id: UUID { + peripheral.identifier + } +} + +extension BluetoothPeripheral: KVOReceiver { + func observeChange(of keyPath: KeyPath, value: V) async { + switch keyPath { + case \CBPeripheral.state: + // force cast is okay as we implicitly verify the type using the KeyPath in the case statement. + self.stateContainer.state = .init(from: value as! CBPeripheralState) // swiftlint:disable:this force_cast + default: + break + } + } +} + + +// MARK: Delegate Accessors +extension BluetoothPeripheral { + fileprivate func update(name: String?) { + self.stateContainer.name = name + } + + fileprivate func update(rssi: Int, error: Error?) { + stateContainer.rssi = rssi + + let result: Result + if let error { + result = .failure(error) + } else { + result = .success(rssi) + } + + for continuation in rssiReadAccess { + continuation.resume(with: result) + } + + self.rssiReadAccess.removeAll() + } + + fileprivate func discovered(characteristics: [CBCharacteristic], for service: CBService) { + // automatically subscribe to discovered characteristics for which we have a handler subscribed! + for characteristic in characteristics { + guard characteristic.properties.contains(.notify) else { + continue + } + + let locator = CharacteristicLocator(serviceId: service.uuid, characteristicId: characteristic.uuid) + + if notificationHandlers[locator] != nil { + peripheral.setNotifyValue(true, for: characteristic) + } + } + + // check if we discover descriptors + guard let requestedCharacteristics = stateContainer.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 { + peripheral.discoverDescriptors(for: characteristic) + } + } + } + + fileprivate func receivedReadyNotification() { + for continuation in writeWithoutResponseAccess { + continuation.resume() + } + writeWithoutResponseAccess.removeAll() + } + + fileprivate func receivedUpdatedValue(for characteristic: CBCharacteristic, result: Result) async { + if case let .read(continuations, queued) = ongoingAccesses[characteristic] { + ongoingAccesses[characteristic] = nil + + if case let .failure(error) = result { + logger.debug("Characteristic read for \(characteristic.debugIdentifier) returned with error: \(error)") + } + + for continuation in continuations { + continuation.resume(with: result) + } + + for queue in queued { + queue.resume() + } + } + + switch result { + case let .success(data): + guard let service = characteristic.service else { + break + } + + let locator = CharacteristicLocator(serviceId: service.uuid, characteristicId: characteristic.uuid) + + for handler in notificationHandlers[locator, default: [:]].values { + await handler(data) + } + case let .failure(error): + logger.debug("Received unsolicited value update error for \(characteristic.debugIdentifier): \(error)") + } + } + + fileprivate func receivedWriteResponse(for characteristic: CBCharacteristic, result: Result) { + guard case let .write(continuation, queued) = ongoingAccesses[characteristic] else { + logger.warning("Received write response for \(characteristic.debugIdentifier) without an ongoing access. Discarding write ...") + return + } + + ongoingAccesses[characteristic] = nil + + if case let .failure(error) = result { + logger.debug("Characteristic write for \(characteristic.debugIdentifier) returned with error: \(error)") + } + + continuation.resume(with: result) + + for queue in queued { + queue.resume() + } + } +} + + +// MARK: Hashable +extension BluetoothPeripheral: Hashable { + public static func == (lhs: BluetoothPeripheral, rhs: BluetoothPeripheral) -> Bool { + lhs.peripheral == rhs.peripheral + } + + + public nonisolated func hash(into hasher: inout Hasher) { + hasher.combine(peripheral) + } +} + + +// MARK: Delegate +extension BluetoothPeripheral { + private class Delegate: NSObject, CBPeripheralDelegate { + private let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "BluetoothDeviceDelegate") + + private weak var device: BluetoothPeripheral! // swiftlint:disable:this implicitly_unwrapped_optional + + override init() { + super.init() + } + + + func initDevice(_ device: BluetoothPeripheral) { + self.device = device + } + + func peripheralDidUpdateName(_ peripheral: CBPeripheral) { + Task { + await device.update(name: peripheral.name) + } + } + + func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) { + Task { + await device.update(rssi: RSSI.intValue, error: error) + } + } + + func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { + // this is called if ... + // 1) The peripheral removes a service from its database. + // 2) The peripheral adds a new service to its database. + // 3) The peripheral adds back a previously-removed service, but at a different location in the database. + + // so a service we requested might be gone now. Or might just have changed location. So, discover them to check if they moved location? + + let serviceIds = invalidatedServices.map { $0.uuid } + logger.debug("Services modified, invalidating \(serviceIds)") + + // update our local model + device.stateContainer.services?.removeAll(where: { invalidatedServices.contains($0) }) + + peripheral.discoverServices(serviceIds) + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + if let error { + logger.error("Error discovering services: \(error.localizedDescription)") + return + } + + guard let services = peripheral.services else { + return + } + + // update our local model for observability + device.stateContainer.services = services + + logger.debug("Discovered \(services) services for peripheral \(peripheral.debugIdentifier)") + + for service in services { + guard let requestedCharacteristicsDescriptions = device.stateContainer.requestedCharacteristics?[service.uuid] else { + continue + } + + let requestedCharacteristics = requestedCharacteristicsDescriptions?.map { $0.characteristicId } + + // see peripheral(_:didDiscoverCharacteristicsFor:error:) + peripheral.discoverCharacteristics(requestedCharacteristics, for: service) + } + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + if let error = error { + logger.error("Error discovering characteristics: \(error.localizedDescription)") + return + } + + guard let characteristics = service.characteristics else { + return + } + + logger.debug("Discovered \(characteristics.count) characteristic(s) for service \(service.uuid)") + + Task { + await device.discovered(characteristics: characteristics, for: service) + } + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverDescriptorsFor characteristic: CBCharacteristic, error: Error?) { + guard let descriptors = characteristic.descriptors else { + return + } + + logger.debug("Discovered descriptors for characteristic \(characteristic.debugIdentifier): \(descriptors)") + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + Task { + if let error { + await device.receivedUpdatedValue(for: characteristic, result: .failure(error)) + } else if let value = characteristic.value { + await device.receivedUpdatedValue(for: characteristic, result: .success(value)) + } + } + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + Task { + if let error { + await device.receivedWriteResponse(for: characteristic, result: .failure(error)) + } else { + await device.receivedWriteResponse(for: characteristic, result: .success(())) + } + } + } + + func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { + Task { + await device.receivedReadyNotification() + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + if let error = error { + logger.error("Error changing notification state: \(error.localizedDescription)") + return + } + + + if characteristic.isNotifying { + logger.log("Notification began on \(characteristic.uuid.uuidString)") + + if characteristic.properties.contains(.read) { // read the initial value + peripheral.readValue(for: characteristic) + } + } else { + logger.log("Notification stopped on \(characteristic.uuid.uuidString).") + } + } + } +} // swiftlint:disable:this file_length diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift new file mode 100644 index 00000000..72b246ff --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift @@ -0,0 +1,46 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth + + +/// A characteristic description. +public struct CharacteristicDescription { + /// The characteristic id. + public let characteristicId: CBUUID + /// Flag indicating if descriptors should be discovered for this characteristic. + public let discoverDescriptors: 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) { + self.characteristicId = id + self.discoverDescriptors = discoverDescriptors + } +} + + +extension CharacteristicDescription: ExpressibleByStringLiteral { + public init(stringLiteral value: StringLiteralType) { + self.init(id: CBUUID(string: value)) + } +} + + +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) + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift new file mode 100644 index 00000000..40bb3f33 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift @@ -0,0 +1,71 @@ +// +// 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 OSLog + + +/// The description for a certain type of device. +/// +/// 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 { + /// The criteria by which we identify a discovered device. + public let discoveryCriteria: DiscoveryCriteria + /// 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 + + + /// 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?) { + // swiftlint:disable:previous discouraged_optional_collection + self.discoveryCriteria = discoveryCriteria + self.services = services + } +} + + +extension DeviceDescription: Identifiable { + public var id: DiscoveryCriteria { + discoveryCriteria + } +} + + +extension DeviceDescription: Hashable { + public static func == (lhs: DeviceDescription, rhs: DeviceDescription) -> Bool { + lhs.discoveryCriteria == rhs.discoveryCriteria + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(discoveryCriteria) + } +} + + +extension Collection where Element: Identifiable, Element.ID == DiscoveryCriteria { + func find(for advertisementData: AdvertisementData, logger: Logger) -> Element? { + let configurations = filter { configuration in + configuration.id.matches(advertisementData) + } + + if configurations.count > 1 { + let criteria = configurations + .map { $0.id.description } + .joined(separator: ", ") + logger.warning("Found ambiguous discovery configuration for peripheral. Peripheral matched all these criteria: \(criteria)") + } + + return configurations.first + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift new file mode 100644 index 00000000..fc0fe52b --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift @@ -0,0 +1,55 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth + + +/// The criteria by which we identify a discovered device. +/// +/// ## Topics +/// +/// ### Criteria +/// - ``advertisedService(_:)-swift.enum.case`` +/// - ``advertisedService(_:)-swift.type.method`` +public enum DiscoveryCriteria { + /// Identify a device by their advertised service. + case advertisedService(_ uuid: CBUUID) + + + var discoveryId: CBUUID { + switch self { + case let .advertisedService(uuid): + uuid + } + } + + + /// Identify a device by their advertised service. + /// - Parameter uuid: The Bluetooth ServiceId in string format. + /// - Returns: A ``DiscoveryCriteria/advertisedService(_:)-swift.enum.case`` criteria. + public static func advertisedService(_ uuid: String) -> DiscoveryCriteria { + .advertisedService(CBUUID(string: uuid)) + } + + + func matches(_ advertisementData: AdvertisementData) -> Bool { + switch self { + case let .advertisedService(uuid): + return advertisementData.serviceUUIDs?.contains(uuid) ?? false + } + } +} + +extension DiscoveryCriteria: Hashable, CustomStringConvertible { + public var description: String { + switch self { + case let .advertisedService(uuid): + ".advertisedService(\(uuid))" + } + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift new file mode 100644 index 00000000..01536222 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift @@ -0,0 +1,54 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth + + +/// A service description for a certain device. +/// +/// Describes what characteristics we expect to be present for a certain service. +public struct ServiceDescription { + /// The service id. + public let serviceId: CBUUID + /// The description of characteristics present on the service. + /// + /// Those are the characteristics we try to discover. If empty, we discover all characteristics + /// on a given service. + public let characteristics: Set? // swiftlint:disable:this discouraged_optional_collection + + + /// Create a new service description. + /// - Parameters: + /// - serviceId: The bluetooth service id. + /// - characteristics: The description of characteristics we expect to be present on the service. + /// Use `nil` to discover all characteristics. + public init(serviceId: CBUUID, characteristics: Set?) { // swiftlint:disable:this discouraged_optional_collection + self.serviceId = serviceId + self.characteristics = characteristics + } + + /// Create a new service description. + /// - Parameters: + /// - serviceId: The bluetooth service id. + /// - characteristics: The description of characteristics we expect to be present on the service. + /// Use `nil` to discover all characteristics. + public init(serviceId: String, characteristics: Set?) { // swiftlint:disable:this discouraged_optional_collection + self.init(serviceId: CBUUID(string: serviceId), characteristics: characteristics) + } +} + + +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) + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBCharacteristic+DebugIdentifier.swift b/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBCharacteristic+DebugIdentifier.swift new file mode 100644 index 00000000..bcb10da2 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBCharacteristic+DebugIdentifier.swift @@ -0,0 +1,21 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth + + +// CustomDebugStringConvertible is already implemented for NSObjects. So we just define a custom property +extension CBCharacteristic { + var debugIdentifier: String { + if let service { + "\(uuid)@\(service)" + } else { + "\(uuid)" + } + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBError+LocalizedError.swift b/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBError+LocalizedError.swift new file mode 100644 index 00000000..364877f8 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBError+LocalizedError.swift @@ -0,0 +1,31 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth + + +extension CBError: LocalizedError { + public var errorDescription: String? { + "CoreBluetooth Error" + } + + public var failureReason: String? { + localizedDescription + } +} + + +extension CBATTError: LocalizedError { + public var errorDescription: String? { + "CoreBluetooth ATT Error" + } + + public var failureReason: String? { + localizedDescription + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBPeripheral+DebugIdentifier.swift b/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBPeripheral+DebugIdentifier.swift new file mode 100644 index 00000000..13bf81d2 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBPeripheral+DebugIdentifier.swift @@ -0,0 +1,28 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth + + +// CustomDebugStringConvertible is already implemented for NSObjects. So we just define a custom property +extension CBPeripheral { + var debugIdentifier: String { + if let name { + "'\(name)' @ \(identifier)" + } else { + "\(identifier)" + } + } +} + + +extension BluetoothPeripheral: CustomDebugStringConvertible { + public nonisolated var debugDescription: String { + cbPeripheral.debugIdentifier + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/AdvertisementData.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/AdvertisementData.swift new file mode 100644 index 00000000..8cac8a14 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/AdvertisementData.swift @@ -0,0 +1,69 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth + + +/// All advertised information of a peripheral. +public struct AdvertisementData { + /// The raw advertisement data dictionary provided by CoreBluetooth. + public let rawAdvertisementData: [String: Any] + + /// The local name of a peripheral. + public var localName: String? { + rawAdvertisementData[CBAdvertisementDataLocalNameKey] as? String + } + + /// The manufacturer data of a peripheral. + public var manufacturerData: Data? { + rawAdvertisementData[CBAdvertisementDataManufacturerDataKey] as? Data + } + + /// Service-specific advertisement data. + /// + /// The keys are CBService UUIDs. The values are Data objects, representing service-specific data. + public var serviceData: [CBUUID: Data]? { // swiftlint:disable:this discouraged_optional_collection + rawAdvertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data] + } + + /// The advertised service UUIDs. + public var serviceUUIDs: [CBUUID]? { // swiftlint:disable:this discouraged_optional_collection + rawAdvertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] + } + + /// An array of one or more CBUUID objects, representing CBService UUIDs that were found in the “overflow” + /// area of the advertisement data. + public var overflowServiceUUIDs: [CBUUID]? { // swiftlint:disable:this discouraged_optional_collection + rawAdvertisementData[CBAdvertisementDataOverflowServiceUUIDsKey] as? [CBUUID] + } + + /// The transmit power of a peripheral. + /// + /// This key and value are available if the broadcaster (peripheral) provides its Tx power level in its advertising packet. + /// Using the RSSI value and the Tx power level, it is possible to calculate path loss. + public var txPowerLevel: NSNumber? { + rawAdvertisementData[CBAdvertisementDataTxPowerLevelKey] as? NSNumber + } + + /// Determine if the advertising event type is connectable. + public var isConnectable: Bool? { // swiftlint:disable:this discouraged_optional_boolean + rawAdvertisementData[CBAdvertisementDataIsConnectable] as? Bool // bridge cast + } + + /// An array solicited CBService UUIDs. + public var solicitedServiceUUIDs: [CBUUID]? { // swiftlint:disable:this discouraged_optional_collection + rawAdvertisementData[CBAdvertisementDataSolicitedServiceUUIDsKey] as? [CBUUID] + } + + + /// Creates advertisement data based on CoreBluetooth's dictionary. + /// - Parameter advertisementData: Core Bluetooth's advertisement data + init(advertisementData: [String: Any]) { + self.rawAdvertisementData = advertisementData + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothError.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothError.swift new file mode 100644 index 00000000..bcda0778 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothError.swift @@ -0,0 +1,46 @@ +// +// 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 Foundation + + +/// Represents errors that can occur during Bluetooth operations. +public enum BluetoothError: String, Error, CustomStringConvertible, LocalizedError { + /// Could not decode the ByteBuffer into the provided ByteDecodable. + case incompatibleDataFormat + /// Thrown when accessing a ``Characteristic`` that was not present. + /// Either because the device wasn't connected or the characteristic is not present on the connected device. + case notPresent + + + /// Provides a human-readable description of the error. + public var description: String { + "\(errorDescription!): \(failureReason!)" // swiftlint:disable:this force_unwrapping + } + + + /// Provides a detailed description of the error. + public var errorDescription: String? { + switch self { + case .incompatibleDataFormat: + String(localized: "Decoding Error", bundle: .module) + case .notPresent: + String(localized: "Not Present", bundle: .module) + } + } + + + public var failureReason: String? { + switch self { + case .incompatibleDataFormat: + String(localized: "Could not decode byte representation into provided format.", bundle: .module) + case .notPresent: + String(localized: "The request characteristic was not present on the device.", bundle: .module) + } + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothState.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothState.swift new file mode 100644 index 00000000..ddbcac26 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothState.swift @@ -0,0 +1,42 @@ +// +// 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 +// + + +/// Represents the various states of Bluetooth. +public enum BluetoothState: UInt8 { + /// The Bluetooth state is unknown. + /// + /// The state will become known once you start scanning for nearby devices or use Bluetooth. + case unknown + /// Bluetooth module is powered off. + case poweredOff + /// Bluetooth is unsupported on this device (e.g., on simulator devices). + case unsupported + /// The application does not have permission to use Bluetooth features. + case unauthorized + /// Bluetooth is powered on and usable. + case poweredOn +} + + +extension BluetoothState: CustomStringConvertible, Sendable { + public var description: String { + switch self { + case .unknown: + "unknown" + case .poweredOff: + "poweredOff" + case .unsupported: + "unsupported" + case .unauthorized: + "unauthorized" + case .poweredOn: + "poweredOn" + } + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccessContinuation.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccessContinuation.swift new file mode 100644 index 00000000..d85142c6 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccessContinuation.swift @@ -0,0 +1,15 @@ +// +// 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 + + +enum CharacteristicAccessContinuation { + case read(_ continuation: [CheckedContinuation], queued: [CheckedContinuation] = []) + case write(_ continuation: CheckedContinuation, queued: [CheckedContinuation] = []) +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicLocator.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicLocator.swift new file mode 100644 index 00000000..2633d1df --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicLocator.swift @@ -0,0 +1,15 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth + + +struct CharacteristicLocator: Hashable { + let serviceId: CBUUID + let characteristicId: CBUUID +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicNotification.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicNotification.swift new file mode 100644 index 00000000..362cf8db --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicNotification.swift @@ -0,0 +1,47 @@ +// +// 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 + + +/// An active registration of a notification handler. +/// +/// This object represents an active registration of an notification handler. Primarily, this can be used to keep +/// track of a notification handler and cancel the registration at a later point. +/// +/// - Tip: The notification handler will be automatically unregistered when this object is deallocated. +public class CharacteristicNotification { + private weak var peripheral: BluetoothPeripheral? + let locator: CharacteristicLocator + let handlerId: UUID + + + init(peripheral: BluetoothPeripheral?, locator: CharacteristicLocator, handlerId: UUID) { + self.peripheral = peripheral + self.locator = locator + self.handlerId = handlerId + } + + + /// Cancel the notification handler registration. + public func cancel() async { + await peripheral?.deregisterNotification(self) + } + + + deinit { + // make sure we don't capture self after this deinit + let peripheral = peripheral + let locator = locator + let handlerId = handlerId + + Task { + await peripheral?.deregisterNotification(locator: locator, handlerId: handlerId) + } + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralState.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralState.swift new file mode 100644 index 00000000..c23fb1bf --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralState.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 CoreBluetooth + + +/// Describes the state of a Bluetooth peripheral. +public enum PeripheralState: UInt8 { + /// The peripheral is disconnected. + case disconnected + /// The peripheral is currently establishing a connection. + case connecting + /// The peripheral is connected. + case connected + /// The peripheral is currently disconnecting. + case disconnecting +} + + +extension PeripheralState: CustomStringConvertible, Sendable { + public var description: String { + switch self { + case .disconnected: + "disconnected" + case .connecting: + "connecting" + case .connected: + "connected" + case .disconnecting: + "disconnecting" + } + } +} + + +extension PeripheralState { + init(from state: CBPeripheralState) { + switch state { + case .disconnected: + self = .disconnected + case .connecting: + self = .connecting + case .connected: + self = .connected + case .disconnecting: + self = .disconnecting + @unknown default: + self = .disconnected + } + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStateContainer.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStateContainer.swift new file mode 100644 index 00000000..806ca4d7 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStateContainer.swift @@ -0,0 +1,37 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth +import Foundation + + +/// A dedicated state container for a ``BluetoothPeripheral``. +/// +/// Main motivation is to have `BluetoothPeripheral` be implemented as an actor and moving state +/// into a separate state container that is `@Observable`. +@Observable +final class PeripheralStateContainer { + var name: String? + var rssi: Int + var advertisementData: AdvertisementData + var state: PeripheralState + var lastActivity: Date + + var services: [CBService]? // swiftlint:disable:this discouraged_optional_collection + + /// The list of requested characteristic uuids indexed by service uuids. + var requestedCharacteristics: [CBUUID: Set?]? // swiftlint:disable:this discouraged_optional_collection + + init(name: String?, rssi: Int, advertisementData: AdvertisementData, state: CBPeripheralState, lastActivity: Date = .now) { + self.name = name + self.advertisementData = advertisementData + self.rssi = rssi + self.state = .init(from: state) + self.lastActivity = lastActivity + } +} diff --git a/Tests/SpeziBluetoothTests/SpeziBluetoothTests.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothNotificationHandler.swift similarity index 55% rename from Tests/SpeziBluetoothTests/SpeziBluetoothTests.swift rename to Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothNotificationHandler.swift index 9f08e0f0..2c151b56 100644 --- a/Tests/SpeziBluetoothTests/SpeziBluetoothTests.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothNotificationHandler.swift @@ -6,12 +6,8 @@ // SPDX-License-Identifier: MIT // -@testable import SpeziBluetooth -import XCTest +import Foundation -final class SpeziBluetoothTests: XCTestCase { - func testSpeziBluetooth() throws { - XCTAssertTrue(true) - } -} +/// Notification handler for a change value of a specified characteristic. +public typealias BluetoothNotificationHandler = (_ data: Data) async -> Void diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/DiscoveryStaleTimer.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/DiscoveryStaleTimer.swift new file mode 100644 index 00000000..44b67de7 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/DiscoveryStaleTimer.swift @@ -0,0 +1,40 @@ +// +// 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 + + +class DiscoveryStaleTimer { + let targetDevice: UUID + /// The dispatch work item that schedules the next stale timer. + private let workItem: DispatchWorkItem + + init(device: UUID, handler: @escaping () -> Void) { + // make sure that you don't create a reference cycle through the closure above! + + self.targetDevice = device + self.workItem = DispatchWorkItem { // we do not capture self here!! + handler() + } + } + + + func cancel() { + workItem.cancel() + } + + func schedule(for timeout: TimeInterval, in queue: DispatchQueue) { + // `DispatchTime` only allows for integer time + let milliSeconds = Int(timeout * 1000) + queue.asyncAfter(deadline: .now() + .milliseconds(milliSeconds), execute: workItem) + } + + deinit { + cancel() + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/KVOStateObserver.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/KVOStateObserver.swift new file mode 100644 index 00000000..f1239080 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/KVOStateObserver.swift @@ -0,0 +1,42 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +protocol KVOReceiver: AnyObject { + func observeChange(of keyPath: KeyPath, value: V) async +} + + +class KVOStateObserver: NSObject { + private weak var receiver: Receiver? + + private var observation: NSKeyValueObservation? + + // swiftlint:disable:next function_default_parameter_at_end + init(receiver: Receiver? = nil, entity: Entity, property: KeyPath) { + self.receiver = receiver + super.init() + + observation = entity.observe(property) { [weak self] entity, _ in + let value = entity[keyPath: property] + self?.observeChange(of: property, value: value) + } + } + + func initReceiver(_ receiver: Receiver) { + self.receiver = receiver + } + + func observeChange(of keyPath: KeyPath, value: V) { + Task { + await receiver?.observeChange(of: keyPath, value: value) + } + } +} diff --git a/Sources/SpeziBluetooth/Model/Actions/BluetoothConnectAction.swift b/Sources/SpeziBluetooth/Model/Actions/BluetoothConnectAction.swift new file mode 100644 index 00000000..92af41fc --- /dev/null +++ b/Sources/SpeziBluetooth/Model/Actions/BluetoothConnectAction.swift @@ -0,0 +1,25 @@ +// +// 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 +// + + +/// Connect to the Bluetooth peripheral. +/// +/// For more information refer to ``DeviceActions/connect`` +public struct BluetoothConnectAction: _BluetoothPeripheralAction { + private let peripheral: BluetoothPeripheral + + @_documentation(visibility: internal) + public init(from peripheral: BluetoothPeripheral) { + self.peripheral = peripheral + } + + + public func callAsFunction() async { + await peripheral.connect() + } +} diff --git a/Sources/SpeziBluetooth/Model/Actions/BluetoothDisconnectAction.swift b/Sources/SpeziBluetooth/Model/Actions/BluetoothDisconnectAction.swift new file mode 100644 index 00000000..122dfb70 --- /dev/null +++ b/Sources/SpeziBluetooth/Model/Actions/BluetoothDisconnectAction.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 +// + + +/// Disconnect from the Bluetooth peripheral. +/// +/// For more information refer to ``DeviceActions/disconnect`` +public struct BluetoothDisconnectAction: _BluetoothPeripheralAction { + private let peripheral: BluetoothPeripheral + + @_documentation(visibility: internal) + public init(from peripheral: BluetoothPeripheral) { + self.peripheral = peripheral + } + + public func callAsFunction() async { + await peripheral.disconnect() + } +} diff --git a/Sources/SpeziBluetooth/Model/Actions/BluetoothPeripheralAction.swift b/Sources/SpeziBluetooth/Model/Actions/BluetoothPeripheralAction.swift new file mode 100644 index 00000000..2a51e1d7 --- /dev/null +++ b/Sources/SpeziBluetooth/Model/Actions/BluetoothPeripheralAction.swift @@ -0,0 +1,18 @@ +// +// 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 action that can be reference using ``DeviceAction``. +/// +/// To implement a device action, implement a conforming type that implements +/// a `callAsFunction()` method and declare the respective extension to ``DeviceActions``. +public protocol _BluetoothPeripheralAction { // swiftlint:disable:this type_name + /// Create a new action for a given peripheral instance. + /// - Parameter peripheral: The bluetooth peripheral instance. + init(from peripheral: BluetoothPeripheral) +} diff --git a/Sources/SpeziBluetooth/Model/Actions/DeviceActions.swift b/Sources/SpeziBluetooth/Model/Actions/DeviceActions.swift new file mode 100644 index 00000000..2cc7cee3 --- /dev/null +++ b/Sources/SpeziBluetooth/Model/Actions/DeviceActions.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 +// + + +/// Collection of device actions available on a ``BluetoothPeripheral``. +/// +/// Exposes the meta-types of all available actions of a Bluetooth Device as properties of this type. +/// +/// ## Topics +/// +/// ### Managing Connection +/// - ``connect`` +/// - ``disconnect`` +/// +/// ### Retrieving current signal strength +/// - ``readRSSI`` +/// +/// ### Implementations +/// +/// - ``BluetoothConnectAction`` +/// - ``BluetoothDisconnectAction`` +/// - ``ReadRSSIAction`` +public struct DeviceActions { + /// Connect to the Bluetooth peripheral. + /// + /// This action makes a call to ``BluetoothPeripheral/connect()``. + public var connect: BluetoothConnectAction.Type { + BluetoothConnectAction.self + } + + /// Disconnect from the Bluetooth peripheral. + /// + /// This action makes a call to ``BluetoothPeripheral/disconnect()``. + public var disconnect: BluetoothDisconnectAction.Type { + BluetoothDisconnectAction.self + } + + /// Retrieve the current signal strength. + /// + /// This action makes a call to ``BluetoothPeripheral/readRSSI()`` + public var readRSSI: ReadRSSIAction.Type { + ReadRSSIAction.self + } +} diff --git a/Sources/SpeziBluetooth/Model/Actions/ReadRSSIAction.swift b/Sources/SpeziBluetooth/Model/Actions/ReadRSSIAction.swift new file mode 100644 index 00000000..d23cc727 --- /dev/null +++ b/Sources/SpeziBluetooth/Model/Actions/ReadRSSIAction.swift @@ -0,0 +1,26 @@ +// +// 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 +// + + +/// Read the current RSSI from the Bluetooth peripheral. +/// +/// For more information refer to ``DeviceActions/readRSSI`` +public struct ReadRSSIAction: _BluetoothPeripheralAction { + private let peripheral: BluetoothPeripheral + + @_documentation(visibility: internal) + public init(from peripheral: BluetoothPeripheral) { + self.peripheral = peripheral + } + + + @discardableResult + public func callAsFunction() async throws -> Int { + try await peripheral.readRSSI() + } +} diff --git a/Sources/SpeziBluetooth/Model/BluetoothDevice.swift b/Sources/SpeziBluetooth/Model/BluetoothDevice.swift new file mode 100644 index 00000000..2059f820 --- /dev/null +++ b/Sources/SpeziBluetooth/Model/BluetoothDevice.swift @@ -0,0 +1,43 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Observation +import Spezi + + +/// A Bluetooth device implementation. +/// +/// This protocol allows you to decoratively define your Bluetooth peripheral. +/// Use the ``Service`` property wrapper to declare all services of your device. +/// +/// - Tip: You can use the ``DeviceState`` and ``DeviceAction`` property wrappers to retrieve device state +/// or interact with your Bluetooth device. +/// +/// Below is a short code example of a device that implements a Device Information and Heart Rate service. +/// +/// ```swift +/// class MyDevice: BluetoothDevice { +/// @Service(id: "180A") +/// var deviceInformation = DeviceInformationService() +/// @Service(id: "180D") +/// var heartRate = HeartRateService() +/// +/// init() {} +/// } +/// ``` +public protocol BluetoothDevice: AnyObject, EnvironmentAccessible { + /// Initializes the Bluetooth Device. + /// + /// This initializer is called automatically when a peripheral of this type connects. + /// + /// - Important: All property wrappers are only available after the initializer returned. + /// + /// - Note: This initializer is also called upon configuration to inspect the device structure. + /// You might want to make sure to not perform any heavy processing within the initializer. + init() +} diff --git a/Sources/SpeziBluetooth/Model/BluetoothService.swift b/Sources/SpeziBluetooth/Model/BluetoothService.swift new file mode 100644 index 00000000..083a54ac --- /dev/null +++ b/Sources/SpeziBluetooth/Model/BluetoothService.swift @@ -0,0 +1,28 @@ +// +// 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 Bluetooth service implementation. +/// +/// This protocol allows you to decoratively define a service of a given Bluetooth peripheral. +/// Use the ``Characteristic`` property wrapper to declare all characteristics of your service. +/// +/// - Tip: You may also use the ``DeviceState`` and ``DeviceAction`` property wrappers within your service implementation +/// to interact with the Bluetooth device the service is used on. +/// +/// Below is a short code example that implements some parts of the Device Information service. +/// +/// ```swift +/// class DeviceInformationService: BluetoothService { +/// @Characteristic(id: "2A29") +/// var manufacturer: String? +/// @Characteristic(id: "2A26") +/// var firmwareRevision: String? +/// } +/// ``` +public protocol BluetoothService: AnyObject {} diff --git a/Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift b/Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift new file mode 100644 index 00000000..970bc4c1 --- /dev/null +++ b/Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift @@ -0,0 +1,145 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth + + +/// Interact with a given Characteristic. +/// +/// This type allows you to interact with a Characteristic you previously declared using the ``Characteristic`` property wrapper. +/// +/// ## Topics +/// +/// ### Characteristic properties +/// - ``isPresent`` +/// - ``properties`` +/// - ``descriptors`` +/// +/// ### Reading a value +/// - ``read()`` +/// +/// ### Writing a value +/// - ``write(_:)`` +/// - ``writeWithoutResponse(_:)`` +/// +/// ### Controlling notifications +/// - ``isNotifying`` +/// - ``enableNotifications(_:)`` +public struct CharacteristicAccessors { + let id: CBUUID + fileprivate let context: CharacteristicContext + + + init(id: CBUUID, context: CharacteristicContext) { + self.id = id + self.context = context + } +} + + +extension CharacteristicAccessors { + /// Determine if the characteristic is available. + /// + /// Returns true if the characteristic is available for the current device. + /// It is ture if (a) the device is connected and (b) the device exposes the requested characteristic. + public var isPresent: Bool { + context.characteristic != nil + } + + /// Properties of the characteristic. + /// + /// Nil if device is not connected. + public var properties: CBCharacteristicProperties? { + context.characteristic?.properties + } + + /// Descriptors of the characteristic. + /// + /// Nil if device is not connected or descriptors are not yet discovered. + public var descriptors: [CBDescriptor]? { // swiftlint:disable:this discouraged_optional_collection + context.characteristic?.descriptors + } +} + + +extension CharacteristicAccessors where Value: ByteDecodable { + /// Characteristic is currently notifying about updated values. + /// + /// This is false if device is not connected. + public var isNotifying: Bool { + context.characteristic?.isNotifying ?? false + } + + + /// Enable or disable characteristic notifications. + /// - Parameter enable: Flag indicating if notifications should be enabled. + public func enableNotifications(_ enable: Bool = true) async { + if enable { + await context.enableNotifications() + } else { + await context.disableNotifications() + } + } + + /// Read the current characteristic value from the remote peripheral. + /// - Returns: The value that was read. + /// - Throws: Throws an `CBError` or `CBATTError` if the read fails. + /// It might also throw a ``BluetoothError/notPresent`` or ``BluetoothError/incompatibleDataFormat`` error. + @discardableResult + public func read() async throws -> Value { + guard let characteristic = context.characteristic else { + throw BluetoothError.notPresent + } + + let data = try await context.peripheral.read(characteristic: characteristic) + guard let value = Value(data: data) else { + throw BluetoothError.incompatibleDataFormat + } + return value + } +} + + +extension CharacteristicAccessors where Value: ByteEncodable { + /// Write the value of a characteristic expecting a confirmation. + /// + /// Writes the value of a characteristic expecting a confirmation from the peripheral. + /// + /// - Note: The write operation is specified in Bluetooth Core Specification, Volume 3, + /// Part G, 4.9.3 Write Characteristic Value. + /// + /// - Parameter value: The value you want to write. + /// - Throws: Throws an `CBError` or `CBATTError` if the write fails. + /// It might also throw a ``BluetoothError/notPresent`` error. + public func write(_ value: Value) async throws { + guard let characteristic = context.characteristic else { + throw BluetoothError.notPresent + } + + let requestData = value.encode() + try await context.peripheral.write(data: requestData, for: characteristic) + } + + /// Write the value of a characteristic without expecting a confirmation. + /// + /// Writes the value of a characteristic without expecting a confirmation from the peripheral. + /// + /// - Note: The write operation is specified in Bluetooth Core Specification, Volume 3, + /// Part G, 4.9.1 Write Without Response. + /// - Parameter value: The value you want to write. + /// - Throws: Throws an `CBError` or `CBATTError` if the write fails. + /// It might also throw a ``BluetoothError/notPresent`` error. + public func writeWithoutResponse(_ value: Value) async throws { + guard let characteristic = context.characteristic else { + throw BluetoothError.notPresent + } + + let data = value.encode() + await context.peripheral.writeWithoutResponse(data: data, for: characteristic) + } +} diff --git a/Sources/SpeziBluetooth/Model/Characteristic/CharacteristicContext.swift b/Sources/SpeziBluetooth/Model/Characteristic/CharacteristicContext.swift new file mode 100644 index 00000000..0bedc1db --- /dev/null +++ b/Sources/SpeziBluetooth/Model/Characteristic/CharacteristicContext.swift @@ -0,0 +1,164 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth + + +/// Indirect storage box to support a write-only lock with eventual consistent reads. +class OptionalBox { + fileprivate(set) var value: Value? + + init(value: Value?) { + self.value = value + } +} + + +private protocol DecodableCharacteristic { + func handleUpdateValueAssumingIsolation(_ data: Data?) +} + + +/// Captures and synchronizes access to the state of a ``Characteristic`` property wrapper. +actor CharacteristicContext { + let peripheral: BluetoothPeripheral + let characteristicId: CBUUID + let serviceId: CBUUID + + private let characteristicBox: OptionalBox + private let valueBox: OptionalBox + + private var notify = false + private var registration: CharacteristicNotification? + + nonisolated var characteristic: CBCharacteristic? { // nil if device is not connected yet + characteristicBox.value + } + + nonisolated var value: Value? { + valueBox.value + } + + + init( + peripheral: BluetoothPeripheral, + serviceId: CBUUID, + characteristicId: CBUUID, + characteristic: CBCharacteristic? + ) { + self.peripheral = peripheral + self.serviceId = serviceId + self.characteristicId = characteristicId + self.characteristicBox = OptionalBox(value: characteristic) + self.valueBox = OptionalBox(value: nil) + } + + /// Setup the context. Must be called after initialization to set up all handlers and write the initial value. + /// - Parameter defaultNotify: Flag indicating if notification handlers should be registered immediately. + func setup(defaultNotify: Bool) async { + trackServicesUpdates() // enable observation tracking for peripheral.services + + if let instance = self as? DecodableCharacteristic { // Value is ByteDecodable! + // handle assigning the initial value! + if let characteristic, + let value = characteristic.value { + instance.handleUpdateValueAssumingIsolation(value) + } + + if defaultNotify { + await enableNotifications() + } + } + } + + /// Enable notifications (if not already) for the characteristic. + func enableNotifications() async { + guard !notify else { + return + } + + self.notify = true + + let registration = await peripheral + .registerNotifications(service: serviceId, characteristic: characteristicId) { [weak self] data in + Task { [weak self] in + await self?.handleNotification(data) + } + } + + // we have a suspension point above, so we need to double check that our `notify` property is still true to catch any race conditions + + if notify { + self.registration = registration + } else { + // notifications were disabled in the meantime. Remove our registration again. + await registration.cancel() + } + } + + /// Disable notifications (if not already) for the characteristic. + func disableNotifications() async { + guard notify else { + return + } + + let registration = self.registration + + self.notify = false + self.registration = nil + + await registration?.cancel() + } + + private nonisolated func trackServicesUpdates() { + withObservationTracking { + _ = peripheral.services + } onChange: { [weak self] in + Task { [weak self] in + await self?.handleServicesChange() + } + self?.trackServicesUpdates() + } + } + + private func handleServicesChange() { + let service = peripheral.services?.first(where: { $0.uuid == serviceId }) + let characteristic = service?.characteristics?.first(where: { $0.uuid == characteristicId }) + + characteristicBox.value = characteristic + + if characteristic == nil { // device disconnected, remove the value + valueBox.value = nil + } + } + + private func handleNotification(_ data: Data?) { + guard let decodable = self as? DecodableCharacteristic else { + return + } + + decodable.handleUpdateValueAssumingIsolation(data) + } +} + + +extension CharacteristicContext: DecodableCharacteristic where Value: ByteDecodable { + nonisolated func handleUpdateValueAssumingIsolation(_ data: Data?) { + // assumes this is called with actor isolation! + if let data { + guard let value = Value(data: data) else { + Bluetooth.logger.error("Could decode updated value for characteristic \(self.characteristic?.debugIdentifier ?? self.characteristicId.uuidString). Invalid format!") + return + } + + self.valueBox.value = value + } else { + self.valueBox.value = nil + } + } +} diff --git a/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift b/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift new file mode 100644 index 00000000..3e4cfcd8 --- /dev/null +++ b/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift @@ -0,0 +1,273 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth +import Foundation + + +/// Declare a characteristic within a Bluetooth service. +/// +/// This property wrapper can be used to declare a Bluetooth characteristic within a ``BluetoothService``. +/// The value type of your property needs to be optional and conform to ``ByteEncodable``, ``ByteDecodable`` or ``ByteCodable`` respectively. +/// +/// If your device is connected, the characteristic value is automatically updated upon a characteristic read or a notify. +/// +/// - Note: Every `Characteristic` is [Observable](https://developer.apple.com/documentation/Observation) out of the box. +/// So you can easily use the characteristic value within your SwiftUI view and it will be automatically re-rendered +/// when the characteristic value is updated. +/// +/// The below code example demonstrates declaring the Firmware Revision characteristic of the Device Information service. +/// +/// ```swift +/// class DeviceInformationService: BluetoothService { +/// @Characteristic(id: "2A26") +/// var firmwareRevision: String? +/// } +/// ``` +/// +/// ### Automatic Notifications +/// +/// If your characteristic supports notifications, you can automatically subscribe to characteristic notifications +/// by supplying the `notify` initializer argument. +/// +/// 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. +/// +/// ```swift +/// class HeartRateService: BluetoothService { +/// @Characteristic(id: "2A37", notify: true) +/// var heartRateMeasurement: HeartRateMeasurement? +/// +/// init() {} +/// } +/// ``` +/// +/// ### Characteristic Interactions +/// +/// To interact with a characteristic to read or write a value or enable or disable notifications, +/// you can use the ``projectedValue`` (`$` notation) to retrieve a temporary ``CharacteristicAccessors`` instance. +/// +/// Do demonstrate this functionality, we completed the implementation of our Heart Rate Service +/// according to its [Specification](https://www.bluetooth.com/specifications/specs/heart-rate-service-1-0). +/// The example demonstrates reading and writing of characteristic values, controlling characteristic notifications, +/// and inspecting other properties like `isPresent`. +/// +/// ```swift +/// class HeartRateService: BluetoothService { +/// @Characteristic(id: "2A37", notify: true) +/// var heartRateMeasurement: HeartRateMeasurement? +/// @Characteristic(id: "2A38") +/// var bodySensorLocation: UInt8? +/// @Characteristic(id: "2A39") +/// var heartRateControlPoint: UInt8? +/// +/// var measurementsRunning: Bool { +/// $heartRateMeasurement.isNotifying +/// } +/// +/// var energyExpendedFeatureSupported: Bool { +/// // characteristic is required to be present if feature is supported (see Heart Rate Service spec). +/// $heartRateControlPoint.isPresent +/// } +/// +/// +/// init() {} +/// +/// +/// func handleConnected() async throws { // manually called from the outside +/// try await $bodySensorLocation.read() +/// if energyExpendedFeatureSupported { +/// try await $heartRateControlPoint.write(0x01) // resets the energy expended measurement +/// } +/// } +/// +/// func pauseMeasurements() async { +/// await $heartRateMeasurement.enableNotifications(false) +/// } +/// +/// func resumeMeasurements() async { +/// await $heartRateMeasurement.enableNotifications() +/// } +/// } +/// ``` +/// +/// ## Topics +/// +/// ### Declaring a Characteristic +/// - ``init(wrappedValue:id:notify:discoverDescriptors:)-8r34a`` +/// - ``init(wrappedValue:id:notify:discoverDescriptors:)-bev4`` +/// - ``init(wrappedValue:id:discoverDescriptors:)-6xq7e`` +/// - ``init(wrappedValue:id:discoverDescriptors:)-2esyb`` +/// - ``init(wrappedValue:id:notify:discoverDescriptors:)-4tg93`` +/// - ``init(wrappedValue:id:notify:discoverDescriptors:)-9zex3`` +/// +/// ### Inspecting a Characteristic +/// - ``CharacteristicAccessors/isPresent`` +/// - ``CharacteristicAccessors/properties`` +/// - ``CharacteristicAccessors/descriptors`` +/// +/// ### Reading a value +/// - ``CharacteristicAccessors/read()`` +/// +/// ### Controlling notifications +/// - ``CharacteristicAccessors/isNotifying`` +/// - ``CharacteristicAccessors/enableNotifications(_:)`` +/// +/// ### Writing a value +/// - ``CharacteristicAccessors/write(_:)`` +/// - ``CharacteristicAccessors/writeWithoutResponse(_:)`` +/// +/// ### Property wrapper access +/// - ``wrappedValue`` +/// - ``projectedValue`` +/// - ``CharacteristicAccessors`` +@Observable +@propertyWrapper +public class Characteristic { + private let id: CBUUID + private let discoverDescriptors: Bool + + private let defaultValue: Value? + private let defaultNotify: Bool + + var description: CharacteristicDescription { + CharacteristicDescription(id: id, discoverDescriptors: discoverDescriptors) + } + + /// Access the current characteristic value. + /// + /// This is either the last read value or the latest notified value. + public var wrappedValue: Value? { + guard let context else { + return defaultValue + } + return context.value + } + + /// Retrieve a temporary accessors instance. + public var projectedValue: CharacteristicAccessors { + guard let context else { + preconditionFailure( + """ + Failed to access bluetooth characteristic. Make sure your @Characteristic is only declared within your bluetooth device class \ + that is managed by SpeziBluetooth. + """ + ) + } + return CharacteristicAccessors(id: id, context: context) + } + + private var context: CharacteristicContext? + + fileprivate init(wrappedValue: Value? = nil, characteristic: CBUUID, notify: Bool, discoverDescriptors: Bool = false) { + // swiftlint:disable:previous function_default_parameter_at_end + self.defaultValue = wrappedValue + self.id = characteristic + self.defaultNotify = notify + self.discoverDescriptors = discoverDescriptors + } + + + @MainActor + func inject(peripheral: BluetoothPeripheral, serviceId: CBUUID, service: CBService?) { + let characteristic = service?.characteristics?.first(where: { $0.uuid == self.id }) + + let context = CharacteristicContext( + peripheral: peripheral, + serviceId: serviceId, + characteristicId: self.id, + characteristic: characteristic + ) + + self.context = context + + Task { + await context.setup(defaultNotify: defaultNotify) + } + } +} + + +extension Characteristic where Value: ByteEncodable { + /// Declare a write-only characteristic. + /// - Parameters: + /// - wrappedValue: An optional default value. + /// - id: The characteristic id. + /// - discoverDescriptors: Flag if characteristic descriptors should be discovered automatically. + public convenience init(wrappedValue: Value? = nil, id: String, discoverDescriptors: Bool = false) { + // swiftlint:disable:previous function_default_parameter_at_end + self.init(wrappedValue: wrappedValue, id: CBUUID(string: id), discoverDescriptors: discoverDescriptors) + } + + /// Declare a write-only characteristic. + /// - Parameters: + /// - wrappedValue: An optional default value. + /// - id: The characteristic id. + /// - discoverDescriptors: Flag if characteristic descriptors should be discovered automatically. + public convenience init(wrappedValue: Value? = nil, id: CBUUID, discoverDescriptors: Bool = false) { + // swiftlint:disable:previous function_default_parameter_at_end + self.init(wrappedValue: wrappedValue, characteristic: id, notify: false, discoverDescriptors: discoverDescriptors) + } +} + + +extension Characteristic where Value: ByteDecodable { + /// Declare a read-only characteristic. + /// - Parameters: + /// - wrappedValue: An optional default value. + /// - id: The characteristic id. + /// - notify: Automatically subscribe to characteristic notifications if supported. + /// - discoverDescriptors: Flag if characteristic descriptors should be discovered automatically. + public convenience init(wrappedValue: Value? = nil, id: String, notify: Bool = false, discoverDescriptors: Bool = false) { + // swiftlint:disable:previous function_default_parameter_at_end + self.init(wrappedValue: wrappedValue, id: CBUUID(string: id), notify: notify, discoverDescriptors: discoverDescriptors) + } + + /// Declare a read-only characteristic. + /// - Parameters: + /// - wrappedValue: An optional default value. + /// - id: The characteristic id. + /// - notify: Automatically subscribe to characteristic notifications if supported. + /// - discoverDescriptors: Flag if characteristic descriptors should be discovered automatically. + public convenience init(wrappedValue: Value? = nil, id: CBUUID, notify: Bool = false, discoverDescriptors: Bool = false) { + // swiftlint:disable:previous function_default_parameter_at_end + self.init(wrappedValue: wrappedValue, characteristic: id, notify: notify, discoverDescriptors: discoverDescriptors) + } +} + + +extension Characteristic where Value: ByteCodable { // reduce ambiguity + /// Declare a read and write characteristic. + /// - Parameters: + /// - wrappedValue: An optional default value. + /// - id: The characteristic id. + /// - notify: Automatically subscribe to characteristic notifications if supported. + /// - discoverDescriptors: Flag if characteristic descriptors should be discovered automatically. + public convenience init(wrappedValue: Value? = nil, id: String, notify: Bool = false, discoverDescriptors: Bool = false) { + // swiftlint:disable:previous function_default_parameter_at_end + self.init(wrappedValue: wrappedValue, id: CBUUID(string: id), notify: notify, discoverDescriptors: discoverDescriptors) + } + + /// Declare a read and write characteristic. + /// - Parameters: + /// - wrappedValue: An optional default value. + /// - id: The characteristic id. + /// - notify: Automatically subscribe to characteristic notifications if supported. + /// - discoverDescriptors: Flag if characteristic descriptors should be discovered automatically. + public convenience init(wrappedValue: Value? = nil, id: CBUUID, notify: Bool = false, discoverDescriptors: Bool = false) { + // swiftlint:disable:previous function_default_parameter_at_end + self.init(wrappedValue: wrappedValue, characteristic: id, notify: notify, discoverDescriptors: discoverDescriptors) + } +} + + +extension Characteristic: ServiceVisitable { + func accept(_ visitor: inout Visitor) { + visitor.visit(self) + } +} diff --git a/Sources/SpeziBluetooth/Model/Properties/DeviceAction.swift b/Sources/SpeziBluetooth/Model/Properties/DeviceAction.swift new file mode 100644 index 00000000..cbe51e02 --- /dev/null +++ b/Sources/SpeziBluetooth/Model/Properties/DeviceAction.swift @@ -0,0 +1,92 @@ +// +// 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 +// + + +/// Control an action of a Bluetooth peripheral. +/// +/// This property wrapper can be used within your ``BluetoothDevice`` or ``BluetoothService`` models to +/// control an action of a Bluetooth peripheral. +/// +/// Below is a short code example that demonstrates the usage of the `DeviceAction` property wrapper to +/// execute the connect and disconnect actions of a device. +/// +/// - Note: The `@DeviceAction` property wrapper can only be accessed after the initializer returned. Accessing within the initializer will result in a runtime crash. +/// +/// ```swift +/// class ExampleDevice: BluetoothDevice { +/// @DeviceAction(\.connect) +/// var connect +/// +/// @DeviceAction(\.disconnect) +/// var disconnect +/// +/// init() { +/// // ... +/// } +/// +/// /// Called when all measurements were successfully transmitted. +/// func transmissionFinished() async { +/// // ... +/// await disconnect() +/// } +/// } +/// ``` +/// +/// ## Topics +/// +/// ### Available Device Actions +/// - ``DeviceActions/connect`` +/// - ``DeviceActions/disconnect`` +/// - ``DeviceActions/readRSSI`` +/// +/// ### Declaring a device action +/// - ``init(_:)`` +/// +/// ### Property wrapper access +/// - ``wrappedValue`` +/// +/// ### Device Actions +/// - ``DeviceActions`` +@propertyWrapper +public class DeviceAction { + /// Access the device action. + public var wrappedValue: Action { + guard let peripheral else { + preconditionFailure( + """ + Failed to access bluetooth device action. Make sure your @DeviceAction is only declared within your bluetooth device class \ + that is managed by SpeziBluetooth. + """ + ) + } + return Action(from: peripheral) + } + + private var peripheral: BluetoothPeripheral? + + + /// Provide a `KeyPath` to the device action you want to access. + /// - Parameter keyPath: The `KeyPath` to a property of ``DeviceActions``. + public init(_ keyPath: KeyPath) {} + + + func inject(peripheral: BluetoothPeripheral) { + self.peripheral = peripheral + } +} + + +extension DeviceAction: DeviceVisitable, ServiceVisitable { + func accept(_ visitor: inout Visitor) { + visitor.visit(self) + } + + func accept(_ visitor: inout Visitor) { + visitor.visit(self) + } +} diff --git a/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift b/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift new file mode 100644 index 00000000..06d5b9f1 --- /dev/null +++ b/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift @@ -0,0 +1,88 @@ +// +// 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 +// + + +/// Retrieve state of a Bluetooth peripheral. +/// +/// This property wrapper can be used within your ``BluetoothDevice`` or ``BluetoothService`` models to +/// get access to the state of your Bluetooth peripheral. +/// +/// Below is a short code example that demonstrate the usage of the `DeviceState` property wrapper to retrieve the name and current ``BluetoothState`` +/// of a device. +/// +/// - Note: The `@DeviceState` property wrapper can only be accessed after the initializer returned. Accessing within the initializer will result in a runtime crash. +/// +/// ```swift +/// class ExampleDevice: BluetoothDevice { +/// @DeviceState(\.name) +/// var name: String? +/// +/// @DeviceState(\.state) +/// var state: BluetoothState +/// +/// init() { +/// // ... +/// } +/// } +/// ``` +/// +/// ## Topics +/// +/// ### Available Device States +/// - ``BluetoothPeripheral/id`` +/// - ``BluetoothPeripheral/name`` +/// - ``BluetoothPeripheral/state`` +/// - ``BluetoothPeripheral/rssi`` +/// - ``BluetoothPeripheral/advertisementData`` +/// +/// ### Declaring device state +/// - ``init(_:)`` +/// +/// ### Property wrapper access +/// - ``wrappedValue`` +@propertyWrapper +public class DeviceState { + private let keyPath: KeyPath + private var peripheral: BluetoothPeripheral? + + /// Access the device state. + public var wrappedValue: Value { + guard let peripheral else { + preconditionFailure( + """ + Failed to access bluetooth device state. Make sure your @DeviceState is only declared within your bluetooth device class \ + that is managed by SpeziBluetooth. + """ + ) + } + return peripheral[keyPath: keyPath] + } + + + /// Provide a `KeyPath` to the device state you want to access. + /// - Parameter keyPath: The `KeyPath` to a property of the underlying ``BluetoothPeripheral`` instance. + public init(_ keyPath: KeyPath) { + self.keyPath = keyPath + } + + + func inject(peripheral: BluetoothPeripheral) { + self.peripheral = peripheral + } +} + + +extension DeviceState: DeviceVisitable, ServiceVisitable { + func accept(_ visitor: inout Visitor) { + visitor.visit(self) + } + + func accept(_ visitor: inout Visitor) { + visitor.visit(self) + } +} diff --git a/Sources/SpeziBluetooth/Model/Properties/Service.swift b/Sources/SpeziBluetooth/Model/Properties/Service.swift new file mode 100644 index 00000000..c8628d03 --- /dev/null +++ b/Sources/SpeziBluetooth/Model/Properties/Service.swift @@ -0,0 +1,67 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth + + +/// Declare a service within a Bluetooth device. +/// +/// This property wrapper can be used to declare a Bluetooth service within a ``BluetoothDevice``. +/// You must provide an instance to your ``BluetoothService`` implementation. +/// Refer to the respective documentation for more details. +/// +/// Below is a short code example on how you would declare your [Bluetooth Heart Rate Service](https://www.bluetooth.com/specifications/specs/heart-rate-service-1-0) +/// implementation within your Bluetooth device. +/// +/// ``swift +/// class MyDevice: BluetoothDevice { +/// @Service(id: "180D") +/// var heartRate = HeartRateService() +/// } +/// ``` +/// +/// ## Topics +/// +/// ### Declaring a Service +/// - ``init(wrappedValue:id:)-2mo8b`` +/// - ``init(wrappedValue:id:)-1if8d`` +/// +/// ### Property wrapper access +/// - ``wrappedValue`` +@propertyWrapper +public class Service { + let id: CBUUID + + /// Access the service instance. + public let wrappedValue: S + + + /// Declare a service. + /// - Parameters: + /// - wrappedValue: The service instance. + /// - id: The service id. + public convenience init(wrappedValue: S, id: String) { + self.init(wrappedValue: wrappedValue, id: CBUUID(string: id)) + } + + /// Declare a service. + /// - Parameters: + /// - wrappedValue: The service instance. + /// - id: The service id. + public init(wrappedValue: S, id: CBUUID) { + self.wrappedValue = wrappedValue + self.id = id + } +} + + +extension Service: DeviceVisitable { + func accept(_ visitor: inout Visitor) { + visitor.visit(self) + } +} diff --git a/Sources/SpeziBluetooth/Model/SemanticModel/DeviceDescriptionParser.swift b/Sources/SpeziBluetooth/Model/SemanticModel/DeviceDescriptionParser.swift new file mode 100644 index 00000000..cbed2863 --- /dev/null +++ b/Sources/SpeziBluetooth/Model/SemanticModel/DeviceDescriptionParser.swift @@ -0,0 +1,53 @@ +// +// 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 +// + + +private struct CharacteristicsBuilder: ServiceVisitor { + var characteristics: Set = [] + + mutating func visit(_ characteristic: Characteristic) { + characteristics.insert(characteristic.description) + } +} + + +private struct ServiceDescriptionBuilder: DeviceVisitor { + var configurations: Set = [] + + mutating func visit(_ service: Service) { + var visitor = CharacteristicsBuilder() + service.wrappedValue.accept(&visitor) + + let configuration = ServiceDescription(serviceId: service.id, characteristics: visitor.characteristics) + configurations.insert(configuration) + } +} + + +extension DiscoveryConfiguration { + func parseDeviceDescription() -> DeviceDescription { + let device = anyDeviceType.init() + + var builder = ServiceDescriptionBuilder() + device.accept(&builder) + return DeviceDescription(discoverBy: discoveryCriteria, services: builder.configurations) + } +} + + +extension Set where Element == DiscoveryConfiguration { + var deviceTypes: [BluetoothDevice.Type] { + map { configuration in + configuration.anyDeviceType + } + } + + func parseDeviceDescription() -> Set { + Set(map { $0.parseDeviceDescription() }) + } +} diff --git a/Sources/SpeziBluetooth/Model/SemanticModel/SetupDeviceVisitor.swift b/Sources/SpeziBluetooth/Model/SemanticModel/SetupDeviceVisitor.swift new file mode 100644 index 00000000..37e6308f --- /dev/null +++ b/Sources/SpeziBluetooth/Model/SemanticModel/SetupDeviceVisitor.swift @@ -0,0 +1,71 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth + + +private struct SetupServiceVisitor: ServiceVisitor { + private let peripheral: BluetoothPeripheral + private let serviceId: CBUUID + private let service: CBService? + + + init(peripheral: BluetoothPeripheral, serviceId: CBUUID, service: CBService?) { + self.peripheral = peripheral + self.serviceId = serviceId + self.service = service + } + + + @MainActor + func visit(_ characteristic: Characteristic) { + characteristic.inject(peripheral: peripheral, serviceId: serviceId, service: service) + } + + func visit(_ action: DeviceAction) { + action.inject(peripheral: peripheral) + } + + func visit(_ state: DeviceState) { + state.inject(peripheral: peripheral) + } +} + + +private struct SetupDeviceVisitor: DeviceVisitor { + private let peripheral: BluetoothPeripheral + + + init(peripheral: BluetoothPeripheral) { + self.peripheral = peripheral + } + + + func visit(_ service: Service) { + let cbService = peripheral.services?.first(where: { $0.uuid == service.id }) + + var visitor = SetupServiceVisitor(peripheral: peripheral, serviceId: service.id, service: cbService) + service.wrappedValue.accept(&visitor) + } + + func visit(_ action: DeviceAction) { + action.inject(peripheral: peripheral) + } + + func visit(_ state: DeviceState) { + state.inject(peripheral: peripheral) + } +} + + +extension BluetoothDevice { + func inject(peripheral: BluetoothPeripheral) { + var visitor = SetupDeviceVisitor(peripheral: peripheral) + accept(&visitor) + } +} diff --git a/Sources/SpeziBluetooth/Model/Visitor/BaseVisitor.swift b/Sources/SpeziBluetooth/Model/Visitor/BaseVisitor.swift new file mode 100644 index 00000000..bdb910a7 --- /dev/null +++ b/Sources/SpeziBluetooth/Model/Visitor/BaseVisitor.swift @@ -0,0 +1,21 @@ +// +// 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 BaseVisitor { + mutating func visit(_ action: DeviceAction) + + mutating func visit(_ state: DeviceState) +} + + +extension BaseVisitor { + func visit(_ action: DeviceAction) {} + + func visit(_ state: DeviceState) {} +} diff --git a/Sources/SpeziBluetooth/Model/Visitor/DeviceVisitor.swift b/Sources/SpeziBluetooth/Model/Visitor/DeviceVisitor.swift new file mode 100644 index 00000000..5a4a315f --- /dev/null +++ b/Sources/SpeziBluetooth/Model/Visitor/DeviceVisitor.swift @@ -0,0 +1,31 @@ +// +// 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 DeviceVisitable { + func accept(_ visitor: inout Visitor) +} + + +protocol DeviceVisitor: BaseVisitor { + mutating func visit(_ service: Service) +} + + +extension BluetoothDevice { + func accept(_ visitor: inout Visitor) { + let mirror = Mirror(reflecting: self) + for (_, child) in mirror.children { + if let visitable = child as? DeviceVisitable { + visitable.accept(&visitor) + } else if child is ServiceVisitable { + preconditionFailure("@Characteristic declaration found in \(Self.self). @Characteristic cannot be used within the BluetoothDevice class!") + } + } + } +} diff --git a/Sources/SpeziBluetooth/Model/Visitor/ServiceVisitor.swift b/Sources/SpeziBluetooth/Model/Visitor/ServiceVisitor.swift new file mode 100644 index 00000000..fdd31e79 --- /dev/null +++ b/Sources/SpeziBluetooth/Model/Visitor/ServiceVisitor.swift @@ -0,0 +1,31 @@ +// +// 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 ServiceVisitable { + func accept(_ visitor: inout Visitor) +} + + +protocol ServiceVisitor: BaseVisitor { + mutating func visit(_ characteristic: Characteristic) +} + + +extension BluetoothService { + func accept(_ visitor: inout Visitor) { + let mirror = Mirror(reflecting: self) + for (_, child) in mirror.children { + if let visitable = child as? ServiceVisitable { + visitable.accept(&visitor) + } else if child is DeviceVisitable { + preconditionFailure("@Service declaration found in \(Self.self). @Service cannot be used within BluetoothService classes!") + } + } + } +} diff --git a/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift b/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift new file mode 100644 index 00000000..299fe975 --- /dev/null +++ b/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift @@ -0,0 +1,71 @@ +// +// 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 ConnectedDeviceEnvironmentModifier: ViewModifier { + @Environment(ConnectedDevices.self) + var connectedDevices + + init() {} + + + func body(content: Content) -> some View { + let connectedDeviceAny = connectedDevices[ObjectIdentifier(Device.self)] + let connectedDevice = connectedDeviceAny as? Device + + content + .environment(connectedDevice) + } +} + + +struct ConnectedDevicesEnvironmentModifier: ViewModifier { + private let configuredDeviceTypes: [BluetoothDevice.Type] + + @Environment(ConnectedDevices.self) + var connectedDevices + + + init(configuredDeviceTypes: [BluetoothDevice.Type]) { + self.configuredDeviceTypes = configuredDeviceTypes + } + + + func body(content: Content) -> some View { + let modifiers = configuredDeviceTypes.map { $0.deviceEnvironmentModifier } + + modifiers.modify(content) + } +} + + +extension BluetoothDevice { + fileprivate static var deviceEnvironmentModifier: any ViewModifier { + ConnectedDeviceEnvironmentModifier() + } +} + + +extension Array where Element == any ViewModifier { + fileprivate func modify(_ view: V) -> AnyView { + var view = AnyView(view) + for modifier in self { + view = modifier.modify(view) + } + return view + } +} + + +extension ViewModifier { + fileprivate func modify(_ view: AnyView) -> AnyView { + AnyView(view.modifier(self)) + } +} diff --git a/Sources/SpeziBluetooth/Modifier/DeviceAutoConnectModifier.swift b/Sources/SpeziBluetooth/Modifier/DeviceAutoConnectModifier.swift new file mode 100644 index 00000000..a07b18a3 --- /dev/null +++ b/Sources/SpeziBluetooth/Modifier/DeviceAutoConnectModifier.swift @@ -0,0 +1,48 @@ +// +// 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 new file mode 100644 index 00000000..e2cae6dd --- /dev/null +++ b/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift @@ -0,0 +1,85 @@ +// +// 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 Spezi +import SwiftUI + + +private struct ScanNearbyDevicesModifier: ViewModifier { + private let enabled: Bool + private let scanner: Scanner + private let autoConnect: Bool + + + init(enabled: Bool, scanner: Scanner, autoConnect: Bool) { + self.enabled = enabled + self.scanner = scanner + self.autoConnect = autoConnect + } + + + func body(content: Content) -> some View { + content + .onAppear(perform: onForeground) + .onDisappear(perform: onBackground) + .onReceive(NotificationCenter.default.publisher(for: UIScene.willEnterForegroundNotification)) { _ in + onForeground() // onAppear is coupled with view rendering only and won't get fired when putting app into the foreground + } + .onReceive(NotificationCenter.default.publisher(for: UIScene.didEnterBackgroundNotification)) { _ in + onBackground() // onDisappear is coupled with view rendering only and won't get fired when putting app into the background + } + } + + @MainActor + private func onForeground() { + if enabled { + Task { + await scanner.scanNearbyDevices(autoConnect: autoConnect) + } + } + } + + @MainActor + private func onBackground() { + Task { + await scanner.stopScanning() + } + } +} + + +extension View { + /// Scan for nearby Bluetooth devices. + /// + /// 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:)`` modifier instead. + /// + /// 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. + /// - autoConnect: If enabled, the bluetooth manager will automatically connect to the nearby device if only one is found. + /// - Returns: The modified view. + /// + /// ## Topics + /// + /// ### 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)) + } +} diff --git a/Sources/SpeziBluetooth/Resources/Localizable.xcstrings b/Sources/SpeziBluetooth/Resources/Localizable.xcstrings index 6aeb78da..29667c1a 100644 --- a/Sources/SpeziBluetooth/Resources/Localizable.xcstrings +++ b/Sources/SpeziBluetooth/Resources/Localizable.xcstrings @@ -1,32 +1,42 @@ { "sourceLanguage" : "en", "strings" : { - "BLUETOOTH_ERROR_DEVICE_TIME_OUT" : { + "Could not decode byte representation into provided format." : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "The device no longer sends new measurement values. The connection seemed to have timed out." + "value" : "Could not decode byte representation into provided format." } } } }, - "BLUETOOTH_ERROR_NOT_CONNECTED" : { + "Decoding Error" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "The device is not connected. Please ensure that the device is powered on or try to restart the device." + "value" : "Decoding Error" } } } }, - "BLUETOOTH_ERROR_NOT_READABLE" : { + "Not Present" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "The characteristic you requested was not readable." + "value" : "Not Present" + } + } + } + }, + "The request characteristic was not present on the device." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The request characteristic was not present on the device." } } } diff --git a/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md b/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md index 12c3b211..4b77b9cd 100644 --- a/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md +++ b/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md @@ -10,40 +10,47 @@ # --> -Connect and communicate with Bluetooth devices. +Connect and communicate with Bluetooth devices using modern programming paradigms. ## Overview -The Spezi Bluetooth module provides a convenient way to handle state management with a Bluetooth device, retrieve data from different services and characteristics, and write data to a combination of services and characteristics. +The Spezi Bluetooth module provides a convenient way to handle state management with a Bluetooth device, +retrieve data from different services and characteristics, +and write data to a combination of services and characteristics. -> Tip: You will need a basic understanding of the Bluetooth Terminology and the underlying software model to understand the structure and API of the Spezi Bluetooth module. You can find a good overview in the [Wikipedia Bluetooth Low Energy (LE) Software Model section](https://en.wikipedia.org/wiki/Bluetooth_Low_Energy#Software_model) or the [Developer’s Guide -to Bluetooth Technology](https://www.bluetooth.com/blog/a-developers-guide-to-bluetooth/). +This package uses Apples [CoreBluetooth](https://developer.apple.com/documentation/corebluetooth) framework under the hood. + +> Tip: You will need a basic understanding of the Bluetooth Terminology and the underlying software model to understand + the structure and API of the Spezi Bluetooth module. You can find a good overview in the + [Wikipedia Bluetooth Low Energy (LE) Software Model section](https://en.wikipedia.org/wiki/Bluetooth_Low_Energy#Software_model) or the + [Developer’s Guide to Bluetooth Technology](https://www.bluetooth.com/blog/a-developers-guide-to-bluetooth/). ## Setup -### 1. Add Spezi Bluetooth as a Dependency +### Add Spezi Bluetooth as a Dependency You need to add the Spezi Bluetooth Swift package to [your app in Xcode](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app#) or [Swift package](https://developer.apple.com/documentation/xcode/creating-a-standalone-swift-package-with-xcode#Add-a-dependency-on-another-Swift-package). -> Important: If your application is not yet configured to use Spezi, follow the [Spezi setup article](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/initial-setup) to setup the core Spezi infrastructure. +> Important: If your application is not yet configured to use Spezi, follow the [Spezi setup article](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/initial-setup) to set up the core Spezi infrastructure. -### 2. Register the Module +### Register the Module -The [`Bluetooth`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth) module needs to be registered in a Spezi-based application using the +The ``Bluetooth`` module needs to be registered in a Spezi-based application using the [`configuration`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/speziappdelegate/configuration) in a [`SpeziAppDelegate`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/speziappdelegate): ```swift class ExampleAppDelegate: SpeziAppDelegate { override var configuration: Configuration { Configuration { - Bluetooth(services: [/* ... */]) - // ... + Bluetooth { + // discover devices ... + } } } } @@ -54,116 +61,126 @@ class ExampleAppDelegate: SpeziAppDelegate { ## Example -`MyDeviceModule` demonstrates the capabilities of the Spezi Bluetooth module. -This class integrates the ``Bluetooth`` module to create a `MyDevice` instance injected in the SwiftUI environment to send string messages over Bluetooth and collect them in a messages array. +### Create your Bluetooth device -> Tip: The type uses the Spezi dependency injection of the `Bluetooth` module, the most common usage of the ``Bluetooth`` module. [You can learn more about the Spezi dependency injection mechanisms in the Spezi documentation](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module-dependency). +The ``Bluetooth`` module allows to declarative define your Bluetooth device using a ``BluetoothDevice`` implementation and property wrappers +like ``Service`` and ``Characteristic``. -```swift -import Spezi -import SpeziBluetooth +The below code examples demonstrate how you can implement your own Bluetooth device. +First of all we define our Bluetooth service by implementing a ``BluetoothService``. +We use the ``Characteristic`` property wrapper to declare its characteristics. +Note that the value types needs to be optional and conform to ``ByteEncodable``, ``ByteDecodable`` or ``ByteCodable`` respectively. -public class MyDeviceModule: DefaultInitializable, Module { - /// Spezi dependency injection of the `Bluetooth` module; see https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module-dependency for more details. - @Dependency private var bluetooth: Bluetooth - /// Injecting the `MyDevice` class in the SwiftUI environment as documented at https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/interactions-with-swiftui - @Model private var myDevice: MyDevice - - - public required init() {} - - - /// Configuration method to create the `MyDevice` and pass in the Bluetooth module. - public func configure() { - self.myDevice = MyDevice(bluetooth: bluetooth) - } +```swift +class DeviceInformationService: BluetoothService { + @Characteristic(id: "2A29") + var manufacturer: String? + @Characteristic(id: "2A26") + var firmwareRevision: String? } ``` -The next step is to define the Bluetooth services and caracteristics that you want to read from or get notified about: +We can use this Bluetooth service now in the `MyDevice` implementation as follows. + +> Tip: We use the ``DeviceState`` and ``DeviceAction`` property wrappers to get access to the device state and its actions. Those two + property wrappers can also be used within a ``BluetoothService`` type. + ```swift -enum MyDeviceBluetoothConstants { - /// UUID for the example characteristic. - static let exampleCharacteristic = CBUUID(string: "a7779a75-f00a-05b4-147b-abf02f0d9b17") - /// Configuration for the example Bluetooth service. - static let exampleService = BluetoothService( - serviceUUID: CBUUID(string: "a7779a75-f00a-05b4-147b-abf02f0d9b17"), - characteristicUUIDs: [exampleCharacteristic] - ) +class MyDevice: BluetoothDevice { + @DeviceState(\.id) + var id: UUID + @DeviceState(\.name) + var name: String? + @DeviceState(\.state) + var state: PeripheralState + + @Service(id: "180A") + var deviceInformation = DeviceInformationService() + + @DeviceAction(\.connect) + var connect + @DeviceAction(\.disconnect) + var disconnect + + init() {} // required initializer } ``` -You will have to ensure that the ``Bluetooth`` module is correctly setup with the right services, e.g., as shown in the following example: +### Configure the Bluetooth Module + +We use the above `BluetoothDevice` implementation to configure the ``Bluetooth`` module within the +[SpeziAppDelegate](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/speziappdelegate). + ```swift -class ExampleAppDelegate: SpeziAppDelegate { +import Spezi + +class ExampleDelegate: SpeziAppDelegate { override var configuration: Configuration { Configuration { - Bluetooth(services: [MyDeviceBluetoothConstants.exampleService]) - // ... + Bluetooth { + // Define which devices type to discover by what criteria . + // In this case we search for some custom FFF0 characteristic that is advertised. + Discover(MyDevice.self, by: .advertisedService("FFF0")) + } } } } ``` -The `MyDevice` type showcases the interaction with the ``BluetoothService`` and the implementation of the ``BluetoothMessageHandler`` protocol. -It does all the message handling, and is responsible for parsing the information. +### Using the Bluetooth Module + +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). -> Tip: We highly recommend to use SwiftNIO [`ByteBuffer`](https://swiftpackageindex.com/apple/swift-nio/2.61.1/documentation/niocore/bytebuffer)s to parse more complex data coming in from the wire. You can learn more about creating a `ByteBuffer` from a Foundation `Data` instance using [NIOFoundationCompat](https://swiftpackageindex.com/apple/swift-nio/2.61.1/documentation/niofoundationcompat/niocore/bytebuffer). +You can use the ``SwiftUI/View/scanNearbyDevices(enabled:with:autoConnect:)`` and ``SwiftUI/View/autoConnect(enabled:with:)`` +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()``. + +To retrieve the list of nearby devices you may use ``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. + +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. ```swift -import Foundation -import Observation -import OSLog - - -@Observable -public class MyDevice: BluetoothMessageHandler { - /// Spezi dependency injection of the `Bluetooth` module; see https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module-dependency for more details. - private let bluetooth: Bluetooth - private let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "Example") - - /// Array of messages received from the Bluetooth connection. - private(set) public var messages: [String] = [] - - - /// The current Bluetooth connection state. - public var bluetoothState: BluetoothState { - bluetooth.state - } - - - required init(bluetooth: Bluetooth) { - self.bluetooth = bluetooth - bluetooth.add(messageHandler: self) - } - - - /// Sends a string message over Bluetooth. - /// - /// - Parameter information: The string message to be sent. - public func send(information: String) async throws { - try await bluetooth.write( - Data(information.utf8), - service: MyDeviceBluetoothConstants.exampleService.serviceUUID, - characteristic: MyDeviceBluetoothConstants.exampleCharacteristic - ) - } - - // Example implementation of the ``BluetoothMessageHandler`` requirements. - public func recieve(_ data: Data, service: CBUUID, characteristic: CBUUID) { - switch service { - case MyDeviceBluetoothConstants.exampleService.serviceUUID: - guard MyDeviceBluetoothConstants.exampleCharacteristic == characteristic else { - logger.debug("Unknown characteristic Id: \(MyDeviceBluetoothConstants.exampleCharacteristic)") - return +import SpeziBluetooth +import SwiftUI + +struct MyView: View { + @Environment(Bluetooth.self) + var bluetooth + @Environment(MyDevice.self) + var myDevice: MyDevice? + + var body: some View { + List { + if let myDevice { + Section { + Text("Device") + Spacer() + Text("\(myDevice.state.description)") + } + } + + Section { + ForEach(bluetooth.nearbyDevices(for: MyDevice.self), id: \.id) { device in + Text("\(device.name ?? "unknown")") + } + } header: { + HStack { + Text("Devices") + .padding(.trailing, 10) + if bluetooth.isScanning { + ProgressView() + } + } } - - // Convert the received data into a string and append it to the messages array. - messages.append(String(decoding: data, as: UTF8.self)) - default: - logger.debug("Unknown Service: \(service.uuidString)") } + .scanNearbyDevices(with: bluetooth, autoConnect: true) } } ``` @@ -171,14 +188,43 @@ public class MyDevice: BluetoothMessageHandler { ## Topics -### Establishing a Bluetooth Connection +### Configuring the Bluetooth Module - ``Bluetooth`` +- ``Discover`` +- ``DiscoveryCriteria`` + +### Discovering nearby devices + +- ``SwiftUI/View/scanNearbyDevices(enabled:with:autoConnect:)`` +- ``SwiftUI/View/autoConnect(enabled:with:)`` + +### Declaring a Bluetooth Device + +- ``BluetoothDevice`` - ``BluetoothService`` -- ``BluetoothMessageHandler`` +- ``Service`` +- ``Characteristic`` +- ``DeviceState`` +- ``DeviceAction`` + +### Coding +- ``ByteCodable`` +- ``ByteEncodable`` +- ``ByteDecodable`` -### State of a Bluetooth Connection +### Core Bluetooth +- ``BluetoothManager`` +- ``BluetoothPeripheral`` - ``BluetoothState`` +- ``PeripheralState`` - ``BluetoothError`` +- ``AdvertisementData`` + +### Configuring Core Bluetooth + +- ``DeviceDescription`` +- ``ServiceDescription`` +- ``CharacteristicDescription`` diff --git a/Sources/SpeziBluetooth/TestingSupport/Data+HexString.swift b/Sources/SpeziBluetooth/TestingSupport/Data+HexString.swift new file mode 100644 index 00000000..93da6759 --- /dev/null +++ b/Sources/SpeziBluetooth/TestingSupport/Data+HexString.swift @@ -0,0 +1,60 @@ +// +// 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) + } +} diff --git a/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift b/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift new file mode 100644 index 00000000..1c00ee1d --- /dev/null +++ b/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift @@ -0,0 +1,53 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +@Observable +class ConnectedDevices { + @MainActor private var connectedDevices: [ObjectIdentifier: BluetoothDevice] = [:] + @MainActor private var connectedDeviceIds: [ObjectIdentifier: UUID] = [:] + + var hasConnectedDevices = false + + + @MainActor + func update(with devices: [UUID: 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 + } + + hasConnectedDevices = !connectedDevices.isEmpty + } + + @MainActor + subscript(_ identifier: ObjectIdentifier) -> BluetoothDevice? { + connectedDevices[identifier] + } +} + + +extension BluetoothDevice { + fileprivate var typeIdentifier: ObjectIdentifier { + ObjectIdentifier(Self.self) + } +} diff --git a/Sources/SpeziBluetooth/Utils/Lazy.swift b/Sources/SpeziBluetooth/Utils/Lazy.swift new file mode 100644 index 00000000..d59894fe --- /dev/null +++ b/Sources/SpeziBluetooth/Utils/Lazy.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 +// + + +@propertyWrapper +class Lazy { + private let initializer: () -> Value + private let onCleanup: () -> Void + + private var storedValue: Value? + + + var wrappedValue: Value { + if let storedValue { + return storedValue + } + + let value = initializer() + storedValue = value + return value + } + + + /// Support lazy initialization of lazy property. + convenience init() { + self.init { + preconditionFailure("Forgot to initialize \(Self.self) lazy property!") + } + } + + + init(initializer: @escaping () -> Value, onCleanup: @escaping () -> Void = {}) { + self.initializer = initializer + self.onCleanup = onCleanup + } + + + func destroy() { + let wasStored = storedValue != nil + storedValue = nil + if wasStored { + onCleanup() + } + } +} diff --git a/Sources/XCTBluetooth/TestIdentity.swift b/Sources/XCTBluetooth/TestIdentity.swift new file mode 100644 index 00000000..4af402bb --- /dev/null +++ b/Sources/XCTBluetooth/TestIdentity.swift @@ -0,0 +1,53 @@ +// +// 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 NIO +import SpeziBluetooth +import XCTest + + +/// Tests the identity invariant of a `ByteCodable` implementation. +/// +/// This function encodes a provided value into its byte representation, then +/// decodes it back into the value and asserts its equality using `XCTAssertEqual`. +/// +/// - Parameter value: The value to encode and decode. +/// - Throws: Failed test. +public func testIdentity(from value: T) throws { + let data = value.encode() + + var decodingBuffer = ByteBuffer(data: data) + + let instance: T = try XCTUnwrap(T(from: &decodingBuffer)) + + XCTAssertEqual(instance, value) +} + + +/// Tests the identity invariant of a `ByteCodable` implementation. +/// +/// This function decodes the type from the provided byte representation, then +/// encodes it back into its byte representations and asserts its equality using `XCTAssertEqual`. +/// - Parameters: +/// - type: The type to test. +/// - data: The data representation to decode. +/// - Throws: Failed test. +public func testIdentity(of type: T.Type, from data: Data) throws { + var decodingBuffer = ByteBuffer(data: data) + + let instance: T = try XCTUnwrap(T(from: &decodingBuffer)) + + var encodingBuffer = ByteBuffer() + encodingBuffer.reserveCapacity(data.count) + + instance.encode(to: &encodingBuffer) + + let encodingData = Data(buffer: encodingBuffer) + XCTAssertEqual(encodingData, data) +} diff --git a/Tests/SpeziBluetoothTests/ByteCodableTests.swift b/Tests/SpeziBluetoothTests/ByteCodableTests.swift new file mode 100644 index 00000000..9965cd76 --- /dev/null +++ b/Tests/SpeziBluetoothTests/ByteCodableTests.swift @@ -0,0 +1,83 @@ +// +// 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 NIO +@testable @_spi(TestingSupport) import SpeziBluetooth // swiftlint:disable:this attributes +import XCTBluetooth +import XCTest + + +final class ByteCodableTests: XCTestCase { + func testData() throws { + let data = try XCTUnwrap(Data(hex: "0xAABBCCDDEE")) + + try testIdentity(of: Data.self, from: data) + } + + func testBoolean() throws { + let trueData = try XCTUnwrap(Data(hex: "0x01")) + try testIdentity(of: Bool.self, from: trueData) + + let falseData = try XCTUnwrap(Data(hex: "0x00")) + try testIdentity(of: Bool.self, from: falseData) + + var empty = ByteBuffer() + XCTAssertNil(Bool(from: &empty)) + } + + func testString() throws { + let data = try XCTUnwrap("Hello World".data(using: .utf8)) + try testIdentity(of: String.self, from: data) + + var empty = ByteBuffer() + XCTAssertEqual(String(from: &empty), "") + } + + func testInt8() throws { + try testIdentity(from: Int8.max) + try testIdentity(from: Int8.min) + } + + func testInt16() throws { + try testIdentity(from: Int16.max) + try testIdentity(from: Int16.min) + } + + func testInt32() throws { + try testIdentity(from: Int32.max) + try testIdentity(from: Int32.min) + } + + func testInt64() throws { + try testIdentity(from: Int64.max) + try testIdentity(from: Int64.min) + } + + func testUInt8() throws { + try testIdentity(from: UInt8.max) + try testIdentity(from: UInt8.min) + + var empty = ByteBuffer() + XCTAssertNil(UInt8(from: &empty)) + } + + func testUInt16() throws { + try testIdentity(from: UInt16.max) + try testIdentity(from: UInt16.min) + } + + func testUInt32() throws { + try testIdentity(from: UInt32.max) + try testIdentity(from: UInt32.min) + } + + func testUInt64() throws { + try testIdentity(from: UInt64.max) + try testIdentity(from: UInt64.min) + } +} diff --git a/Tests/UITests/TestApp/BluetoothManagerView.swift b/Tests/UITests/TestApp/BluetoothManagerView.swift new file mode 100644 index 00000000..366c82aa --- /dev/null +++ b/Tests/UITests/TestApp/BluetoothManagerView.swift @@ -0,0 +1,42 @@ +// +// 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 + + +struct BluetoothManagerView: View { + @State private var bluetooth = BluetoothManager(devices: []) // discovery any devices! + + var body: some View { + List { + BluetoothStateSection(scanner: bluetooth) + + if bluetooth.nearbyPeripheralsView.isEmpty { + SearchingNearbyDevicesView() + } else { + Section { + ForEach(bluetooth.nearbyPeripheralsView) { peripheral in + DeviceRowView(peripheral: peripheral) + } + } header: { + DevicesHeader(loading: bluetooth.isScanning) + } + } + } + .scanNearbyDevices(with: bluetooth) + .navigationTitle("Nearby Devices") + } +} + + +#Preview { + NavigationStack { + BluetoothManagerView() + } +} diff --git a/Tests/UITests/TestApp/BluetoothModuleView.swift b/Tests/UITests/TestApp/BluetoothModuleView.swift new file mode 100644 index 00000000..68b0271c --- /dev/null +++ b/Tests/UITests/TestApp/BluetoothModuleView.swift @@ -0,0 +1,60 @@ +// +// 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 + + +struct BluetoothModuleView: View { + @Environment(Bluetooth.self) + private var bluetooth + @Environment(TestDevice.self) + private var device: TestDevice? + + var body: some View { + List { + BluetoothStateSection(scanner: bluetooth) + + let nearbyDevices = bluetooth.nearbyDevices(for: TestDevice.self) + + if nearbyDevices.isEmpty { + SearchingNearbyDevicesView() + } else { + Section { + ForEach(nearbyDevices) { device in + DeviceRowView(peripheral: device) + } + } header: { + DevicesHeader(loading: bluetooth.isScanning) + } + } + + if let device { + Section { + Text("Device State: \(device.state.description)") + Text("RSSI: \(device.rssi)") + } + } + } + .scanNearbyDevices(with: bluetooth, autoConnect: true) + .navigationTitle("Auto Connect Device") + } +} + + +#Preview { + NavigationStack { + BluetoothManagerView() + .previewWith { + Bluetooth { + Discover(TestDevice.self, by: .advertisedService("FFF0")) + } + } + } +} diff --git a/Tests/UITests/TestApp/Info.plist b/Tests/UITests/TestApp/Info.plist new file mode 100644 index 00000000..0c67376e --- /dev/null +++ b/Tests/UITests/TestApp/Info.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/Tests/UITests/TestApp/Info.plist.license b/Tests/UITests/TestApp/Info.plist.license new file mode 100644 index 00000000..28f53d0d --- /dev/null +++ b/Tests/UITests/TestApp/Info.plist.license @@ -0,0 +1,5 @@ +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/Tests/UITests/TestApp/TestApp.swift b/Tests/UITests/TestApp/TestApp.swift index fd080a7d..7124c9a1 100644 --- a/Tests/UITests/TestApp/TestApp.swift +++ b/Tests/UITests/TestApp/TestApp.swift @@ -7,18 +7,28 @@ // import Spezi +import SpeziBluetooth import SwiftUI @main struct UITestsApp: App { - @UIApplicationDelegateAdaptor(TestAppDelegate.self) var appDelegate + @UIApplicationDelegateAdaptor(TestAppDelegate.self) + var appDelegate var body: some Scene { WindowGroup { NavigationStack { - Text("Spezi Bluetooth") + List { + NavigationLink("Nearby Devices") { + BluetoothManagerView() + } + NavigationLink("Auto Connect Device") { + BluetoothModuleView() + } + } + .navigationTitle("Spezi Bluetooth") } .spezi(appDelegate) } diff --git a/Tests/UITests/TestApp/TestAppDelegate.swift b/Tests/UITests/TestApp/TestAppDelegate.swift index d1746d40..5042114b 100644 --- a/Tests/UITests/TestApp/TestAppDelegate.swift +++ b/Tests/UITests/TestApp/TestAppDelegate.swift @@ -14,7 +14,9 @@ import SwiftUI class TestAppDelegate: SpeziAppDelegate { override var configuration: Configuration { Configuration { - Bluetooth() + Bluetooth { + Discover(TestDevice.self, by: .advertisedService("FFF0")) + } } } } diff --git a/Tests/UITests/TestApp/TestDevice.swift b/Tests/UITests/TestApp/TestDevice.swift new file mode 100644 index 00000000..7522e654 --- /dev/null +++ b/Tests/UITests/TestApp/TestDevice.swift @@ -0,0 +1,52 @@ +// +// 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 Foundation +import SpeziBluetooth + + +protocol SomePeripheral { + var id: UUID { get } + var name: String? { get } + var state: PeripheralState { get } + var rssi: Int { get } + + func connect() async + func disconnect() async +} + + +class TestDevice: BluetoothDevice, Identifiable, SomePeripheral { + @DeviceState(\.id) + var id + @DeviceState(\.name) + var name + @DeviceState(\.state) + var state + @DeviceState(\.rssi) + var rssi + + @DeviceAction(\.connect) + var connect + @DeviceAction(\.disconnect) + var disconnect + + required init() {} + + + func connect() async { + await self.connect() + } + + func disconnect() async { + await self.disconnect() + } +} + + +extension BluetoothPeripheral: SomePeripheral {} diff --git a/Tests/UITests/TestApp/Utiltities/BluetoothStateSection.swift b/Tests/UITests/TestApp/Utiltities/BluetoothStateSection.swift new file mode 100644 index 00000000..a55254e3 --- /dev/null +++ b/Tests/UITests/TestApp/Utiltities/BluetoothStateSection.swift @@ -0,0 +1,39 @@ +// +// 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 + + +struct BluetoothStateSection: View { + private let scanner: BluetoothScanner + + var body: some View { + Section("State") { + HStack { + Text("Scanning") + Spacer() + Text(scanner.isScanning ? "Yes" : "No") + .foregroundColor(.secondary) + } + .accessibilityElement(children: .combine) + HStack { + Text("State") + Spacer() + Text(scanner.state.description) + .foregroundColor(.secondary) + } + .accessibilityElement(children: .combine) + } + } + + + init(scanner: Scanner) { + self.scanner = scanner + } +} diff --git a/Tests/UITests/TestApp/Utiltities/DeviceRowView.swift b/Tests/UITests/TestApp/Utiltities/DeviceRowView.swift new file mode 100644 index 00000000..73762b00 --- /dev/null +++ b/Tests/UITests/TestApp/Utiltities/DeviceRowView.swift @@ -0,0 +1,59 @@ +// +// 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 + + +struct DeviceRowView: View { + private let peripheral: Peripheral + + var body: some View { + Button(action: peripheralAction) { + VStack { + HStack { + if let name = peripheral.name { + Text("\(name)") + } else { + Text("unknown") + .italic() + } + Spacer() + Text("\(peripheral.rssi) dB") + .foregroundColor(.secondary) + } + .foregroundColor(.primary) + HStack { + Text(peripheral.id.uuidString) + Spacer() + Text("\(peripheral.state.description)") + } + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + + init(peripheral: Peripheral) { + self.peripheral = peripheral + } + + + @MainActor + func peripheralAction() { + let state = peripheral.state + Task { + switch state { + case .disconnected, .disconnecting: + await self.peripheral.connect() + case .connecting, .connected: + await self.peripheral.disconnect() + } + } + } +} diff --git a/Tests/UITests/TestApp/Utiltities/DevicesHeader.swift b/Tests/UITests/TestApp/Utiltities/DevicesHeader.swift new file mode 100644 index 00000000..59208f2c --- /dev/null +++ b/Tests/UITests/TestApp/Utiltities/DevicesHeader.swift @@ -0,0 +1,30 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +struct DevicesHeader: View { + private let loading: Bool + + + var body: some View { + HStack { + Text("Devices") + .padding(.trailing, 10) + if loading { + ProgressView() + } + } + } + + + init(loading: Bool) { + self.loading = loading + } +} diff --git a/Tests/UITests/TestApp/Utiltities/SearchingNearbyDevicesView.swift b/Tests/UITests/TestApp/Utiltities/SearchingNearbyDevicesView.swift new file mode 100644 index 00000000..7f5b9402 --- /dev/null +++ b/Tests/UITests/TestApp/Utiltities/SearchingNearbyDevicesView.swift @@ -0,0 +1,22 @@ +// +// 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 + + +struct SearchingNearbyDevicesView: View { + var body: some View { + VStack { + Text("Searching for nearby devices ...") + .foregroundColor(.secondary) + ProgressView() + } + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + } +} diff --git a/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift b/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift index 535701e5..31d22ac0 100644 --- a/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift +++ b/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift @@ -16,5 +16,31 @@ final class SpeziBluetoothTests: XCTestCase { app.launch() XCTAssert(app.staticTexts["Spezi Bluetooth"].waitForExistence(timeout: 2)) + + XCTAssert(app.buttons["Nearby Devices"].exists) + XCTAssert(app.buttons["Auto Connect Device"].exists) + + app.buttons["Nearby Devices"].tap() + + XCTAssert(app.navigationBars.staticTexts["Nearby Devices"].waitForExistence(timeout: 2.0)) + assertMinimalSimulatorInformation(app) + + XCTAssert(app.navigationBars.buttons["Spezi Bluetooth"].exists) + app.navigationBars.buttons["Spezi Bluetooth"].tap() + + XCTAssert(app.buttons["Auto Connect Device"].waitForExistence(timeout: 2.0)) + app.buttons["Auto Connect Device"].tap() + + XCTAssert(app.navigationBars.staticTexts["Auto Connect Device"].waitForExistence(timeout: 2.0)) + assertMinimalSimulatorInformation(app) + + XCTAssert(app.navigationBars.buttons["Spezi Bluetooth"].exists) + app.navigationBars.buttons["Spezi Bluetooth"].tap() + } + + private func assertMinimalSimulatorInformation(_ app: XCUIApplication) { + XCTAssert(app.staticTexts["Scanning, No"].exists) + XCTAssert(app.staticTexts["State, unsupported"].exists) + XCTAssert(app.staticTexts["Searching for nearby devices ..."].exists) } } diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index fefb1997..b717856c 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -10,10 +10,16 @@ 2F64EA852A86B347006789D0 /* TestAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F64EA812A86B346006789D0 /* TestAppDelegate.swift */; }; 2F64EA882A86B36C006789D0 /* TestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F64EA872A86B36C006789D0 /* TestApp.swift */; }; 2F64EA8B2A86B3DE006789D0 /* XCTestExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 2F64EA8A2A86B3DE006789D0 /* XCTestExtensions */; }; - 2F64EA8E2A86B46B006789D0 /* Spezi in Frameworks */ = {isa = PBXBuildFile; productRef = 2F64EA8D2A86B46B006789D0 /* Spezi */; }; - 2F68C3C8292EA52000B3E12C /* SpeziBluetooth in Frameworks */ = {isa = PBXBuildFile; productRef = 2F68C3C7292EA52000B3E12C /* SpeziBluetooth */; }; 2F6D139A28F5F386007C25D6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2F6D139928F5F386007C25D6 /* Assets.xcassets */; }; 2FA43E922AE057CA009B1B2C /* SpeziBluetoothTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA43E912AE057CA009B1B2C /* SpeziBluetoothTests.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 */; }; + A95542B42B5E3E210066646D /* BluetoothModuleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95542B32B5E3E210066646D /* BluetoothModuleView.swift */; }; + A95542B62B5E3EAC0066646D /* BluetoothStateSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95542B52B5E3EAC0066646D /* BluetoothStateSection.swift */; }; + A95542B92B5E3F490066646D /* SearchingNearbyDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95542B82B5E3F490066646D /* SearchingNearbyDevicesView.swift */; }; + A95542BB2B5E3F7B0066646D /* DevicesHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95542BA2B5E3F7B0066646D /* DevicesHeader.swift */; }; + A95542BD2B5E40DF0066646D /* TestDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95542BC2B5E40DF0066646D /* TestDevice.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -48,6 +54,14 @@ 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TestAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 2FA43E912AE057CA009B1B2C /* SpeziBluetoothTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SpeziBluetoothTests.swift; sourceTree = ""; }; 2FB0758A299DDB9000C0B37F /* TestApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestApp.xctestplan; 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 = ""; }; + A95542B32B5E3E210066646D /* BluetoothModuleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothModuleView.swift; sourceTree = ""; }; + A95542B52B5E3EAC0066646D /* BluetoothStateSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothStateSection.swift; sourceTree = ""; }; + A95542B82B5E3F490066646D /* SearchingNearbyDevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchingNearbyDevicesView.swift; sourceTree = ""; }; + A95542BA2B5E3F7B0066646D /* DevicesHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesHeader.swift; sourceTree = ""; }; + A95542BC2B5E40DF0066646D /* TestDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestDevice.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -55,8 +69,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 2F64EA8E2A86B46B006789D0 /* Spezi in Frameworks */, - 2F68C3C8292EA52000B3E12C /* SpeziBluetooth in Frameworks */, + A92802B72B5081F200874D0A /* SpeziBluetooth in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -95,9 +108,14 @@ 2F6D139428F5F384007C25D6 /* TestApp */ = { isa = PBXGroup; children = ( + A92802BA2B5085BE00874D0A /* Info.plist */, + A92802B82B50823600874D0A /* BluetoothManagerView.swift */, + A95542B32B5E3E210066646D /* BluetoothModuleView.swift */, 2F64EA872A86B36C006789D0 /* TestApp.swift */, 2F64EA812A86B346006789D0 /* TestAppDelegate.swift */, 2F6D139928F5F386007C25D6 /* Assets.xcassets */, + A95542B72B5E3F260066646D /* Utiltities */, + A95542BC2B5E40DF0066646D /* TestDevice.swift */, ); path = TestApp; sourceTree = ""; @@ -117,6 +135,17 @@ name = Frameworks; sourceTree = ""; }; + A95542B72B5E3F260066646D /* Utiltities */ = { + isa = PBXGroup; + children = ( + A95542B52B5E3EAC0066646D /* BluetoothStateSection.swift */, + A92802BC2B51CBBE00874D0A /* DeviceRowView.swift */, + A95542BA2B5E3F7B0066646D /* DevicesHeader.swift */, + A95542B82B5E3F490066646D /* SearchingNearbyDevicesView.swift */, + ); + path = Utiltities; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -135,8 +164,7 @@ ); name = TestApp; packageProductDependencies = ( - 2F68C3C7292EA52000B3E12C /* SpeziBluetooth */, - 2F64EA8D2A86B46B006789D0 /* Spezi */, + A92802B62B5081F200874D0A /* SpeziBluetooth */, ); productName = Example; productReference = 2F6D139228F5F384007C25D6 /* TestApp.app */; @@ -193,7 +221,6 @@ mainGroup = 2F6D138928F5F384007C25D6; packageReferences = ( 2F64EA892A86B3DE006789D0 /* XCRemoteSwiftPackageReference "XCTestExtensions" */, - 2F64EA8C2A86B46B006789D0 /* XCRemoteSwiftPackageReference "Spezi" */, ); productRefGroup = 2F6D139328F5F384007C25D6 /* Products */; projectDirPath = ""; @@ -228,8 +255,15 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A95542B92B5E3F490066646D /* SearchingNearbyDevicesView.swift in Sources */, 2F64EA852A86B347006789D0 /* TestAppDelegate.swift in Sources */, + A95542BB2B5E3F7B0066646D /* DevicesHeader.swift in Sources */, + A95542BD2B5E40DF0066646D /* TestDevice.swift in Sources */, + A92802BD2B51CBBE00874D0A /* DeviceRowView.swift in Sources */, + A92802B92B50823600874D0A /* BluetoothManagerView.swift in Sources */, 2F64EA882A86B36C006789D0 /* TestApp.swift in Sources */, + A95542B62B5E3EAC0066646D /* BluetoothStateSection.swift in Sources */, + A95542B42B5E3E210066646D /* BluetoothModuleView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -377,10 +411,12 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 637867499T; + DEVELOPMENT_TEAM = 484YT3X9X7; ENABLE_PREVIEWS = YES; ENABLE_TESTING_SEARCH_PATHS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TestApp/Info.plist; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "This App uses Bluetooth to connect to nearby devices."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -396,7 +432,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; @@ -414,10 +450,12 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 637867499T; + DEVELOPMENT_TEAM = 484YT3X9X7; ENABLE_PREVIEWS = YES; ENABLE_TESTING_SEARCH_PATHS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TestApp/Info.plist; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "This App uses Bluetooth to connect to nearby devices."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -433,7 +471,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; @@ -560,10 +598,12 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 637867499T; + DEVELOPMENT_TEAM = 484YT3X9X7; ENABLE_PREVIEWS = YES; ENABLE_TESTING_SEARCH_PATHS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TestApp/Info.plist; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "This App uses Bluetooth to connect to nearby devices."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -579,7 +619,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; @@ -623,7 +663,7 @@ 2F6D13B528F5F386007C25D6 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Debug; }; 2F6D13B628F5F386007C25D6 /* Build configuration list for PBXNativeTarget "TestApp" */ = { isa = XCConfigurationList; @@ -633,7 +673,7 @@ 2F6D13B828F5F386007C25D6 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Debug; }; 2F6D13BC28F5F386007C25D6 /* Build configuration list for PBXNativeTarget "TestAppUITests" */ = { isa = XCConfigurationList; @@ -643,7 +683,7 @@ 2F6D13BE28F5F386007C25D6 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Debug; }; /* End XCConfigurationList section */ @@ -656,14 +696,6 @@ minimumVersion = 0.4.6; }; }; - 2F64EA8C2A86B46B006789D0 /* XCRemoteSwiftPackageReference "Spezi" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/StanfordSpezi/Spezi.git"; - requirement = { - kind = upToNextMinorVersion; - minimumVersion = 0.8.0; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -672,12 +704,7 @@ package = 2F64EA892A86B3DE006789D0 /* XCRemoteSwiftPackageReference "XCTestExtensions" */; productName = XCTestExtensions; }; - 2F64EA8D2A86B46B006789D0 /* Spezi */ = { - isa = XCSwiftPackageProductDependency; - package = 2F64EA8C2A86B46B006789D0 /* XCRemoteSwiftPackageReference "Spezi" */; - productName = Spezi; - }; - 2F68C3C7292EA52000B3E12C /* SpeziBluetooth */ = { + A92802B62B5081F200874D0A /* SpeziBluetooth */ = { isa = XCSwiftPackageProductDependency; productName = SpeziBluetooth; };