Skip to content

Commit

Permalink
Merge pull request #3 from danielepantaleone/develop
Browse files Browse the repository at this point in the history
Release 1.1.0
  • Loading branch information
danielepantaleone authored Oct 26, 2024
2 parents 9c3fcdf + 804f018 commit 3e9a53e
Show file tree
Hide file tree
Showing 303 changed files with 859 additions and 341 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/swift-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
strategy:
matrix:
platform: ['macOS', 'iOS']
swift: ["5.9"]
swift: ["5.9", "6.0"]
runs-on: macos-latest
steps:
- name: Repository checkout
Expand Down
2 changes: 1 addition & 1 deletion BlueConnect.podspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = "BlueConnect"
s.version = "1.0.0"
s.version = "1.1.0"
s.summary = "A modern approach to Bluetooth LE connectivity built around CoreBluetooth"
s.license = { :type => "MIT", :file => "LICENSE" }
s.homepage = "https://github.com/danielepantaleone/BlueConnect"
Expand Down
4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,9 @@ let package = Package(
"BlueConnect"
]
),
],
swiftLanguageVersions: [
.v5,
.version("6")
]
)
26 changes: 13 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,17 @@ This combination of asynchronous communication, event-driven architecture, and t

## Feature Highlights

- Supports both iOS and macOS.
- Fully covered by unit tests.
- Replaces the delegate-based interface of **`CBCentralManager`** and **`CBPeripheral`** with closures and Swift concurrency (async/await).
- Delivers event notifications via Combine publishers for both **`CBCentralManager`** and **`CBPeripheral`**.
- Includes connection timeout handling for **`CBPeripheral`**.
- Includes characteristic operations timeout handling for **`CBPeripheral`** (discovery, read, write, set notify).
- Provides direct interaction with **`CBPeripheral`** characteristics with no need to manage **`CBPeripheral`** data.
- Provides an optional cache policy for **`CBPeripheral`** data retrieval, ideal for scenarios where characteristic data remains static over time.
- Provides automatic service/characteristic discovery when characteristic operations are requested (read, write, set notify).
- Correct routing of **`CBCentralManager`** disconnection events towards connection failure publisher and callbacks if the connection didn't happen at all.
- Facilitates unit testing by supporting BLE central and peripheral mocks, enabling easier testing for libraries and apps that interact with BLE peripherals.
- [x] Supports both iOS and macOS.
- [x] Fully covered by unit tests.
- [x] Replaces the delegate-based interface of **`CBCentralManager`** and **`CBPeripheral`** with closures and Swift concurrency (async/await).
- [x] Delivers event notifications via Combine publishers for both **`CBCentralManager`** and **`CBPeripheral`**.
- [x] Includes connection timeout handling for **`CBPeripheral`**.
- [x] Includes characteristic operations timeout handling for **`CBPeripheral`** (discovery, read, write, set notify).
- [x] Provides direct interaction with **`CBPeripheral`** characteristics with no need to manage **`CBPeripheral`** data.
- [x] Provides an optional cache policy for **`CBPeripheral`** data retrieval, ideal for scenarios where characteristic data remains static over time.
- [x] Provides automatic service/characteristic discovery when characteristic operations are requested (read, write, set notify).
- [x] Correct routing of **`CBCentralManager`** disconnection events towards connection failure publisher and callbacks if the connection didn't happen at all.
- [x] Facilitates unit testing by supporting BLE central and peripheral mocks, enabling easier testing for libraries and apps that interact with BLE peripherals.

## Usage

Expand Down Expand Up @@ -376,14 +376,14 @@ You can create mock versions of your central manager and peripheral(s) and suppl
### Cocoapods

```ruby
pod 'BlueConnect', '~> 1.0.0'
pod 'BlueConnect', '~> 1.1.0'
```

### Swift Package Manager

```swift
dependencies: [
.package(url: "https://github.com/danielepantaleone/BlueConnect.git", .upToNextMajor(from: "1.0.0"))
.package(url: "https://github.com/danielepantaleone/BlueConnect.git", .upToNextMajor(from: "1.1.0"))
]
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
// THE SOFTWARE.
//

import CoreBluetooth
@preconcurrency import CoreBluetooth

/// A convenience class that helps interpret and access Bluetooth Low Energy (BLE) advertisement data.
///
Expand Down
2 changes: 1 addition & 1 deletion Sources/BlueConnect/CentralManager/BleCentralManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
// THE SOFTWARE.
//

import CoreBluetooth
@preconcurrency import CoreBluetooth

/// A protocol to mimic the capabilities of a `CBCentralManager`.
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
// THE SOFTWARE.
//

import CoreBluetooth
@preconcurrency import CoreBluetooth

/// A protocol defining the delegate methods for handling Bluetooth Central Manager events.
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
// THE SOFTWARE.
//

import CoreBluetooth
@preconcurrency import CoreBluetooth
import Foundation

extension BleCentralManagerProxy: BleCentralManagerDelegate {
Expand Down Expand Up @@ -77,6 +77,13 @@ extension BleCentralManagerProxy: BleCentralManagerDelegate {
// Remove any tracked connection timeout.
connectionTimeouts.removeAll()

} else {

// Kill the timer waiting for central to be ready.
stopWaitUntilReadyTimer()
// Notify any registered callback.
notifyCallbacks(store: &waitUntilReadyCallbacks, value: .success(()))

}

// Notify state publisher.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
//

import Combine
import CoreBluetooth
@preconcurrency import CoreBluetooth
import Foundation

extension BleCentralManagerProxy: CBCentralManagerDelegate {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
//

import Combine
import CoreBluetooth
@preconcurrency import CoreBluetooth
import Foundation

extension BleCentralManagerProxy {
Expand Down Expand Up @@ -57,7 +57,9 @@ extension BleCentralManagerProxy {
) async throws {
try await withCheckedThrowingContinuation { continuation in
connect(peripheral: peripheral, options: options, timeout: timeout) { result in
continuation.resume(with: result)
globalQueue.async {
continuation.resume(with: result)
}
}
}
}
Expand All @@ -83,7 +85,41 @@ extension BleCentralManagerProxy {
public func disconnect(peripheral: BlePeripheral) async throws {
try await withCheckedThrowingContinuation { continuation in
disconnect(peripheral: peripheral) { result in
continuation.resume(with: result)
globalQueue.async {
continuation.resume(with: result)
}
}
}
}

/// Waits asynchronously until the central manager is in the `.poweredOn` state, or throws an error if the state is unauthorized or unsupported.
///
/// This method uses an async/await pattern to wait for the central manager to become ready. It checks the central manager's state and resumes
/// with success if it is already `.poweredOn`. Otherwise, it waits until the state changes to `.poweredOn` within the specified timeout.
/// If the state is unauthorized or unsupported, it throws an error.
///
/// Example usage:
///
/// ```swift
/// do {
/// try await centralManagerProxy.waitUntilReady(timeout: .seconds(5))
/// // Central manager is ready
/// } catch {
/// // Handle error (e.g., unsupported or unauthorized state)
/// }
/// ```
///
/// - Parameters:
/// - timeout: The maximum duration to wait for the central manager to be ready. The default value is `.never`, indicating no timeout.
///
/// - Returns: The method returns asynchronously when the central manager is ready or an error occurs.
/// - Throws: An error if the it's not possible to wait for the central manager to be ready within the provided timeout.
public func waitUntilReady(timeout: DispatchTimeInterval = .never) async throws {
try await withCheckedThrowingContinuation { continuation in
waitUntilReady(timeout: timeout) { result in
globalQueue.async {
continuation.resume(with: result)
}
}
}
}
Expand Down
118 changes: 116 additions & 2 deletions Sources/BlueConnect/CentralManagerProxy/BleCentralManagerProxy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
//

import Combine
import CoreBluetooth
@preconcurrency import CoreBluetooth
import Foundation

/// `BleCentralManagerProxy` provides a higher-level abstraction for managing BLE peripherals via `BleCentralManager`.
Expand Down Expand Up @@ -68,6 +68,8 @@ public class BleCentralManagerProxy: NSObject {

// MARK: - Internal properties

var waitUntilReadyCallbacks: [(Result<Void, Error>) -> Void] = []
var waitUntilReadyTimer: DispatchSourceTimer?
var connectionCallbacks: [UUID: [(Result<Void, Error>) -> Void]] = [:]
var connectionState: [UUID: CBPeripheralState] = [:]
var connectionTimers: [UUID: DispatchSourceTimer] = [:]
Expand Down Expand Up @@ -164,7 +166,16 @@ public class BleCentralManagerProxy: NSObject {
store[uuid]?.append(callback)
}

/// Notifies all registered callbacks for a peripheral and clears the callbacks.
/// Registers a callback.
///
/// - Parameters:
/// - store: The callback store to register the callback in.
/// - callback: The callback to register.
func registerCallback<T>(store: inout [(Result<T, Error>) -> Void], callback: @escaping ((Result<T, Error>) -> Void)) {
store.append(callback)
}

/// Notifies all registered callbacks for a peripheral and clears the callbacks store.
///
/// - Parameters:
/// - store: The callback store to notify.
Expand All @@ -182,6 +193,17 @@ public class BleCentralManagerProxy: NSObject {
callbacks.forEach { $0(value) }
}

/// Notifies all registered callbacks and clears the callbacks store.
///
/// - Parameters:
/// - store: The callback store to notify.
/// - value: The result to pass to the callbacks.
func notifyCallbacks<T>(store: inout [(Result<T, Error>) -> Void], value: Result<T, Error>) {
let callbacks = store
store.removeAll()
callbacks.forEach { $0(value) }
}

}

// MARK: - Peripheral connection
Expand Down Expand Up @@ -396,6 +418,67 @@ extension BleCentralManagerProxy {

}

// MARK: - State change

extension BleCentralManagerProxy {

/// Waits until the central manager is in the `.poweredOn` state, executing the callback upon success or failure.
///
/// This method registers a callback that is invoked when the central manager's state changes to `.poweredOn`, or an error occurs.
/// The method also verifies that the central manager is authorized and supported.
///
/// Example usage:
///
/// ```swift
/// bleCentralManagerProxy.waitUntilReady(timeout: .seconds(10)) { result in
/// switch result {
/// case .success:
/// print("Central manager is ready")
/// case .failure(let error):
/// print("Central manager is not ready: \(error)")
/// }
/// }
/// ```
///
/// - Parameters:
/// - timeout: The maximum time to wait for the central manager to become ready. Default is `.never`.
/// - callback: A closure that receives a `Result` indicating success or an error if the central manager is unauthorized or unsupported.
///
/// - Note: If the state is already `.poweredOn`, the callback is called immediately with success.
public func waitUntilReady(timeout: DispatchTimeInterval = .never, callback: @escaping ((Result<Void, Error>) -> Void)) {

mutex.lock()
defer { mutex.unlock() }

// Ensure central manager is not already powered on.
guard centralManager.state != .poweredOn else {
callback(.success(()))
return
}

// Ensure central manager is authorized.
guard centralManager.state != .unauthorized else {
let error = BleCentralManagerProxyError.invalidState(centralManager.state)
callback(.failure(error))
return
}

// Ensure central manager is supported.
guard centralManager.state != .unsupported else {
let error = BleCentralManagerProxyError.invalidState(centralManager.state)
callback(.failure(error))
return
}

// Register a callback to be executed when the central state is powered on.
registerCallback(store: &waitUntilReadyCallbacks, callback: callback)
// Start tracking state change timeout.
startWaitUntilReadyTimer(timeout: timeout)

}

}

// MARK: - Timers

extension BleCentralManagerProxy {
Expand Down Expand Up @@ -481,4 +564,35 @@ extension BleCentralManagerProxy {
discoverTimer = nil
}

func startWaitUntilReadyTimer(timeout: DispatchTimeInterval) {
mutex.lock()
defer { mutex.unlock() }
guard timeout != .never else {
waitUntilReadyTimer?.cancel()
waitUntilReadyTimer = nil
return
}
waitUntilReadyTimer?.cancel()
waitUntilReadyTimer = DispatchSource.makeTimerSource()
waitUntilReadyTimer?.schedule(deadline: .now() + timeout, repeating: .never)
waitUntilReadyTimer?.setEventHandler { [weak self] in
guard let self else { return }
mutex.lock()
defer { mutex.unlock() }
// Kill the timer and reset.
waitUntilReadyTimer?.cancel()
waitUntilReadyTimer = nil
// Notify callbacks.
notifyCallbacks(store: &waitUntilReadyCallbacks, value: .failure(BleCentralManagerProxyError.readyTimeout))
}
waitUntilReadyTimer?.resume()
}

func stopWaitUntilReadyTimer() {
mutex.lock()
defer { mutex.unlock() }
waitUntilReadyTimer?.cancel()
waitUntilReadyTimer = nil
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
// THE SOFTWARE.
//

import CoreBluetooth
@preconcurrency import CoreBluetooth

/// An enumeration representing various errors that can occur in the `BleCentralManagerProxy`.
///
Expand All @@ -48,6 +48,11 @@ public enum BleCentralManagerProxyError: Error {
/// - Parameter state: The invalid `CBManagerState` that prevented the operation from proceeding.
case invalidState(CBManagerState)

/// Indicates that a timeout occurred while awaiting for the central manager to turn on.
///
/// This error is thrown when someone starts awaiting for the central manager to be ready but the state change is not triggered within a provided timeout.
case readyTimeout

/// Represents an unknown error condition.
///
/// This error is used when an unrecognized or unspecified issue occurs within the central manager proxy.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
// THE SOFTWARE.
//

import CoreBluetooth
@preconcurrency import CoreBluetooth
import Foundation

public extension BleCharacteristicNotifyProxy {
Expand Down Expand Up @@ -58,7 +58,9 @@ public extension BleCharacteristicNotifyProxy {
func setNotify(enabled: Bool, timeout: DispatchTimeInterval = .seconds(10)) async throws -> Bool {
try await withCheckedThrowingContinuation { continuation in
setNotify(enabled: enabled, timeout: timeout) { result in
continuation.resume(with: result)
globalQueue.async {
continuation.resume(with: result)
}
}
}
}
Expand Down
Loading

0 comments on commit 3e9a53e

Please sign in to comment.