Skip to content

Commit c5a9f4e

Browse files
authored
Merge pull request #3 from exPHAT/v1.0.0
v1.0.0
2 parents 69af89d + 3718d0f commit c5a9f4e

28 files changed

+1383
-423
lines changed

README.md

Lines changed: 58 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -17,79 +17,40 @@ Easily interface with Bluetooth peripherals in new or existing projects through
1717
- [x] Thread safe
1818
- [x] Zero inherited dependencies
1919
- [x] Tested with included `SwiftBluetoothMock` library
20-
- [ ] SwiftUI support
2120

2221
## Examples
2322

2423
[API Documentation.](https://swiftpackageindex.com/exPHAT/SwiftBluetooth/1.0.0/documentation/)
2524

25+
#### Complete example
2626

27-
#### Migrate existing CoreBluetooth project
27+
Async API's make the entire Bluetooth lifecycle much simpler, using method names you're already familiar with from CoreBluetooth.
2828

2929
```swift
30-
import CoreBluetooth
31-
import SwiftBluetooth // Add this
32-
33-
// Override existing CoreBluetooth classes to use SwiftBluetooth
34-
typealias CBCentralManager = SwiftBluetooth.CentralManager
35-
typealias CBCentralManagerDelegate = SwiftBluetooth.CentralManagerDelegate
36-
typealias CBPeripheral = SwiftBluetooth.Peripheral
37-
typealias CBPeripheralDelegate = SwiftBluetooth.PeripheralDelegate
38-
39-
// Your existing code should continue to work as normal.
40-
// But now you have access to all the new API's!
41-
```
42-
43-
#### Stream discovered peripherals
44-
45-
```swift
46-
let central = CentralManager()
47-
await central.waitUntilReady()
48-
49-
for await peripheral in await central.scanForPeripherals() {
50-
print("Discovered:", peripheral.name ?? "Unknown")
51-
}
52-
```
53-
54-
#### Define characteristics
55-
56-
```swift
57-
// Define your characteristic UUID's as static members of the `Characteristic` type
58-
extension Characteristic {
59-
static let someCharacteristic = Self("00000000-0000-0000-0000-000000000000")
60-
}
30+
import SwiftBluetooth
6131

62-
// Use those characteristics later on your peripheral
63-
await myPeripheral.readValue(for: .someCharacteristic)
64-
```
65-
66-
#### Discover, connect, and read characteristic
67-
68-
```swift
6932
let central = CentralManager()
70-
await central.waitUntilReady()
33+
try await central.waitUntilReady()
7134

72-
// Find and connect to the first peripheral
35+
// Find and connect to the first available peripheral
7336
let peripheral = await central.scanForPeripherals(withServices: [myService]).first!
74-
try! await central.connect(peripheral)
75-
defer { central.cancelPeripheralConnection(peripheral) }
37+
try await central.connect(peripheral, timeout: connectionTimeout)
7638

7739
// Discover services and characteristics
78-
let service = try! await peripheral.discoverServices([myService]).first(where: { $0.uuid == myService })!
79-
let _ = try! await peripheral.discoverCharacteristics([.someCharacteristic], for: service)
40+
let service = try await peripheral.discoverServices([myService]).first!
41+
let _ = try await peripheral.discoverCharacteristics([.someCharacteristic], for: service)
8042

81-
// Read characteristic value!
82-
print("Got value:", await peripheral.readValue(for: .someCharacteristic))
83-
```
43+
// Read data directly from your characteristic
44+
let value = try await peripheral.readValue(for: .someCharacteristic)
8445

85-
> **Note**
86-
Force-unwrapping is only used for brevity and is not recommended.
46+
central.cancelPeripheralConnection(peripheral)
47+
```
8748

8849
#### Callbacks
8950

90-
```swift
91-
// Most of the stock CoreBluetooth methods have an additional new signature that takes a completionHandler
51+
Stock CoreBluetooth methods now also have an additional overload that takes a completionHandler for projects not using Swift Concurrency.
9252

53+
```swift
9354
central.connect(peripheral) { result in
9455
if result == .failure(let error) {
9556
// Issue connecting
@@ -98,14 +59,38 @@ central.connect(peripheral) { result in
9859

9960
// Connected!
10061
}
62+
```
63+
> Methods often now have 3 overloads. One marked `async`, one with a `completionHandler`, and the original CoreBluetooth verision. Meaning you can choose whichever is most convienient at the time.
64+
65+
#### Stream discovered peripherals
10166

67+
Some operations (like scanning) conform to `AsyncStream`, meaning you can use for-await-in loops to iterate over new items.
68+
69+
```swift
70+
for await peripheral in await central.scanForPeripherals() {
71+
print("Discovered:", peripheral.name ?? "Unknown")
72+
}
10273
```
10374

104-
#### Watching with callbacks
75+
#### Defining characteristics
76+
77+
Characteristics can be staticly defined on the stock `Characteristic` type, which removes the burden of keeping track of `CBCharacteristic` instances around your app.
10578

10679
```swift
107-
// Peristent tasks return a `CancellableTask` that needs to be cancelled when you're done
80+
extension Characteristic {
81+
static let someCharacteristic = Self("00000000-0000-0000-0000-000000000000")
82+
}
83+
84+
// Use those characteristics later on your peripheral
85+
try await myPeripheral.readValue(for: .someCharacteristic)
86+
```
87+
88+
89+
#### Watching with callbacks
10890

91+
Peristent tasks return a `CancellableTask` that needs to be cancelled when you're done.
92+
93+
```swift
10994
let task = central.scanForPeripherals { peripheral in
11095
print("Discovered:", peripheral.name ?? "Unknown")
11196
}
@@ -116,6 +101,24 @@ task.cancel()
116101
> **Note**
117102
Calling `central.stopScan()` will also cancel any peripheral scanning tasks
118103

104+
#### Migrate existing projects
105+
106+
Existing projects that already use `CoreBluetooth` can immediately get started by typealiasing the stock types. Afterwards, you can adopt async API's at your own pace.
107+
108+
```swift
109+
import CoreBluetooth
110+
import SwiftBluetooth // Add this
111+
112+
// Override existing CoreBluetooth classes to use SwiftBluetooth
113+
typealias CBCentralManager = SwiftBluetooth.CentralManager
114+
typealias CBCentralManagerDelegate = SwiftBluetooth.CentralManagerDelegate
115+
typealias CBPeripheral = SwiftBluetooth.Peripheral
116+
typealias CBPeripheralDelegate = SwiftBluetooth.PeripheralDelegate
117+
118+
// Your existing code should continue to work as normal.
119+
// But now you have access to all the new API's!
120+
```
121+
119122

120123
## Install
121124

Sources/SwiftBluetooth/Async/AsyncSubscriptionQueue.swift

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,16 @@ import Foundation
33
internal final class AsyncSubscriptionQueue<Value> {
44
private var items: [AsyncSubscription<Value>] = []
55

6+
internal var isEmpty: Bool {
7+
items.isEmpty
8+
}
9+
610
// TODO: Convert these to just use a lock
7-
private lazy var dispatchQueue = DispatchQueue(label: "async-subscription-queue")
11+
private let dispatchQueue: DispatchQueue
12+
13+
init(_ dispatchQueue: DispatchQueue = .init(label: "async-subscription-queue")) {
14+
self.dispatchQueue = dispatchQueue
15+
}
816

917
@discardableResult
1018
func queue(block: @escaping (Value, () -> Void) -> Void, completion: (() -> Void)? = nil) -> AsyncSubscription<Value> {
@@ -31,4 +39,3 @@ internal final class AsyncSubscriptionQueue<Value> {
3139
}
3240
}
3341
}
34-

Sources/SwiftBluetooth/Async/AsyncSubscriptionQueueMap.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,16 @@ import Foundation
33
internal final class AsyncSubscriptionQueueMap<Key, Value> where Key: Hashable {
44
private var items: [Key: AsyncSubscriptionQueue<Value>] = [:]
55

6+
internal var isEmpty: Bool {
7+
items.values.allSatisfy { $0.isEmpty }
8+
}
9+
610
// TODO: Convert these to just use a lock
7-
private let dispatchQueue = DispatchQueue(label: "async-subscription-queue-map")
11+
private let dispatchQueue: DispatchQueue
12+
13+
init(_ dispatchQueue: DispatchQueue = .init(label: "async-subscription-queue-map")) {
14+
self.dispatchQueue = dispatchQueue
15+
}
816

917
@discardableResult
1018
func queue(key: Key, block: @escaping (Value, () -> Void) -> Void, completion: (() -> Void)? = nil) -> AsyncSubscription<Value> {
@@ -16,7 +24,7 @@ internal final class AsyncSubscriptionQueueMap<Key, Value> where Key: Hashable {
1624

1725
guard let item = item else {
1826
dispatchQueue.safeSync {
19-
items[key] = .init()
27+
items[key] = .init(self.dispatchQueue)
2028
}
2129

2230
return queue(key: key, block: block, completion: completion)

Sources/SwiftBluetooth/CentralManager/CentralManager+async.swift

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,33 +3,50 @@ import CoreBluetooth
33

44
public extension CentralManager {
55
@available(iOS 13, macOS 10.15, watchOS 6.0, tvOS 13.0, *)
6-
func waitUntilReady() async {
7-
await withCheckedContinuation { cont in
8-
self.waitUntilReady {
9-
cont.resume()
6+
func waitUntilReady() async throws {
7+
try await withCheckedThrowingContinuation { cont in
8+
self.waitUntilReady { result in
9+
cont.resume(with: result)
1010
}
1111
}
1212
}
1313

1414
@available(iOS 13, macOS 10.15, watchOS 6.0, tvOS 13.0, *)
1515
@discardableResult
16-
func connect(_ peripheral: Peripheral, options: [String: Any]? = nil) async throws -> Peripheral {
17-
try await withCheckedThrowingContinuation { cont in
18-
self.connect(peripheral, options: options) { result in
19-
switch result {
20-
case .success(let peripheral):
21-
cont.resume(returning: peripheral)
22-
case .failure(let error):
23-
cont.resume(throwing: error)
16+
func connect(_ peripheral: Peripheral, timeout: TimeInterval, options: [String: Any]? = nil) async throws -> Peripheral {
17+
var cancelled = false
18+
var continuation: CheckedContinuation<Peripheral, Error>?
19+
let cancel = {
20+
cancelled = true
21+
self.cancelPeripheralConnection(peripheral)
22+
continuation?.resume(throwing: CancellationError())
23+
}
24+
25+
return try await withTaskCancellationHandler {
26+
try await withCheckedThrowingContinuation { cont in
27+
continuation = cont
28+
29+
if cancelled {
30+
cancel()
31+
return
32+
}
33+
34+
self.connect(peripheral, timeout: timeout, options: options) { result in
35+
guard !cancelled else { return }
36+
37+
cont.resume(with: result)
2438
}
2539
}
40+
} onCancel: {
41+
cancel()
2642
}
2743
}
2844

2945
// This method doesn't need to be marked async, but it prevents a signature collision
3046
@available(iOS 13, macOS 10.15, watchOS 6.0, tvOS 13.0, *)
31-
func scanForPeripherals(withServices services: [CBUUID]? = nil, options: [String: Any]? = nil) async -> AsyncStream<Peripheral> {
47+
func scanForPeripherals(withServices services: [CBUUID]? = nil, timeout: TimeInterval? = nil, options: [String: Any]? = nil) async -> AsyncStream<Peripheral> {
3248
.init { cont in
49+
var timer: Timer?
3350
let subscription = eventSubscriptions.queue { event, done in
3451
switch event {
3552
case .discovered(let peripheral, _, _):
@@ -42,14 +59,23 @@ public extension CentralManager {
4259
}
4360
} completion: { [weak self] in
4461
guard let self = self else { return }
62+
timer?.invalidate()
4563
self.centralManager.stopScan()
4664
}
4765

66+
if let timeout = timeout {
67+
let timeoutTimer = Timer(fire: Date() + timeout, interval: 0, repeats: false) { _ in
68+
subscription.cancel()
69+
cont.finish()
70+
}
71+
timer = timeoutTimer
72+
RunLoop.main.add(timeoutTimer, forMode: .default)
73+
}
74+
4875
cont.onTermination = { _ in
4976
subscription.cancel()
5077
}
5178

52-
5379
centralManager.scanForPeripherals(withServices: services, options: options)
5480
}
5581
}
@@ -58,14 +84,8 @@ public extension CentralManager {
5884
func cancelPeripheralConnection(_ peripheral: Peripheral) async throws {
5985
try await withCheckedThrowingContinuation { cont in
6086
self.cancelPeripheralConnection(peripheral) { result in
61-
switch result {
62-
case .success(_):
63-
cont.resume()
64-
case .failure(let error):
65-
cont.resume(throwing: error)
66-
}
87+
cont.resume(with: result)
6788
}
6889
}
69-
7090
}
7191
}

0 commit comments

Comments
 (0)