Skip to content

Commit

Permalink
Merge pull request #3 from exPHAT/v1.0.0
Browse files Browse the repository at this point in the history
v1.0.0
  • Loading branch information
exPHAT authored Apr 16, 2024
2 parents 69af89d + 3718d0f commit c5a9f4e
Show file tree
Hide file tree
Showing 28 changed files with 1,383 additions and 423 deletions.
113 changes: 58 additions & 55 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,79 +17,40 @@ Easily interface with Bluetooth peripherals in new or existing projects through
- [x] Thread safe
- [x] Zero inherited dependencies
- [x] Tested with included `SwiftBluetoothMock` library
- [ ] SwiftUI support

## Examples

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

#### Complete example

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

```swift
import CoreBluetooth
import SwiftBluetooth // Add this

// Override existing CoreBluetooth classes to use SwiftBluetooth
typealias CBCentralManager = SwiftBluetooth.CentralManager
typealias CBCentralManagerDelegate = SwiftBluetooth.CentralManagerDelegate
typealias CBPeripheral = SwiftBluetooth.Peripheral
typealias CBPeripheralDelegate = SwiftBluetooth.PeripheralDelegate

// Your existing code should continue to work as normal.
// But now you have access to all the new API's!
```

#### Stream discovered peripherals

```swift
let central = CentralManager()
await central.waitUntilReady()

for await peripheral in await central.scanForPeripherals() {
print("Discovered:", peripheral.name ?? "Unknown")
}
```

#### Define characteristics

```swift
// Define your characteristic UUID's as static members of the `Characteristic` type
extension Characteristic {
static let someCharacteristic = Self("00000000-0000-0000-0000-000000000000")
}
import SwiftBluetooth

// Use those characteristics later on your peripheral
await myPeripheral.readValue(for: .someCharacteristic)
```

#### Discover, connect, and read characteristic

```swift
let central = CentralManager()
await central.waitUntilReady()
try await central.waitUntilReady()

// Find and connect to the first peripheral
// Find and connect to the first available peripheral
let peripheral = await central.scanForPeripherals(withServices: [myService]).first!
try! await central.connect(peripheral)
defer { central.cancelPeripheralConnection(peripheral) }
try await central.connect(peripheral, timeout: connectionTimeout)

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

// Read characteristic value!
print("Got value:", await peripheral.readValue(for: .someCharacteristic))
```
// Read data directly from your characteristic
let value = try await peripheral.readValue(for: .someCharacteristic)

> **Note**
Force-unwrapping is only used for brevity and is not recommended.
central.cancelPeripheralConnection(peripheral)
```

#### Callbacks

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

```swift
central.connect(peripheral) { result in
if result == .failure(let error) {
// Issue connecting
Expand All @@ -98,14 +59,38 @@ central.connect(peripheral) { result in

// Connected!
}
```
> 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.
#### Stream discovered peripherals

Some operations (like scanning) conform to `AsyncStream`, meaning you can use for-await-in loops to iterate over new items.

```swift
for await peripheral in await central.scanForPeripherals() {
print("Discovered:", peripheral.name ?? "Unknown")
}
```

#### Watching with callbacks
#### Defining characteristics

Characteristics can be staticly defined on the stock `Characteristic` type, which removes the burden of keeping track of `CBCharacteristic` instances around your app.

```swift
// Peristent tasks return a `CancellableTask` that needs to be cancelled when you're done
extension Characteristic {
static let someCharacteristic = Self("00000000-0000-0000-0000-000000000000")
}

// Use those characteristics later on your peripheral
try await myPeripheral.readValue(for: .someCharacteristic)
```


#### Watching with callbacks

Peristent tasks return a `CancellableTask` that needs to be cancelled when you're done.

```swift
let task = central.scanForPeripherals { peripheral in
print("Discovered:", peripheral.name ?? "Unknown")
}
Expand All @@ -116,6 +101,24 @@ task.cancel()
> **Note**
Calling `central.stopScan()` will also cancel any peripheral scanning tasks

#### Migrate existing projects

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.

```swift
import CoreBluetooth
import SwiftBluetooth // Add this

// Override existing CoreBluetooth classes to use SwiftBluetooth
typealias CBCentralManager = SwiftBluetooth.CentralManager
typealias CBCentralManagerDelegate = SwiftBluetooth.CentralManagerDelegate
typealias CBPeripheral = SwiftBluetooth.Peripheral
typealias CBPeripheralDelegate = SwiftBluetooth.PeripheralDelegate

// Your existing code should continue to work as normal.
// But now you have access to all the new API's!
```


## Install

Expand Down
11 changes: 9 additions & 2 deletions Sources/SwiftBluetooth/Async/AsyncSubscriptionQueue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,16 @@ import Foundation
internal final class AsyncSubscriptionQueue<Value> {
private var items: [AsyncSubscription<Value>] = []

internal var isEmpty: Bool {
items.isEmpty
}

// TODO: Convert these to just use a lock

Check warning on line 10 in Sources/SwiftBluetooth/Async/AsyncSubscriptionQueue.swift

View workflow job for this annotation

GitHub Actions / lint

Todo Violation: TODOs should be resolved (Convert these to just use a lo...) (todo)
private lazy var dispatchQueue = DispatchQueue(label: "async-subscription-queue")
private let dispatchQueue: DispatchQueue

init(_ dispatchQueue: DispatchQueue = .init(label: "async-subscription-queue")) {
self.dispatchQueue = dispatchQueue
}

@discardableResult
func queue(block: @escaping (Value, () -> Void) -> Void, completion: (() -> Void)? = nil) -> AsyncSubscription<Value> {

Check warning on line 18 in Sources/SwiftBluetooth/Async/AsyncSubscriptionQueue.swift

View workflow job for this annotation

GitHub Actions / lint

Line Length Violation: Line should be 120 characters or less; currently it has 123 characters (line_length)
Expand All @@ -31,4 +39,3 @@ internal final class AsyncSubscriptionQueue<Value> {
}
}
}

12 changes: 10 additions & 2 deletions Sources/SwiftBluetooth/Async/AsyncSubscriptionQueueMap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,16 @@ import Foundation
internal final class AsyncSubscriptionQueueMap<Key, Value> where Key: Hashable {
private var items: [Key: AsyncSubscriptionQueue<Value>] = [:]

internal var isEmpty: Bool {
items.values.allSatisfy { $0.isEmpty }
}

// TODO: Convert these to just use a lock

Check warning on line 10 in Sources/SwiftBluetooth/Async/AsyncSubscriptionQueueMap.swift

View workflow job for this annotation

GitHub Actions / lint

Todo Violation: TODOs should be resolved (Convert these to just use a lo...) (todo)
private let dispatchQueue = DispatchQueue(label: "async-subscription-queue-map")
private let dispatchQueue: DispatchQueue

init(_ dispatchQueue: DispatchQueue = .init(label: "async-subscription-queue-map")) {
self.dispatchQueue = dispatchQueue
}

@discardableResult
func queue(key: Key, block: @escaping (Value, () -> Void) -> Void, completion: (() -> Void)? = nil) -> AsyncSubscription<Value> {

Check warning on line 18 in Sources/SwiftBluetooth/Async/AsyncSubscriptionQueueMap.swift

View workflow job for this annotation

GitHub Actions / lint

Line Length Violation: Line should be 120 characters or less; currently it has 133 characters (line_length)
Expand All @@ -16,7 +24,7 @@ internal final class AsyncSubscriptionQueueMap<Key, Value> where Key: Hashable {

guard let item = item else {
dispatchQueue.safeSync {
items[key] = .init()
items[key] = .init(self.dispatchQueue)
}

return queue(key: key, block: block, completion: completion)
Expand Down
62 changes: 41 additions & 21 deletions Sources/SwiftBluetooth/CentralManager/CentralManager+async.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,50 @@ import CoreBluetooth

public extension CentralManager {
@available(iOS 13, macOS 10.15, watchOS 6.0, tvOS 13.0, *)
func waitUntilReady() async {
await withCheckedContinuation { cont in
self.waitUntilReady {
cont.resume()
func waitUntilReady() async throws {
try await withCheckedThrowingContinuation { cont in
self.waitUntilReady { result in
cont.resume(with: result)
}
}
}

@available(iOS 13, macOS 10.15, watchOS 6.0, tvOS 13.0, *)
@discardableResult
func connect(_ peripheral: Peripheral, options: [String: Any]? = nil) async throws -> Peripheral {
try await withCheckedThrowingContinuation { cont in
self.connect(peripheral, options: options) { result in
switch result {
case .success(let peripheral):
cont.resume(returning: peripheral)
case .failure(let error):
cont.resume(throwing: error)
func connect(_ peripheral: Peripheral, timeout: TimeInterval, options: [String: Any]? = nil) async throws -> Peripheral {
var cancelled = false
var continuation: CheckedContinuation<Peripheral, Error>?
let cancel = {
cancelled = true
self.cancelPeripheralConnection(peripheral)
continuation?.resume(throwing: CancellationError())
}

return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { cont in
continuation = cont

if cancelled {
cancel()
return
}

self.connect(peripheral, timeout: timeout, options: options) { result in
guard !cancelled else { return }

cont.resume(with: result)
}
}
} onCancel: {
cancel()
}
}

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

if let timeout = timeout {
let timeoutTimer = Timer(fire: Date() + timeout, interval: 0, repeats: false) { _ in
subscription.cancel()
cont.finish()
}
timer = timeoutTimer
RunLoop.main.add(timeoutTimer, forMode: .default)
}

cont.onTermination = { _ in
subscription.cancel()
}


centralManager.scanForPeripherals(withServices: services, options: options)
}
}
Expand All @@ -58,14 +84,8 @@ public extension CentralManager {
func cancelPeripheralConnection(_ peripheral: Peripheral) async throws {
try await withCheckedThrowingContinuation { cont in
self.cancelPeripheralConnection(peripheral) { result in
switch result {
case .success(_):
cont.resume()
case .failure(let error):
cont.resume(throwing: error)
}
cont.resume(with: result)
}
}

}
}
Loading

0 comments on commit c5a9f4e

Please sign in to comment.