Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
11 changes: 0 additions & 11 deletions .swift-format

This file was deleted.

105 changes: 76 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions Sources/BLECombineKit/Central/BLECentralManager+Async.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]?
Expand All @@ -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]?
Expand Down
4 changes: 4 additions & 0 deletions Sources/BLECombineKit/Central/BLEScanResult.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<BLEData, Error> {
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<BLEData, Error> {
return observeValueUpdateAndSetNotification().asyncThrowingStream
}
Expand Down
15 changes: 15 additions & 0 deletions Sources/BLECombineKit/Characteristic/BLECharacteristic.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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<BLEData, BLEError> {
peripheral.readValue(for: value)
}

/// Observes value updates for the characteristic.
/// - Returns: A Publisher that emits the value whenever it updates.
public func observeValue() -> AnyPublisher<BLEData, BLEError> {
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<BLEData, BLEError> {
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
Expand Down
12 changes: 12 additions & 0 deletions Sources/BLECombineKit/Data/BLEData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(type: T.Type) -> T? where T: ExpressibleByIntegerLiteral {
var genericValue: T = 0
guard value.count >= MemoryLayout.size(ofValue: genericValue) else { return nil }
Expand Down
19 changes: 14 additions & 5 deletions Sources/BLECombineKit/Error/BLEError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CBManagerState, Never>()
let isReady = PassthroughSubject<Void, Never>()
Expand Down
Loading
Loading