Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

v1.0.0 #3

Merged
merged 14 commits into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ typealias CBPeripheralDelegate = SwiftBluetooth.PeripheralDelegate

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

for await peripheral in await central.scanForPeripherals() {
print("Discovered:", peripheral.name ?? "Unknown")
Expand All @@ -60,31 +60,28 @@ extension Characteristic {
}

// Use those characteristics later on your peripheral
await myPeripheral.readValue(for: .someCharacteristic)
try 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
let peripheral = await central.scanForPeripherals(withServices: [myService]).first!
try! await central.connect(peripheral)
try await central.connect(peripheral, timeout: connectionTimeout)
defer { central.cancelPeripheralConnection(peripheral) }

// 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(where: { $0.uuid == myService })!
let _ = try await peripheral.discoverCharacteristics([.someCharacteristic], for: service)

// Read characteristic value!
print("Got value:", await peripheral.readValue(for: .someCharacteristic))
print("Got value:", try await peripheral.readValue(for: .someCharacteristic))
```

> **Note**
Force-unwrapping is only used for brevity and is not recommended.

#### Callbacks

```swift
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
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> {
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
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> {
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 @@

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 {

Check warning on line 16 in Sources/SwiftBluetooth/CentralManager/CentralManager+async.swift

View workflow job for this annotation

GitHub Actions / lint

Line Length Violation: Line should be 120 characters or less; currently it has 125 characters (line_length)
var cancelled = false
var continuation: CheckedContinuation<Peripheral, Error>?
let cancel = {
cancelled = true
self.cancelPeripheralConnection(peripheral)
continuation?.resume(throwing: CentralError.cancelled)
exPHAT marked this conversation as resolved.
Show resolved Hide resolved
}

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> {

Check warning on line 47 in Sources/SwiftBluetooth/CentralManager/CentralManager+async.swift

View workflow job for this annotation

GitHub Actions / lint

Line Length Violation: Line should be 120 characters or less; currently it has 163 characters (line_length)
.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 @@
}
} 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 @@
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)
}
}

}
}
160 changes: 107 additions & 53 deletions Sources/SwiftBluetooth/CentralManager/CentralManager+callback.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,83 +2,137 @@ import Foundation
import CoreBluetooth

public extension CentralManager {
func waitUntilReady(completionHandler: @escaping () -> Void) {
guard state != .poweredOn else {
completionHandler()
return
}
func waitUntilReady(completionHandler: @escaping (Result<Void, Error>) -> Void) {
eventQueue.async { [self] in
guard state != .poweredOn else {
completionHandler(.success(Void()))
return
}

guard state != .unauthorized else {
completionHandler(.failure(CentralError.unauthorized))
return
}

guard state != .unsupported else {
completionHandler(.failure(CentralError.unavailable))
return
}

eventSubscriptions.queue { event, done in
if case .stateUpdated(let state) = event,
state == .poweredOn {
eventSubscriptions.queue { event, done in
guard case .stateUpdated(let state) = event else { return }

switch state {
case .poweredOn:
completionHandler(.success(Void()))
case .unauthorized:
completionHandler(.failure(CentralError.unauthorized))
case .unsupported:
completionHandler(.failure(CentralError.unavailable))
default:
return
}

completionHandler()
done()
}
}
}

func connect(_ peripheral: Peripheral, options: [String: Any]? = nil, completionHandler: @escaping (Result<Peripheral, Error>) -> Void) {
eventSubscriptions.queue { event, done in
switch event {
case .connected(let connected):
guard connected == peripheral else { return }
func connect(_ peripheral: Peripheral, timeout: TimeInterval, options: [String: Any]? = nil, completionHandler: @escaping (Result<Peripheral, Error>) -> Void) {
eventQueue.async { [self] in
guard peripheral.state != .connected else {
completionHandler(.success(peripheral))
case .disconnected(let disconnected, let error):
guard disconnected == peripheral else { return }
completionHandler(.failure(error ?? CentralError.unknown))
case .failToConnect(let failed, let error):
guard failed == peripheral else { return }
completionHandler(.failure(error ?? CentralError.unknown))
default:
return
}

done()
}

connect(peripheral, options: options)
}
var timer: Timer?
let task = eventSubscriptions.queue { event, done in
switch event {
case .connected(let connected):
guard connected == peripheral else { return }
completionHandler(.success(peripheral))
case .disconnected(let disconnected, let error):
guard disconnected == peripheral else { return }
completionHandler(.failure(error ?? CentralError.unknown))
case .failToConnect(let failed, let error):
guard failed == peripheral else { return }
completionHandler(.failure(error ?? CentralError.unknown))
default:
return
}

func scanForPeripherals(withServices services: [CBUUID]? = nil, options: [String: Any]? = nil, onPeripheralFound: @escaping (Peripheral) -> Void) -> CancellableTask {
let subscription = eventSubscriptions.queue { event, done in
switch event {
case .discovered(let peripheral, _, _):
onPeripheralFound(peripheral)
case .stopScan:
timer?.invalidate()
done()
default:
break
}
} completion: { [weak self] in
guard let self = self else { return }
self.centralManager.stopScan()

if timeout != .infinity {
let timeoutTimer = Timer(fire: Date() + timeout, interval: 0, repeats: false) { _ in
task.cancel()
completionHandler(.failure(CBError(.connectionTimeout)))
}
timer = timeoutTimer
RunLoop.main.add(timeoutTimer, forMode: .default)
}

connect(peripheral, options: options)
}
}

func scanForPeripherals(withServices services: [CBUUID]? = nil, timeout: TimeInterval? = nil, options: [String: Any]? = nil, onPeripheralFound: @escaping (Peripheral) -> Void) -> CancellableTask {
eventQueue.sync {
var timer: Timer?
let subscription = eventSubscriptions.queue { event, done in
switch event {
case .discovered(let peripheral, _, _):
onPeripheralFound(peripheral)
case .stopScan:
done()
default:
break
}
} completion: { [weak self, timer] in
guard let self = self else { return }
timer?.invalidate()
self.centralManager.stopScan()
}

centralManager.scanForPeripherals(withServices: services, options: options)
if timeout != .infinity {
if let timeout = timeout {
let timeoutTimer = Timer(fire: Date() + timeout, interval: 0, repeats: false) { _ in
subscription.cancel()
}
timer = timeoutTimer
RunLoop.main.add(timeoutTimer, forMode: .default)
}
}

centralManager.scanForPeripherals(withServices: services, options: options)

return subscription
return subscription
}
}

func cancelPeripheralConnection(_ peripheral: Peripheral, completionHandler: @escaping (Result<Void, Error>) -> Void) {
guard connectedPeripherals.contains(peripheral) else {
completionHandler(.success(Void()))
return
}
eventQueue.async { [self] in
guard connectedPeripherals.contains(peripheral) else {
completionHandler(.success(Void()))
return
}

eventSubscriptions.queue { event, done in
guard case .disconnected(let disconnected, let error) = event,
disconnected == peripheral else { return }
eventSubscriptions.queue { event, done in
guard case .disconnected(let disconnected, let error) = event,
disconnected == peripheral else { return }

if let error = error {
completionHandler(.failure(error))
} else {
completionHandler(.success(Void()))
if let error = error {
completionHandler(.failure(error))
} else {
completionHandler(.success(Void()))
}

done()
}

done()
cancelPeripheralConnection(peripheral)
}

cancelPeripheralConnection(peripheral)
}
}
Loading
Loading