diff --git a/.swift-format b/.swift-format deleted file mode 100644 index 40916a2..0000000 --- a/.swift-format +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": 1, - "lineLength": 100, - "indentation": { - "spaces": 2 - }, - "maximumBlankLines": 1, - "respectsExistingLineBreaks": true, - "lineBreakBeforeControlFlowKeywords": true, - "lineBreakBeforeEachArgument": true -} \ No newline at end of file diff --git a/README.md b/README.md index b57d8f5..5c2e0b0 100644 --- a/README.md +++ b/README.md @@ -13,23 +13,32 @@ It is currently supported on: iOS 13.0+ macOS 10.15+ +# Architecture Overview + +BLECombineKit wraps CoreBluetooth with a reactive layer using Combine and providing modern Swift Concurrency (Async/Await) extensions. + +- **BLECentralManager**: A wrapper for `CBCentralManager` that handles scanning and connecting to peripherals. +- **BLEPeripheral**: A wrapper for `CBPeripheral` that facilitates service and characteristic discovery, and data operations. +- **BLEService**: A wrapper for `CBService`. +- **BLECharacteristic**: A wrapper for `CBCharacteristic`. +- **BLEPeripheralManager**: A wrapper for `CBPeripheralManager` to act as a Bluetooth peripheral. + # How to use -As simple as creating a CBCentralManager and let the reactive magic of Combine do the rest: +### Central Manager (Scanning and Connecting) + +As simple as creating a `CBCentralManager` and letting the reactive magic of Combine do the rest: ```swift import CoreBluetooth import Combine import BLECombineKit -... - let centralManager = BLECombineKit.buildCentralManager(with: CBCentralManager()) let serviceUUID = CBUUID(string: "0x00FF") let characteristicUUID = CBUUID(string: "0xFF01") -// Connect to the first peripheral that matches the given service UUID and observe a specific -// characteristic in that service. + centralManager.scanForPeripherals(withServices: [serviceUUID], options: nil) .first() .flatMap { $0.peripheral.connect(with: nil) } @@ -45,37 +54,75 @@ centralManager.scanForPeripherals(withServices: [serviceUUID], options: nil) .store(in: &disposables) ``` -And with Swift Concurrency, it would look like this: - -```swift -import CoreBluetooth -import Combine -import BLECombineKit +### Swift Concurrency -... +With Swift Concurrency, you can use `AsyncThrowingStream` and `async/await`: -let centralManager = BLECombineKit.buildCentralManager(with: CBCentralManager()) - -let serviceUUID = CBUUID(string: "0x00FF") -let characteristicUUID = CBUUID(string: "0xFF01") -// Connect to the first peripheral that matches the given service UUID and observe a specific -// characteristic in that service. -let stream = centralManager.scanForPeripherals(withServices: [serviceUUID], options: nil) - .first() - .flatMap { $0.peripheral.connect(with: nil) } - .flatMap { $0.discoverServices(serviceUUIDs: [serviceUUID]) } - .flatMap { $0.discoverCharacteristics(characteristicUUIDs: nil) } - .filter { $0.value.uuid == characteristicUUID } - .flatMap { $0.observeValueUpdateAndSetNotification() } - .values +```swift +let stream = centralManager.scanForPeripheralsStream(withServices: [serviceUUID], options: nil) Task { - for try await value in stream { - print("Value received \(value)") - } + do { + // Connect to all the peripherals found matching the service UUID. + // This is just an example so you can also just wait for the first peripheral + // found instead of using a for loop. + for try await peripheral in stream { + let connected = try await centralManager.connectAsync(peripheral: peripheral) + let services = try await connected.discoverServicesAsync(serviceUUIDs: [serviceUUID]) + + for service in services { + let characteristics = try await service.discoverCharacteristicsAsync(characteristicUUIDs: nil) + // ... interact with characteristics + } + } + } catch { + print("Error: \(error)") + } } ``` +### Writing Data + +You can write data to characteristics using either Combine or Async/Await: + +```swift +// Combine +characteristic.writeValue(someData, type: .withResponse) + .sink(receiveCompletion: { _ in }, receiveValue: { _ in }) + .store(in: &disposables) + +// Async/Await +try await characteristic.writeValueAsync(someData, type: .withResponse) +``` + +### Peripheral Manager (Advertising) + +Act as a peripheral and advertise services: + +```swift +let peripheralManager = BLECombineKit.buildPeripheralManager() + +let advertisementData: [String: Any] = [ + CBAdvertisementDataServiceUUIDsKey: [serviceUUID], + CBAdvertisementDataLocalNameKey: "MyPeripheral" +] + +peripheralManager.startAdvertising(advertisementData) + .sink(receiveCompletion: { _ in }, receiveValue: { result in + print("Advertising status: \(result)") + }) + .store(in: &disposables) +``` + +# Error Handling + +BLECombineKit provides a structured `BLEError` enum that categorizes errors from different parts of the stack: + +- `.managerState`: Issues with Bluetooth state (powered off, unauthorized, etc.) +- `.peripheral`: Issues during connection, discovery, or communication. +- `.data`: Data conversion failures. +- `.writeFailed`: Explicit write operation failures. + # Installation ## Swift Package Manager diff --git a/Sources/BLECombineKit/Central/BLECentralManager+Async.swift b/Sources/BLECombineKit/Central/BLECentralManager+Async.swift index 0e5212e..2161cee 100644 --- a/Sources/BLECombineKit/Central/BLECentralManager+Async.swift +++ b/Sources/BLECombineKit/Central/BLECentralManager+Async.swift @@ -13,6 +13,11 @@ import Foundation @available(iOS 15, macOS 12.0, *) extension BLECentralManager { + /// Scans for peripherals given a set of service identifiers and options, returning an async stream of results. + /// - Parameters: + /// - services: Optional list of service UUIDs to scan for. + /// - options: Optional scanning options. + /// - Returns: An `AsyncThrowingStream` emitting discovered peripherals. public func scanForPeripheralsStream( withServices services: [CBUUID]?, options: [String: Any]? @@ -23,6 +28,11 @@ extension BLECentralManager { return scanPublisher.asyncThrowingStream } + /// Connects to a peripheral asynchronously. + /// - Parameters: + /// - peripheral: The peripheral to connect to. + /// - options: Optional connection options. + /// - Returns: The connected peripheral. public func connectAsync( peripheral: BLEPeripheral, options: [String: Any]? diff --git a/Sources/BLECombineKit/Central/BLEScanResult.swift b/Sources/BLECombineKit/Central/BLEScanResult.swift index 65e490a..24c0685 100644 --- a/Sources/BLECombineKit/Central/BLEScanResult.swift +++ b/Sources/BLECombineKit/Central/BLEScanResult.swift @@ -8,9 +8,13 @@ import Foundation +/// Represents the result of a peripheral scan. public struct BLEScanResult { + /// The discovered peripheral. public let peripheral: BLEPeripheral + /// The advertisement data associated with the scan result. public let advertisementData: [String: Any] + /// The RSSI (Received Signal Strength Indicator) of the peripheral at the time of discovery. public let rssi: NSNumber public init(peripheral: BLEPeripheral, advertisementData: [String: Any], rssi: NSNumber) { diff --git a/Sources/BLECombineKit/Characteristic/BLECharacteristic+Async.swift b/Sources/BLECombineKit/Characteristic/BLECharacteristic+Async.swift index 146957a..36104cf 100644 --- a/Sources/BLECombineKit/Characteristic/BLECharacteristic+Async.swift +++ b/Sources/BLECombineKit/Characteristic/BLECharacteristic+Async.swift @@ -12,6 +12,8 @@ import Foundation @available(iOS 15, macOS 12.0, *) extension BLECharacteristic { + /// Reads the value for the characteristic asynchronously. + /// - Returns: The read data. public func readValueAsync() async throws -> BLEData { var iterator = readValue().values.makeAsyncIterator() guard let value = try await iterator.next() else { @@ -20,10 +22,14 @@ extension BLECharacteristic { return value } + /// Returns an async stream for observing value updates of the characteristic. + /// - Returns: An `AsyncThrowingStream` emitting data updates. public func observeValueStream() -> AsyncThrowingStream { return observeValue().asyncThrowingStream } + /// Sets notifications and returns an async stream for observing value updates of the characteristic. + /// - Returns: An `AsyncThrowingStream` emitting data updates. public func observeValueUpdateAndSetNotificationStream() -> AsyncThrowingStream { return observeValueUpdateAndSetNotification().asyncThrowingStream } diff --git a/Sources/BLECombineKit/Characteristic/BLECharacteristic.swift b/Sources/BLECombineKit/Characteristic/BLECharacteristic.swift index 3ab25a7..2480852 100644 --- a/Sources/BLECombineKit/Characteristic/BLECharacteristic.swift +++ b/Sources/BLECombineKit/Characteristic/BLECharacteristic.swift @@ -9,7 +9,9 @@ import Combine @preconcurrency import CoreBluetooth +/// A wrapper around `CBCharacteristic` that provides Combine-based APIs. public struct BLECharacteristic: Sendable { + /// The underlying CoreBluetooth characteristic. public let value: CBCharacteristic private let peripheral: BLEPeripheral @@ -18,22 +20,35 @@ public struct BLECharacteristic: Sendable { self.peripheral = peripheral } + /// Reads the value of the characteristic. + /// - Returns: A Publisher that emits the value or an error. public func readValue() -> AnyPublisher { peripheral.readValue(for: value) } + /// Observes value updates for the characteristic. + /// - Returns: A Publisher that emits the value whenever it updates. public func observeValue() -> AnyPublisher { peripheral.observeValue(for: value) } + /// Observes value updates and sets the notification/indication status. + /// - Returns: A Publisher that emits the value whenever it updates. public func observeValueUpdateAndSetNotification() -> AnyPublisher { peripheral.observeValueUpdateAndSetNotification(for: value) } + /// Sets the notification/indication status for the characteristic. + /// - Parameter enabled: Whether to enable or disable notifications. public func setNotifyValue(_ enabled: Bool) { peripheral.setNotifyValue(enabled, for: value) } + /// Writes a value to the characteristic. + /// - Parameters: + /// - data: The data to write. + /// - type: The type of write (with or without response). + /// - Returns: A Publisher that completes on success or fails with an error. public func writeValue( _ data: Data, type: CBCharacteristicWriteType diff --git a/Sources/BLECombineKit/Data/BLEData.swift b/Sources/BLECombineKit/Data/BLEData.swift index b206be1..eeee355 100644 --- a/Sources/BLECombineKit/Data/BLEData.swift +++ b/Sources/BLECombineKit/Data/BLEData.swift @@ -8,41 +8,53 @@ import Foundation +/// A wrapper around `Data` that provides convenient conversion methods for common types. public struct BLEData: Sendable { + /// The underlying raw data. public let value: Data public init(value: Data) { self.value = value } + /// Converts the data to a 32-bit floating point value. public var floatValue: Float32? { self.to(type: Float32.self) } + /// Converts the data to a 32-bit integer value. public var intValue: Int32? { self.to(type: Int32.self) } + /// Converts the data to an unsigned 32-bit integer value. public var uintValue: UInt32? { self.to(type: UInt32.self) } + /// Converts the data to a 16-bit integer value. public var int16Value: Int16? { self.to(type: Int16.self) } + /// Converts the data to an unsigned 16-bit integer value. public var uint16Value: UInt16? { self.to(type: UInt16.self) } + /// Converts the data to an 8-bit integer value. public var int8Value: Int8? { self.to(type: Int8.self) } + /// Converts the data to an unsigned 8-bit integer value. public var uint8Value: UInt8? { self.to(type: UInt8.self) } + /// Converts the raw data to a specified numeric type. + /// - Parameter type: The type to convert to. + /// - Returns: The converted value, or nil if the data size is insufficient. public func to(type: T.Type) -> T? where T: ExpressibleByIntegerLiteral { var genericValue: T = 0 guard value.count >= MemoryLayout.size(ofValue: genericValue) else { return nil } diff --git a/Sources/BLECombineKit/Error/BLEError.swift b/Sources/BLECombineKit/Error/BLEError.swift index c3b34d0..268dd22 100644 --- a/Sources/BLECombineKit/Error/BLEError.swift +++ b/Sources/BLECombineKit/Error/BLEError.swift @@ -21,8 +21,10 @@ extension CBATTError: @retroactive Hashable, @retroactive Identifiable { public var id: Self { self } } +/// Represents errors that can occur within the BLECombineKit framework. public enum BLEError: Error, CustomStringConvertible { + /// Errors related to the underlying CoreBluetooth framework. public enum CoreBluetoothError: Error, Hashable, Identifiable, CustomStringConvertible { case base(code: CBError.Code, description: String), ATT( code: CBATTError.Code, @@ -62,6 +64,7 @@ public enum BLEError: Error, CustomStringConvertible { } } + /// Errors related to the Bluetooth manager's state. public enum ManagerStateError: Error, Hashable, Identifiable, CustomStringConvertible { public var id: Self { self } case unknown @@ -81,6 +84,7 @@ public enum BLEError: Error, CustomStringConvertible { } } + /// Errors related to peripheral operations. public enum PeripheralError: Error, Hashable, Identifiable, CustomStringConvertible { public var id: Self { self } case invalid @@ -112,6 +116,7 @@ public enum BLEError: Error, CustomStringConvertible { } } + /// Errors related to data conversion or validation. public enum DataError: Error, Hashable, Identifiable, CustomStringConvertible { public var id: Self { self } case invalid @@ -127,30 +132,34 @@ public enum BLEError: Error, CustomStringConvertible { public var id: Self { self } + /// Error emitted when advertising is already in progress. case advertisingInProgress + /// Error emitted when advertising fails to start. case advertisingStartFailed(Error) + /// Error emitted when adding a service fails. case addingServiceFailed(CBMutableService, Error) + /// Error emitted when publishing an L2CAP channel fails. case publishingL2CAPChannelFailed(CBL2CAPPSM, Error) /// Generic error for handling `unknown` cases. case unknown - /// Error emitted when publisher turns out to be `nil`. + /// Error emitted when the underlying manager or peripheral is deallocated. case deallocated - // ManagerState + /// Error related to the manager state. case managerState(ManagerStateError) - // Peripheral + /// Error related to a peripheral. case peripheral(PeripheralError) - // Data + /// Error related to data operations. case data(DataError) - // Write + /// Error emitted when a write operation fails. case writeFailed(CoreBluetoothError) public var description: String { diff --git a/Sources/BLECombineKit/Peripheral Manager/BLEPeripheralManagerDelegate.swift b/Sources/BLECombineKit/Peripheral Manager/BLEPeripheralManagerDelegate.swift index e46c7bf..0e79566 100644 --- a/Sources/BLECombineKit/Peripheral Manager/BLEPeripheralManagerDelegate.swift +++ b/Sources/BLECombineKit/Peripheral Manager/BLEPeripheralManagerDelegate.swift @@ -14,7 +14,8 @@ import Foundation /// /// See original source on [GitHub](https://github.com/Polidea/RxBluetoothKit/blob/2a95bce60fb569df57d7bec41d215fe58f56e1d4/Source/CBPeripheralManagerDelegateWrapper.swift). /// -final class BLEPeripheralManagerDelegate: NSObject, CBPeripheralManagerDelegate, @unchecked Sendable { +final class BLEPeripheralManagerDelegate: NSObject, CBPeripheralManagerDelegate, @unchecked Sendable +{ let didUpdateState = PassthroughSubject() let isReady = PassthroughSubject() diff --git a/Sources/BLECombineKit/Peripheral Manager/StandardBLEPeripheralManager.swift b/Sources/BLECombineKit/Peripheral Manager/StandardBLEPeripheralManager.swift index 8660c60..b25848b 100644 --- a/Sources/BLECombineKit/Peripheral Manager/StandardBLEPeripheralManager.swift +++ b/Sources/BLECombineKit/Peripheral Manager/StandardBLEPeripheralManager.swift @@ -104,8 +104,7 @@ final class StandardBLEPeripheralManager: BLEPeripheralManager, @unchecked Senda if strongSelf.manager.isAdvertising { observer.send(.attachedToExternalAdvertising(strongSelf.restoredAdvertisementData)) strongSelf.restoredAdvertisementData = nil - } - else { + } else { cancelable = strongSelf.delegate.didStartAdvertising .prefix(1) .tryMap { error throws -> StartAdvertisingResult in diff --git a/Sources/BLECombineKit/Peripheral/BLEPeripheral+Async.swift b/Sources/BLECombineKit/Peripheral/BLEPeripheral+Async.swift index 0aeb8c5..232c7fc 100644 --- a/Sources/BLECombineKit/Peripheral/BLEPeripheral+Async.swift +++ b/Sources/BLECombineKit/Peripheral/BLEPeripheral+Async.swift @@ -12,6 +12,9 @@ import Foundation @available(iOS 15, macOS 12.0, *) extension BLEPeripheral { + /// Connects to the peripheral asynchronously. + /// - Parameter options: Optional connection options. + /// - Returns: The connected peripheral. @discardableResult public func connectAsync( with options: [String: Any]? @@ -26,6 +29,9 @@ extension BLEPeripheral { return connectedPeripheral } + /// Discovers services for the peripheral asynchronously. + /// - Parameter serviceUUIDs: Optional list of service UUIDs to discover. + /// - Returns: An array of discovered services. public func discoverServicesAsync( serviceUUIDs: [CBUUID]? ) async throws -> [BLEService] { @@ -40,6 +46,11 @@ extension BLEPeripheral { return results } + /// Discovers characteristics for a specified service asynchronously. + /// - Parameters: + /// - characteristicUUIDs: Optional list of characteristic UUIDs to discover. + /// - service: The service to discover characteristics for. + /// - Returns: An array of discovered characteristics. public func discoverCharacteristicsAsync( characteristicUUIDs: [CBUUID]?, for service: CBService @@ -55,6 +66,9 @@ extension BLEPeripheral { return results } + /// Reads the value for a specific characteristic asynchronously. + /// - Parameter characteristic: The characteristic to read from. + /// - Returns: The read data. public func readValueAsync(for characteristic: CBCharacteristic) async throws -> BLEData { var iterator = readValue(for: characteristic).values.makeAsyncIterator() guard let value = try await iterator.next() else { @@ -63,18 +77,29 @@ extension BLEPeripheral { return value } + /// Returns an async stream for observing value updates of a characteristic. + /// - Parameter characteristic: The characteristic to observe. + /// - Returns: An `AsyncThrowingStream` emitting data updates. public func observeValueStream( for characteristic: CBCharacteristic ) -> AsyncThrowingStream { return observeValue(for: characteristic).asyncThrowingStream } + /// Sets notifications and returns an async stream for observing value updates of a characteristic. + /// - Parameter characteristic: The characteristic to observe. + /// - Returns: An `AsyncThrowingStream` emitting data updates. public func observeValueUpdateAndSetNotificationStream( for characteristic: CBCharacteristic ) -> AsyncThrowingStream { return observeValueUpdateAndSetNotification(for: characteristic).asyncThrowingStream } + /// Writes data to a specific characteristic asynchronously. + /// - Parameters: + /// - data: The data to write. + /// - characteristic: The characteristic to write to. + /// - type: The type of write operation. public func writeValueAsync( _ data: Data, for characteristic: CBCharacteristic, diff --git a/Sources/BLECombineKit/Peripheral/StandardBLEPeripheral.swift b/Sources/BLECombineKit/Peripheral/StandardBLEPeripheral.swift index 725f0a3..2d99502 100644 --- a/Sources/BLECombineKit/Peripheral/StandardBLEPeripheral.swift +++ b/Sources/BLECombineKit/Peripheral/StandardBLEPeripheral.swift @@ -23,7 +23,7 @@ final class StandardBLEPeripheral: BLETrackedPeripheral, @unchecked Sendable { // Following SE-0481, this will need to become `weak let` to conform with strict concurrency. // See https://github.com/swiftlang/swift-evolution/blob/main/proposals/0481-weak-let.md /// Reference to te BLECentralManager. - // nonisolated(unsafe) + // nonisolated(unsafe) private weak var centralManager: BLECentralManager? /// Cancellable reference to the connect publisher. @@ -58,8 +58,7 @@ final class StandardBLEPeripheral: BLETrackedPeripheral, @unchecked Sendable { let makeDisconnected: AnyPublisher = if self.connectionState.value { self.disconnect().ignoreFailure() - } - else { + } else { Empty().eraseToAnyPublisher() } @@ -81,8 +80,7 @@ final class StandardBLEPeripheral: BLETrackedPeripheral, @unchecked Sendable { .sink { [weak self] successfullyConnected in if let self, successfullyConnected { promise(.success(self)) - } - else { + } else { promise(.failure(BLEError.peripheral(.connectionFailure))) } } diff --git a/Sources/BLECombineKit/Service/BLEService+Async.swift b/Sources/BLECombineKit/Service/BLEService+Async.swift index 06860c6..02a03c5 100644 --- a/Sources/BLECombineKit/Service/BLEService+Async.swift +++ b/Sources/BLECombineKit/Service/BLEService+Async.swift @@ -12,6 +12,9 @@ import Foundation @available(iOS 15, macOS 12.0, *) extension BLEService { + /// Discovers characteristics for the service asynchronously. + /// - Parameter characteristicUUIDs: Optional list of characteristic UUIDs to discover. + /// - Returns: An array of discovered characteristics. public func discoverCharacteristicsAsync( characteristicUUIDs: [CBUUID]? ) async throws -> [BLECharacteristic] { diff --git a/Sources/BLECombineKit/Service/BLEService.swift b/Sources/BLECombineKit/Service/BLEService.swift index 3743421..3961853 100644 --- a/Sources/BLECombineKit/Service/BLEService.swift +++ b/Sources/BLECombineKit/Service/BLEService.swift @@ -10,7 +10,9 @@ import Combine @preconcurrency import CoreBluetooth import Foundation +/// A wrapper around `CBService` that provides Combine-based APIs. public struct BLEService: Sendable { + /// The underlying CoreBluetooth service. public let value: CBService private let peripheral: BLEPeripheral @@ -19,6 +21,9 @@ public struct BLEService: Sendable { self.peripheral = peripheral } + /// Discovers characteristics for the service. + /// - Parameter characteristicUUIDs: Optional list of characteristic UUIDs to discover. + /// - Returns: A Publisher that emits discovered characteristics or an error. public func discoverCharacteristics( characteristicUUIDs: [CBUUID]? ) -> AnyPublisher { diff --git a/Sources/BLECombineKit/Utils/Publisher+AsyncStream.swift b/Sources/BLECombineKit/Utils/Publisher+AsyncStream.swift index 6eca450..842dfbd 100644 --- a/Sources/BLECombineKit/Utils/Publisher+AsyncStream.swift +++ b/Sources/BLECombineKit/Utils/Publisher+AsyncStream.swift @@ -16,14 +16,13 @@ extension AnyPublisher where Output: Sendable { .sink { completion in if case .failure(let error) = completion { continuation.finish(throwing: error) - } - else { + } else { continuation.finish() } } receiveValue: { data in continuation.yield(data) } - + let uncheckedCancellable = UncheckedSendable(cancellable) continuation.onTermination = { _ in uncheckedCancellable.value.cancel() diff --git a/Tests/BLECombineKitTests/BLEDataTests.swift b/Tests/BLECombineKitTests/BLEDataTests.swift index f0ca400..1f671e2 100644 --- a/Tests/BLECombineKitTests/BLEDataTests.swift +++ b/Tests/BLECombineKitTests/BLEDataTests.swift @@ -39,8 +39,7 @@ final class BLEDataTests: XCTestCase { if let result = data.to(type: Float32.self) { XCTAssertEqual(float32, result, accuracy: 0.000001) - } - else { + } else { XCTFail() } } diff --git a/Tests/BLECombineKitTests/BLEPeripheralManagerTests.swift b/Tests/BLECombineKitTests/BLEPeripheralManagerTests.swift index d763944..7cf3783 100644 --- a/Tests/BLECombineKitTests/BLEPeripheralManagerTests.swift +++ b/Tests/BLECombineKitTests/BLEPeripheralManagerTests.swift @@ -86,8 +86,7 @@ final class BLEPeripheralManagerTests: XCTestCase { .sink { completion in if case .failure(let error) = completion { XCTFail("\(#function): \(error)") - } - else { + } else { expectation.fulfill() } } receiveValue: { service in