Skip to content

Commit

Permalink
Support retrieving known Peripherals (#38)
Browse files Browse the repository at this point in the history
# Support retrieving Peripherals

## ♻️ Current situation & Problem

SpeziBluetooth is currently optimized for device discovery and ad-hoc
connection establishment. However, when dealing with device pairing, one
typically scans for nearby devices once and saving the peripheral
identifier and perform long running connection attempts (e.g., calling
[`retrievePeripherals(withidentifiers:)`](https://developer.apple.com/documentation/corebluetooth/cbcentralmanager/retrieveperipherals(withidentifiers:))
and calling connect()). Connecting a peripheral does not time out and is
therefore the most efficient way of establishing Bluetooth connecting
with a set of known Bluetooth devices.
To support this use case, some modifications have been made to the
underlying SpeziBluetooth infrastructure. New mechanisms were introduced
to retrieve known peripherals
(`BluetoothManager/retrievePeripheral(for:with:)` and
`Bluetooth/retrieveDevice(for:as:)`). These device instances can stay
allocated and SpeziBluetooth automatically frees resources once the
framework user deallocates the peripheral instances. Several changes to
the lifecycle handling of `BluetoothPeripheral`s and `BluetoothDevice`s
have been made to support this new interfaces (e.g., carefully managing
when objects are kept as strong references and when to reuse objects
when, e.g., the same instance is getting discovered at the same time).

### SpeziDevices

SpeziDevices is an upcoming library to encapsulates a lot of
standardized device interactions. This PR is driven by a lot of
requirements of this library.

## :gear: Release Notes 

* Support retrieving known peripherals using
`BluetoothManager/retrievePeripheral(for:with:)` and
`Bluetooth/retrieveDevice(for:as:)`.
* The `BluetoothViews` target was removed and integrated into the
`SpeziDevicesUI` of the SpezIDevices framework (see
StanfordSpezi/SpeziDevices#1, **Breaking**).
* The `BluetoothServices` target was renamed to `SpeziBluetoothServices`
for more consistency (**Breaking**).
* Add `accessory` discovery criteria.
* New `nearby` and `lastActivity` state properties for peripherals and
devices.
* The peripheral name is now preferred with the `name` property and the
`localName` property can now be accessed individually on a peripheral
(**Breaking**).
* Internally restructure discovery state into an `DiscoverySession` for
better code overview.
* Configuration options (like minimumRSSI and
advertisementStaleInterval) are now passed directly to the
`scanNearbyDevices(...)` methods and modifiers allowing for more dynamic
configuration.
* Adding `Bluetooth/stateSubscription` and
`BluetoothManager/stateSubscription` to receive an AsyncStream of
`BluetoothState` changes useful when required to observe CBCentral
changes.
* Adding `CharacteristicAccessor/subscription` and
`DeviceStateAccessor/subscription` properties to receive an AsyncStream
of changes to the characteristic value or device state.
* `onChange(initial:perform:)` methods now must not be set up in the
initializer anymore and must not create strong self references. Instead
setup onChange handlers in the `configure()` method and make sure to
weakly capture `self`. Calling `onChange(initial:perform:)` method in
the initializer results in a runtime crash with the error message
providing a migration guide.
* New `powerOn()` and `powerOff()` methods to manually control
`CBCentralManager` allocation.
* Fixed an issue where characteristic access continuations where
accidentally leaked.
* Add new `ManufacturerIdentifier` model supporting parsing of
manufacturer data in the advertisement.
* Added `ConnectedDevices` environment model to retrieve all connected
devices from the SwiftUI environment.
* Add `Codable` conformance to all Characteristics.

This PR contains breaking changes, requiring a major version bump.

## :books: Documentation
Documentation was updated to reflect changed symbols (README currently
points to 404 pages). Retrieving devices was documented with a small
code examples.


## :white_check_mark: Testing
Existing tests verify that refactoring did not break existing
infrastructure. Additional test case was added to test retrieving
peripherals and ensuring the new reference semantics do not break and
peripherals are properly and expectedly deallocated.


## :pencil: Code of Conduct & Contributing Guidelines 

By submitting creating this pull request, you agree to follow our [Code
of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md):
- [x] I agree to follow the [Code of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md).
  • Loading branch information
Supereg authored Jun 27, 2024
1 parent 3ae32bf commit fd6a51c
Show file tree
Hide file tree
Showing 135 changed files with 3,270 additions and 2,063 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:
path: 'Tests/UITests'
artifactname: TestApp-macOS.xcresult
resultBundle: TestApp-macOS.xcresult
customcommand: "set -o pipefail && xcodebuild test -scheme 'TestApp' -configuration 'Test' -destination 'platform=macOS,arch=arm64,variant=Mac Catalyst' -derivedDataPath '.derivedData' -resultBundlePath 'TestApp-macOS.xcresult' | xcpretty"
customcommand: "set -o pipefail && xcodebuild test -scheme 'TestApp' -configuration 'Test' -destination 'platform=macOS,arch=arm64,variant=Mac Catalyst' -derivedDataPath '.derivedData' -resultBundlePath 'TestApp-macOS.xcresult' -skipPackagePluginValidation -skipMacroValidation | xcpretty"
secrets: inherit
uploadcoveragereport:
name: Upload Coverage Report
Expand Down
81 changes: 58 additions & 23 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,17 @@
// SPDX-License-Identifier: MIT
//

import class Foundation.ProcessInfo
import PackageDescription


#if swift(<6)
let swiftConcurrency: SwiftSetting = .enableExperimentalFeature("SwiftConcurrency")
#else
let swiftConcurrency: SwiftSetting = .enableUpcomingFeature("SwiftConcurrency")
#endif


let package = Package(
name: "SpeziBluetooth",
defaultLocalization: "en",
Expand All @@ -20,18 +28,17 @@ let package = Package(
.macOS(.v14)
],
products: [
.library(name: "BluetoothServices", targets: ["BluetoothServices"]),
.library(name: "BluetoothViews", targets: ["BluetoothViews"]),
.library(name: "SpeziBluetoothServices", targets: ["SpeziBluetoothServices"]),
.library(name: "SpeziBluetooth", targets: ["SpeziBluetooth"])
],
dependencies: [
.package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "1.0.4"),
.package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.3.0"),
.package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.0.1"),
.package(url: "https://github.com/StanfordSpezi/SpeziViews", from: "1.3.0"),
.package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "1.1.0"),
.package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.4.0"),
.package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.1.0"),
.package(url: "https://github.com/apple/swift-nio.git", from: "2.59.0"),
.package(url: "https://github.com/apple/swift-collections.git", from: "1.0.4")
],
.package(url: "https://github.com/apple/swift-collections.git", from: "1.0.4"),
.package(url: "https://github.com/StanfordBDHG/XCTestExtensions.git", from: "0.4.11")
] + swiftLintPackage(),
targets: [
.target(
name: "SpeziBluetooth",
Expand All @@ -44,39 +51,67 @@ let package = Package(
],
resources: [
.process("Resources")
]
],
swiftSettings: [
swiftConcurrency
],
plugins: [] + swiftLintPlugin()
),
.target(
name: "BluetoothServices",
name: "SpeziBluetoothServices",
dependencies: [
.target(name: "SpeziBluetooth"),
.product(name: "ByteCoding", package: "SpeziNetworking"),
.product(name: "SpeziNumerics", package: "SpeziNetworking")
]
),
.target(
name: "BluetoothViews",
dependencies: [
.target(name: "SpeziBluetooth"),
.product(name: "SpeziViews", package: "SpeziViews")
]
],
swiftSettings: [
swiftConcurrency
],
plugins: [] + swiftLintPlugin()
),
.executableTarget(
name: "TestPeripheral",
dependencies: [
.target(name: "SpeziBluetooth"),
.target(name: "BluetoothServices"),
.target(name: "SpeziBluetoothServices"),
.product(name: "ByteCoding", package: "SpeziNetworking")
]
],
swiftSettings: [
swiftConcurrency
],
plugins: [] + swiftLintPlugin()
),
.testTarget(
name: "BluetoothServicesTests",
dependencies: [
.target(name: "BluetoothServices"),
.target(name: "SpeziBluetoothServices"),
.target(name: "SpeziBluetooth"),
.product(name: "XCTByteCoding", package: "SpeziNetworking"),
.product(name: "NIO", package: "swift-nio")
]
.product(name: "NIO", package: "swift-nio"),
.product(name: "XCTestExtensions", package: "XCTestExtensions")
],
swiftSettings: [
swiftConcurrency
],
plugins: [] + swiftLintPlugin()
)
]
)


func swiftLintPlugin() -> [Target.PluginUsage] {
// Fully quit Xcode and open again with `open --env SPEZI_DEVELOPMENT_SWIFTLINT /Applications/Xcode.app`
if ProcessInfo.processInfo.environment["SPEZI_DEVELOPMENT_SWIFTLINT"] != nil {
[.plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLint")]
} else {
[]
}
}

func swiftLintPackage() -> [PackageDescription.Package.Dependency] {
if ProcessInfo.processInfo.environment["SPEZI_DEVELOPMENT_SWIFTLINT"] != nil {
[.package(url: "https://github.com/realm/SwiftLint.git", .upToNextMinor(from: "0.55.1"))]
} else {
[]
}
}
37 changes: 31 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ class DeviceInformationService: BluetoothService {

We can use this Bluetooth service now in the `MyDevice` implementation as follows.

> Tip: We use the [`DeviceState`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/devicestate) and [`DeviceAction`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/deviceaction) property wrappers to get access to the device state and its actions. Those two
> [!TIP]
> We use the [`DeviceState`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/devicestate) and [`DeviceAction`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/deviceaction) property wrappers to get access to the device state and its actions. Those two
property wrappers can also be used within a [`BluetoothService`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetoothservice) type.

```swift
Expand Down Expand Up @@ -145,15 +146,17 @@ class ExampleDelegate: SpeziAppDelegate {
Once you have the `Bluetooth` module configured within your Spezi app, you can access the module within your
[`Environment`](https://developer.apple.com/documentation/swiftui/environment).

You can use the [`scanNearbyDevices(enabled:with:autoConnect:)`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/swiftui/view/scanNearbyDevices(enabled:with:autoConnect:)) and [`autoConnect(enabled:with:)`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/swiftui/view/autoConnect(enabled:with:))
You can use the [`scanNearbyDevices(enabled:with:minimumRSSI:advertisementStaleInterval:autoConnect:)`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/swiftui/view/scanNearbyDevices(enabled:with:minimumRSSI:advertisementStaleInterval:autoConnect:))
and [`autoConnect(enabled:with:minimumRSSI:advertisementStaleInterval:)`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/swiftui/view/autoConnect(enabled:with:minimumRSSI:advertisementStaleInterval:))
modifiers to scan for nearby devices and/or auto connect to the first available device. Otherwise, you can also manually start and stop scanning for nearby devices
using [`scanNearbyDevices(autoConnect:)`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth/scanNearbyDevices(autoConnect:)) and [`stopScanning()`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth/stopScanning()).
using [`scanNearbyDevices(minimumRSSI:advertisementStaleInterval:autoConnect:)`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth/scanNearbyDevices(minimumRSSI:advertisementStaleInterval:autoConnect:)) and [`stopScanning()`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth/stopScanning()).

To retrieve the list of nearby devices you may use [`nearbyDevices(for:)`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth/nearbyDevices(for:)).

> Tip: To easily access the first connected device, you can just query the SwiftUI Environment for your `BluetoothDevice` type.
Make sure to declare the property as optional using the respective [`Environment(_:)`](https://developer.apple.com/documentation/swiftui/environment/init(_:)-8slkf)
initializer.
> [!TIP]
> To easily access the first connected device, you can just query the SwiftUI Environment for your `BluetoothDevice` type.
Make sure to declare the property as optional using the respective [`Environment(_:)`](https://developer.apple.com/documentation/swiftui/environment/init(_:)-8slkf)
initializer.

The below code example demonstrates all these steps of retrieving the `Bluetooth` module from the environment, listing all nearby devices,
auto connecting to the first one and displaying some basic information of the currently connected device.
Expand Down Expand Up @@ -197,6 +200,28 @@ struct MyView: View {
}
```

> [!TIP]
> Use [`ConnectedDevices`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/ConnectedDevices) to retrieve the full list of connected devices from the SwiftUI environment.
#### Retrieving Devices

The previous section explained how to discover nearby devices and retrieve the currently connected one from the environment.
This is great ad-hoc connection establishment with devices currently nearby.
However, this might not be the most efficient approach, if you want to connect to a specific, previously paired device.
In these situations you can use the [`retrieveDevice(for:as:)`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth/retrieveDevice(for:as:)) method to retrieve a known device.

Below is a short code example illustrating this method.

```swift
let id: UUID = ... // a Bluetooth peripheral identifier (e.g., previously retrieved when pairing the device)

let device = bluetooth.retrieveDevice(for: id, as: MyDevice.self)

await device.connect() // assume declaration of @DeviceAction(\.connect)

// Connect doesn't time out. Connection with the device will be established as soon as the device is in reach.
```

### Integration with Spezi Modules

A Spezi [`Module`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module) is a great way of structuring your application into
Expand Down
55 changes: 0 additions & 55 deletions Sources/BluetoothServices/Characteristics/TemperatureType.swift

This file was deleted.

129 changes: 0 additions & 129 deletions Sources/BluetoothViews/BluetoothStateHint.swift

This file was deleted.

Loading

0 comments on commit fd6a51c

Please sign in to comment.