- 所有公开 API 改为 completion handler (Result<T, Error>) - 状态通知改为 DuooomiBleSDKDelegate 协议 - 移除 Sendable、@MainActor、AsyncStream、CheckedContinuation - 网络请求改为 URLSession.dataTask 回调 - BLE 通信改为 delegate + 闭包模式 - deployment target 降至 iOS 12.2 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
232 lines
7.8 KiB
Swift
232 lines
7.8 KiB
Swift
import CoreBluetooth
|
|
import Foundation
|
|
|
|
/// BLE 客户端内部回调协议
|
|
protocol BleClientDelegate: AnyObject {
|
|
func bleClient(_ client: BleClient, didDiscoverDevice device: DiscoveredDevice)
|
|
func bleClient(_ client: BleClient, didReceiveData data: Data)
|
|
}
|
|
|
|
final class BleClient: NSObject {
|
|
|
|
weak var delegate: BleClientDelegate?
|
|
var onConnectionStateChange: ((ConnectionState) -> Void)?
|
|
var onDisconnected: (() -> Void)?
|
|
|
|
private let queue = DispatchQueue(label: "com.duooomi.ble.client", qos: .userInitiated)
|
|
private var centralManager: CBCentralManager!
|
|
private var connectedPeripheral: CBPeripheral?
|
|
private var writeCharacteristic: CBCharacteristic?
|
|
private var readCharacteristic: CBCharacteristic?
|
|
|
|
// Connect/Disconnect callbacks
|
|
private var connectCompletion: ((Result<CBPeripheral, Error>) -> Void)?
|
|
private var disconnectCompletion: ((Result<Void, Error>) -> Void)?
|
|
private var connectTimeoutWork: DispatchWorkItem?
|
|
|
|
var bluetoothState: CBManagerState {
|
|
centralManager.state
|
|
}
|
|
|
|
var maximumWriteLength: Int {
|
|
connectedPeripheral?.maximumWriteValueLength(for: .withoutResponse) ?? 182
|
|
}
|
|
|
|
override init() {
|
|
super.init()
|
|
centralManager = CBCentralManager(delegate: self, queue: queue)
|
|
}
|
|
|
|
// MARK: - Scan
|
|
|
|
func scan(serviceUUIDs: [CBUUID] = [BleUUIDs.service]) {
|
|
guard centralManager.state == .poweredOn else {
|
|
BleLog.w("Bluetooth not powered on", "BLE")
|
|
return
|
|
}
|
|
centralManager.scanForPeripherals(
|
|
withServices: serviceUUIDs,
|
|
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
|
|
)
|
|
}
|
|
|
|
func stopScan() {
|
|
centralManager.stopScan()
|
|
}
|
|
|
|
// MARK: - Connect
|
|
|
|
func connect(deviceId: String, timeout: TimeInterval = 30, completion: @escaping (Result<CBPeripheral, Error>) -> Void) {
|
|
guard centralManager.state == .poweredOn else {
|
|
completion(.failure(DuooomiBleError.bluetoothNotPoweredOn("Bluetooth not powered on")))
|
|
return
|
|
}
|
|
|
|
let peripherals = centralManager.retrievePeripherals(withIdentifiers: [UUID(uuidString: deviceId)!])
|
|
guard let peripheral = peripherals.first else {
|
|
completion(.failure(DuooomiBleError.connectionFailed("Device not found: \(deviceId)")))
|
|
return
|
|
}
|
|
|
|
self.connectCompletion = completion
|
|
peripheral.delegate = self
|
|
centralManager.connect(peripheral, options: nil)
|
|
|
|
// Timeout
|
|
let work = DispatchWorkItem { [weak self] in
|
|
guard let self = self, self.connectCompletion != nil else { return }
|
|
self.centralManager.cancelPeripheralConnection(peripheral)
|
|
let cb = self.connectCompletion
|
|
self.connectCompletion = nil
|
|
cb?(.failure(DuooomiBleError.connectionFailed("Connection timeout")))
|
|
}
|
|
connectTimeoutWork = work
|
|
queue.asyncAfter(deadline: .now() + timeout, execute: work)
|
|
}
|
|
|
|
// MARK: - Disconnect
|
|
|
|
func disconnect(completion: @escaping (Result<Void, Error>) -> Void) {
|
|
guard let peripheral = connectedPeripheral else {
|
|
completion(.success(()))
|
|
return
|
|
}
|
|
disconnectCompletion = completion
|
|
centralManager.cancelPeripheralConnection(peripheral)
|
|
}
|
|
|
|
// MARK: - Write
|
|
|
|
func writeWithoutResponse(_ data: Data) throws {
|
|
guard let peripheral = connectedPeripheral, let char = writeCharacteristic else {
|
|
throw DuooomiBleError.notConnected
|
|
}
|
|
peripheral.writeValue(data, for: char, type: .withoutResponse)
|
|
}
|
|
|
|
// MARK: - Retrieve Connected
|
|
|
|
func retrieveConnectedDevices() -> [DiscoveredDevice] {
|
|
centralManager.retrieveConnectedPeripherals(withServices: [BleUUIDs.service]).map {
|
|
DiscoveredDevice(id: $0.identifier.uuidString, name: $0.name, rssi: 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - CBCentralManagerDelegate
|
|
|
|
extension BleClient: CBCentralManagerDelegate {
|
|
|
|
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
|
BleLog.i("Bluetooth state: \(central.state.rawValue)", "BLE")
|
|
}
|
|
|
|
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
|
|
advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
|
let device = DiscoveredDevice(
|
|
id: peripheral.identifier.uuidString,
|
|
name: peripheral.name,
|
|
rssi: RSSI.intValue
|
|
)
|
|
delegate?.bleClient(self, didDiscoverDevice: device)
|
|
}
|
|
|
|
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
|
connectTimeoutWork?.cancel()
|
|
connectTimeoutWork = nil
|
|
connectedPeripheral = peripheral
|
|
peripheral.delegate = self
|
|
peripheral.discoverServices([BleUUIDs.service])
|
|
}
|
|
|
|
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
|
connectTimeoutWork?.cancel()
|
|
connectTimeoutWork = nil
|
|
let cb = connectCompletion
|
|
connectCompletion = nil
|
|
cb?(.failure(error ?? DuooomiBleError.connectionFailed("Unknown")))
|
|
}
|
|
|
|
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
|
connectedPeripheral = nil
|
|
writeCharacteristic = nil
|
|
readCharacteristic = nil
|
|
|
|
if let cb = disconnectCompletion {
|
|
disconnectCompletion = nil
|
|
if let error = error {
|
|
cb(.failure(error))
|
|
} else {
|
|
cb(.success(()))
|
|
}
|
|
}
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.onDisconnected?()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - CBPeripheralDelegate
|
|
|
|
extension BleClient: CBPeripheralDelegate {
|
|
|
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
|
if let error = error {
|
|
let cb = connectCompletion
|
|
connectCompletion = nil
|
|
cb?(.failure(error))
|
|
return
|
|
}
|
|
guard let service = peripheral.services?.first(where: { $0.uuid == BleUUIDs.service }) else {
|
|
let cb = connectCompletion
|
|
connectCompletion = nil
|
|
cb?(.failure(DuooomiBleError.serviceNotFound))
|
|
return
|
|
}
|
|
peripheral.discoverCharacteristics(
|
|
[BleUUIDs.writeCharacteristic, BleUUIDs.readCharacteristic],
|
|
for: service
|
|
)
|
|
}
|
|
|
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
|
if let error = error {
|
|
let cb = connectCompletion
|
|
connectCompletion = nil
|
|
cb?(.failure(error))
|
|
return
|
|
}
|
|
guard let characteristics = service.characteristics else {
|
|
let cb = connectCompletion
|
|
connectCompletion = nil
|
|
cb?(.failure(DuooomiBleError.characteristicNotFound))
|
|
return
|
|
}
|
|
|
|
for char in characteristics {
|
|
if char.uuid == BleUUIDs.writeCharacteristic {
|
|
writeCharacteristic = char
|
|
} else if char.uuid == BleUUIDs.readCharacteristic {
|
|
readCharacteristic = char
|
|
peripheral.setNotifyValue(true, for: char)
|
|
}
|
|
}
|
|
|
|
guard writeCharacteristic != nil, readCharacteristic != nil else {
|
|
let cb = connectCompletion
|
|
connectCompletion = nil
|
|
cb?(.failure(DuooomiBleError.characteristicNotFound))
|
|
return
|
|
}
|
|
|
|
let cb = connectCompletion
|
|
connectCompletion = nil
|
|
cb?(.success(peripheral))
|
|
}
|
|
|
|
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
|
guard error == nil, let data = characteristic.value else { return }
|
|
delegate?.bleClient(self, didReceiveData: data)
|
|
}
|
|
}
|