BlueConnect

Cocoapods Version GitHub Release GitHub License GitHub Actions Workflow Status

BlueConnect 是一个基于 CoreBluetooth 构建的 Swift 框架,旨在简化与蓝牙低功耗 (BLE) 外围设备的交互。通过封装 Core Bluetooth 的功能,BlueConnect 提供了一种现代化的 BLE 通信方式。它利用异步编程模型,允许你使用传统的回调或 Swift 并发 async/await 与外围设备交互。

此外,BlueConnect 还支持通过 Combine 发布者进行事件通知,提供了一种更精简和响应式的 BLE 事件处理方式。 通过利用 Swift 协议,BlueConnect 还简化了单元测试,使构建可测试的库和与 BLE 外围设备交互的应用程序变得更容易。 异步通信、事件驱动架构和可测试性的结合确保了高度灵活和现代化的 BLE 开发体验。

目录

功能亮点

用法

BlueConnect 将其功能委托给多个代理

由于与 BLE 外围设备的通信需要编码和解码原始数据,因此 BlueConnect 通过提供各种围绕 BlePeripheralProxy 包装的代理协议来简化这种交互。 你可以通过遵循这些协议来创建自定义代理,使你能够执行诸如读取、写入和启用 BLE 外围设备特征值的通知之类的操作

扫描外围设备

你可以通过调用 BleCentralManagerProxy 上的 scanForPeripherals 来开始扫描 BLE 外围设备。 此方法允许你提供 BLE 扫描选项,这些选项会直接传递到底层的 CBCentralManager。 你还可以指定一个可选的超时时间(如果未提供,则默认为 60 秒)。 该方法返回一个发布者,你可以使用它来监听已发现的 BLE 外围设备,以及完成或失败事件。

import BlueConnect
import Combine
import CoreBluetooth

var subscriptions: Set<AnyCancellable> = []
let centralManagerProxy = BleCentralManagerProxy()

do {
    try await centralManagerProxy.waitUntilReady()
    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)
} catch {
    print("peripheral scan failed with error: \(error)")
}

如果指定了超时时间,外围设备扫描将自动停止。 但是,你也可以随时通过调用 BleCentralManagerProxy 上的 stopScan 手动停止扫描。

连接外围设备

要连接到 BLE 外围设备,请使用 BleCentralManagerProxy 上的 connect 方法。 你可以提供将转发到底层 CBCentralManager 的连接选项。 此外,你可以选择指定一个超时时间(如果未提供,则默认为无超时)。 连接的建立也将通过 Combine 发布者进行通知,使你可以对连接状态做出反应。

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.
    try await centralManagerProxy.waitUntilReady()
    try await centralManagerProxy.connect(
        peripheral: peripheral,
        options: nil,
        timeout: .seconds(60))
    print("peripheral '\(peripheral.identifier)' connected")
} catch {
    print("peripheral connection failed with error: \(error)")
}

断开外围设备

要断开已连接的 BLE 外围设备,请使用 BleCentralManagerProxy 上的 disconnect 方法。 断开连接事件将通过 Combine 发布者进行通知,使你能够响应连接状态的变化。

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.waitUntilReady()
    try await centralManagerProxy.disconnect(peripheral: peripheral)
    print("peripheral '\(peripheral.identifier)' disconnected")
} catch {
    print("peripheral disconnection failed with error: \(error)")
}

读取已连接外围设备的 RSSI

要读取已连接外围设备的 RSSI,你可以使用 BlePeripheralProxyreadRSSI 方法。

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)")
}

读取特征值

要读取特征值,你可以通过遵循 BleCharacteristicReadProxy 协议来创建自己的代理,该协议提供了从特征值读取数据所需的必要功能。

import BlueConnect
import Combine
import CoreBluetooth

// Declare your type conforming to the BleCharacteristicReadProxy protocol.
struct SerialNumberProxy: BleCharacteristicReadProxy {
    
    typealias ValueType = String
    
    let characteristicUUID: CBUUID = CBUUID(string: "2A25")
    let 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)")
}

写入特征值

要写入特征值,你可以通过遵循 BleCharacteristicWriteProxy 协议来创建自己的代理,该协议提供了将数据写入特征值所需的必要功能。

import BlueConnect
import Combine
import CoreBluetooth

// Declare your type conforming to the BleCharacteristicWriteProxy protocol.
struct PinProxy: BleCharacteristicWriteProxy {
    
    typealias ValueType = String
    
    let characteristicUUID: CBUUID = CBUUID(string: "5A8F2E01-58D9-4B0B-83B8-843402E49293")
    let 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)")
}

启用特征值的 Notify

要被通知特征值数据何时更新,你可以通过遵循 BleCharacteristicNotifyProxyBleCharacteristicReadProxy 协议来创建你自己的代理。 BleCharacteristicNotifyProxy 提供了在特征值上启用数据通知所需的必要功能,而 BleCharacteristicReadProxy 提供了从特征值接收数据所需的必要功能。

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
    
    let characteristicUUID: CBUUID = CBUUID(string: "2A37")
    let 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)")
}

在你的代码库中提供单元测试

通过利用 BleCentralManagerProxyBlePeripheralManagerProxyBlePeripheralProxy 的强大功能,你可以轻松地为你的代码库创建模拟,使你可以在受控环境中运行单元测试。 这是因为 BleCentralManagerProxyBlePeripheralManagerProxyBlePeripheralProxy 在初始化期间依赖于协议而实现的

你可以创建你的中央管理器和外围设备的模拟版本,并在 BleCentralManagerProxyBlePeripheralManagerProxyBlePeripheralProxy 的初始化期间提供它们。 这可以通过使用依赖注入 (DI) 容器(例如 Factory)轻松实现。

安装

Cocoapods

pod 'BlueConnect', '~> 1.3.1'

Swift Package Manager

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

贡献

如果你喜欢这个项目,你可以通过以下方式贡献

许可证

MIT License

Copyright (c) 2025 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.