BlueConnect is a Swift framework built on top of CoreBluetooth, designed to simplify interaction with Bluetooth Low Energy (BLE) peripherals. By wrapping Core Bluetooth functionalities, BlueConnect provides a modern approach to BLE communication. It leverages asynchronous programming models, allowing you to interact with peripherals using either traditional callbacks or Swift concurrency with async/await.
Additionally, BlueConnect supports event notifications through Combine publishers, offering a more streamlined and reactive way to handle BLE events. By leveraging Swift protocols, BlueConnect also facilitates unit testing, making it easier to build testable libraries and apps that interact with BLE peripherals. This combination of asynchronous communication, event-driven architecture, and testability ensures a highly flexible and modern BLE development experience.
- Feature highlights
- Usage
- Providing unit tests in your codebase
- Installation
- Documentation
- Contributing
- License
- Supports both iOS and macOS.
- Fully covered by unit tests.
- Replaces the delegate-based interface of
CBCentralManager
andCBPeripheral
with closures and Swift concurrency (async/await). - Delivers event notifications via Combine publishers for both
CBCentralManager
andCBPeripheral
. - 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 manageCBPeripheral
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.
BlueConnect delegates its functionality to two proxies:
BleCentralManagerProxy
: A wrapper aroundCBCentralManager
, responsible for connecting, disconnecting, and scanning for peripherals. It publishes events using both asynchronous methods (via callbacks or Swift concurrency) and Combine publishers.BlePeripheralProxy
: A wrapper aroundCBPeripheral
that handles communication with BLE peripherals and manages data transmission. Like the central manager proxy, it publishes events through asynchronous methods and Combine publishers.
Since communication with BLE peripherals requires encoding and decoding raw data, BlueConnect simplifies this
interaction by offering various proxy protocols that wrap around BlePeripheralProxy
. You can create custom proxies
by conforming to these protocols, enabling you to perform operations like reading, writing, and enabling notifications
on BLE peripheral characteristics:
BleCharacteristicProxy
: The base proxy for discovering characteristics.BleCharacteristicReadProxy
: A proxy for reading data from a characteristic.BleCharacteristicWriteProxy
: A proxy for writing data to a characteristic.BleCharacteristicWriteWithoutResponseProxy
: A proxy for writing data to a characteristic without awaiting a response from the BLE peripheral.BleCharacteristicNotifyProxy
: A proxy for enabling notifications on a characteristic.
You can start scanning for BLE peripherals by calling scanForPeripherals
on the BleCentralManagerProxy
.
This method allows you to provide BLE scan options, which are passed directly to the underlying CBCentralManager
.
You can also specify an optional timeout (defaulting to 60 seconds if not provided).
The method returns a publisher that you can use to listen for discovered BLE peripherals, along with completion or
failure events.
import BlueConnect
import Combine
import CoreBluetooth
var subscriptions: Set<AnyCancellable> = []
let centralManagerProxy = BleCentralManagerProxy()
centralManagerProxy.scanForPeripherals(timeout: .seconds(30))
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { completion in
// This is called when the peripheral scan is completed or upon scan failure.
switch completion {
case .finished:
print("peripheral scan completed successfully")
case .failure(let error):
print("peripheral scan terminated with error: \(error)")
}
},
receiveValue: { peripheral, advertisementData, RSSI in
// This is called multiple times for every discovered peripheral.
print("peripheral '\(peripheral.identifier)' was discovered")
}
)
.store(in: &subscriptions)
The peripheral scan will automatically stop if a timeout is specified. However, you can also manually stop the scan at
any time by calling stopScan
on the BleCentralManagerProxy
.
To connect to a BLE peripheral, use the connect
method on the BleCentralManagerProxy
.
You can provide connection options that will be forwarded to the underlying CBCentralManager
.
Additionally, you have the option to specify a timeout (defaulting to no timeout if not provided).
The establishment of the connection will be notified through the Combine publisher, allowing you
to react to the connection status.
import BlueConnect
import Combine
import CoreBluetooth
var subscriptions: Set<AnyCancellable> = []
let centralManagerProxy = BleCentralManagerProxy()
// You can optionally subscribe a publisher to be notified when a connection is established.
centralManagerProxy.didConnectPublisher
.receive(on: DispatchQueue.main)
.sink { peripheral in
print("peripheral '\(peripheral.identifier)' connected")
}
.store(in: &subscriptions)
// You can optionally subscribe a publisher to be notified when a connection attempt fails.
centralManagerProxy.didFailToConnectPublisher
.receive(on: DispatchQueue.main)
.sink { peripheral, error in
print("peripheral '\(peripheral.identifier)' failed to connect with error: \(error)")
}
.store(in: &subscriptions)
do {
// The following will try to establish a connection to a BLE peripheral for at most 60 seconds.
// If the connection cannot be established within the specified amount of time, the connection
// attempt is dropped and notified by raising an appropriate error. If the connection is not
// established then nothing is advertised on the combine publisher.
try await centralManagerProxy.connect(
peripheral: peripheral,
options: nil,
timeout: .seconds(60))
print("peripheral '\(peripheral.identifier)' connected")
} catch {
print("peripheral connection failed with error: \(error)")
}
To disconnect a connected BLE peripheral, use the disconnect
method on the BleCentralManagerProxy
. The
disconnection event will be notified through the Combine publisher, enabling you to respond to changes in the
connection status.
import BlueConnect
import Combine
import CoreBluetooth
var subscriptions: Set<AnyCancellable> = []
let centralManagerProxy = BleCentralManagerProxy()
// You can optionally subscribe a publisher to be notified when a peripheral is disconnected.
centralManagerProxy.didDisconnectPublisher
.receive(on: DispatchQueue.main)
.sink { peripheral in
print("peripheral '\(peripheral.identifier)' disconnected")
}
.store(in: &subscriptions)
do {
// The following will disconnect a BLE peripheral.
try await centralManagerProxy.disconnect(peripheral: peripheral)
print("peripheral '\(peripheral.identifier)' disconnected")
} catch {
print("peripheral disconnection failed with error: \(error)")
}
To read a characteristic, you can create your own proxy by conforming to the BleCharacteristicReadProxy
protocol,
which provides the necessary functionality for reading data from a characteristic.
import BlueConnect
import Combine
import CoreBluetooth
// Declare your type conforming to the BleCharacteristicReadProxy protocol.
struct SerialNumberProxy: BleCharacteristicReadProxy {
typealias ValueType = String
var characteristicUUID: CBUUID = CBUUID(string: "2A25")
var serviceUUID: CBUUID = CBUUID(string: "180A")
weak var peripheralProxy: BlePeripheralProxy?
init(peripheralProxy: BlePeripheralProxy) {
self.peripheralProxy = peripheralProxy
}
func decode(_ data: Data) throws -> String {
return String(decoding: data, as: UTF8.self)
}
}
var subscriptions: Set<AnyCancellable> = []
let peripheralProxy = BlePeripheralProxy(peripheral: peripheral)
let serialNumberProxy = SerialNumberProxy(peripheralProxy: peripheralProxy)
// You can optionally subscribe a publisher to be notified when data is read from the characteristic.
// The publisher sink method won't be triggered when reading data from local cache.
serialNumberProxy.didUpdateValuePublisher
.receive(on: DispatchQueue.main)
.sink { serialNumber in
print("serial number is \(serialNumber)")
}
.store(in: &subscriptions)
do {
// The following will read the serial number of the characteristic.
// If the serial number characteristic, or the service backing the characteristic, has not been discovered yet,
// a silent discovery is performed before attempting to read data from the characteristic.
let serialNumber = try await serialNumberProxy.read(cachePolicy: .always, timeout: .seconds(10))
print("serial number is \(serialNumber)")
} catch {
print("failed to read serial number with error: \(error)")
}
To write a characteristic, you can create your own proxy by conforming to the BleCharacteristicWriteProxy
protocol,
which provides the necessary functionality for writing data to a characteristic.
import BlueConnect
import Combine
import CoreBluetooth
// Declare your type conforming to the BleCharacteristicWriteProxy protocol.
struct PinProxy: BleCharacteristicWriteProxy {
typealias ValueType = String
var characteristicUUID: CBUUID = CBUUID(string: "5A8F2E01-58D9-4B0B-83B8-843402E49293")
var serviceUUID: CBUUID = CBUUID(string: "C5405A74-7C07-4702-A631-9D5EBF007DAE")
weak var peripheralProxy: BlePeripheralProxy?
init(peripheralProxy: BlePeripheralProxy) {
self.peripheralProxy = peripheralProxy
}
func encode(_ value: String) throws -> Data {
return Data(value.utf8)
}
}
var subscriptions: Set<AnyCancellable> = []
let peripheralProxy = BlePeripheralProxy(peripheral: peripheral)
let pinProxy = PinProxy(peripheralProxy: peripheralProxy)
// You can optionally subscribe a publisher to be notified when data is written to the characteristic.
pinProxy.didWriteValuePublisher
.receive(on: DispatchQueue.main)
.sink {
print("data was written to the characteristic")
}
.store(in: &subscriptions)
do {
// The following will write the PIN to the PIN characteristic.
// If the PIN characteristic, or the service backing the PIN characteristic, has not been discovered yet,
// a silent discovery is performed before attempting to write data to the characteristic.
try await pinProxy.write(value: "1234", timeout: .seconds(10))
print("data was written to the characteristic")
} catch {
print("failed to write data to the characteristic with error: \(error)")
}
To be notified when characteristic data is updated, you can create your own proxy by conforming to the
BleCharacteristicNotifyProxy
and BleCharacteristicReadProxy
protocols. The BleCharacteristicNotifyProxy
provides the necessary functionality to enable data notify on the characteristic while the BleCharacteristicReadProxy
provides the necessary functionality for receiving data from a characteristic.
import BlueConnect
import Combine
import CoreBluetooth
// Declare your type conforming to the BleCharacteristicNotifyProxy and BleCharacteristicReadProxy protocols.
// You can omit BleCharacteristicReadProxy if you are not interested in receiving characteristic data and you just want
// to toggle the notification status for a characteristic.
struct HeartRateProxy: BleCharacteristicReadProxy, BleCharacteristicNotifyProxy {
typealias ValueType = Int
var characteristicUUID: CBUUID = CBUUID(string: "2A37")
var serviceUUID: CBUUID = CBUUID(string: "180D")
weak var peripheralProxy: BlePeripheralProxy?
init(peripheralProxy: BlePeripheralProxy) {
self.peripheralProxy = peripheralProxy
}
func decode(_ data: Data) throws -> Int {
return Int(data.first ?? 0x00)
}
}
var subscriptions: Set<AnyCancellable> = []
let peripheralProxy = BlePeripheralProxy(peripheral: peripheral)
let heartRateProxy = HeartRateProxy(peripheralProxy: peripheralProxy)
// You can optionally subscribe a publisher to be triggered when the notify flag is changed.
heartRateProxy.didUpdateNotificationStatePublisher
.receive(on: DispatchQueue.main)
.sink { enabled in
print("notification enabled: \(enabled)")
}
.store(in: &subscriptions)
// You can optionally subscribe a publisher to be notified when data is received from the characteristic.
heartRateProxy.didUpdateValuePublisher
.receive(on: DispatchQueue.main)
.sink { heartRate in
print("heart rate is \(heartRate)")
}
.store(in: &subscriptions)
do {
// The following will enable data notify on the Heart Rate characteristic
// If the Heart Rate characteristic, or the service backing the Heart Rate characteristic, has not
// been discovered yet, a silent discovery is performed before attempting to enable data notify on the
// characteristic.
try await heartRateProxy.setNotify(enabled: true, timeout: .seconds(10))
print("notify enabled on the characteristic")
} catch {
print("failed to enable notify on the characteristic with error: \(error)")
}
To read connected peripheral RSSI you can use the readRSSI
method of the BlePeripheralProxy
.
import BlueConnect
import Combine
import CoreBluetooth
var subscriptions: Set<AnyCancellable> = []
let peripheralProxy = BlePeripheralProxy(peripheral: peripheral)
// You can optionally subscribe a publisher to be triggered when the RSSI value is read.
peripheralProxy.didUpdateRSSIPublisher
.receive(on: DispatchQueue.main)
.sink { value in
print("RSSI: \(value)")
}
.store(in: &subscriptions)
do {
// The following will read the RSSI value from a connected peripheral.
let value = try await peripheralProxy.readRSSI(timeout: .seconds(10))
print("RSSI: \(value)")
} catch {
print("failed to read peripheral RSSI with error: \(error)")
}
By leveraging the power of BleCentralManagerProxy
and BlePeripheralProxy
, you can easily create mocks for your codebase, allowing you to run unit tests in a controlled environment. This is made possible because BleCentralManagerProxy
and BlePeripheralProxy
rely on protocols during initialization:
BleCentralManager
: A protocol that defines all public methods ofCBCentralManager
.CBCentralManager
itself conforms to this protocol.BlePeripheral
: A protocol that defines all public methods ofCBPeripheral
.CBPeripheral
itself conforms to this protocol.
You can create mock versions of your central manager and peripheral(s) and supply them during the initialization of
BleCentralManagerProxy
and BlePeripheralProxy
. This can be easily achieved by using a dependency injection (DI) container such as Factory.
- An example of a mocked central manager can be found here.
- An example of a mocked peripheral can be found here.
pod 'BlueConnect', '~> 1.2.1'
dependencies: [
.package(url: "https://github.com/danielepantaleone/BlueConnect.git", .upToNextMajor(from: "1.2.1"))
]
If you like this project, you can contribute by:
- Submitting a bug report via an issue
- Contributing code through a pull request
MIT License
Copyright (c) 2024 Daniele Pantaleone
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.