import CoreBluetooth import Foundation /// CoreBluetooth 封装,提供 async API final class BleClient: NSObject, @unchecked Sendable { private var centralManager: CBCentralManager! private let queue = DispatchQueue(label: "com.duooomi.ble.client") private(set) var connectedPeripheral: CBPeripheral? private(set) var writeCharacteristic: CBCharacteristic? private(set) var readCharacteristic: CBCharacteristic? /// 已发现但尚未连接的外设缓存 (scan 时填充) private var discoveredPeripherals: [UUID: CBPeripheral] = [:] // MARK: - Continuations private var connectContinuation: CheckedContinuation? private var disconnectContinuation: CheckedContinuation? // MARK: - Streams private var scanContinuation: AsyncStream.Continuation? private var notificationContinuation: AsyncStream.Continuation? // MARK: - Callbacks var onConnectionStateChange: ((ConnectionState) -> Void)? var onDisconnected: (() -> Void)? // MARK: - Init override init() { super.init() centralManager = CBCentralManager(delegate: self, queue: queue) BleLog.i("BleClient initialized", "BLE") } // MARK: - Public API /// 当前蓝牙状态。 var bluetoothState: CBManagerState { centralManager.state } /// 当前连接在 `.withoutResponse` 模式下允许写入的最大长度。 var maximumWriteLength: Int { connectedPeripheral?.maximumWriteValueLength(for: .withoutResponse) ?? 20 } /// 扫描包含目标 Service 的外设。 /// - Parameter serviceUUIDs: 需要匹配的 Service UUID 列表,默认使用 SDK 预设服务。 /// - Returns: 异步设备流;终止时自动停止底层扫描。 func scan(serviceUUIDs: [CBUUID] = [BleUUIDs.service]) -> AsyncStream { stopScan() discoveredPeripherals.removeAll() return AsyncStream { continuation in self.scanContinuation = continuation continuation.onTermination = { [weak self] _ in self?.queue.async { self?.centralManager.stopScan() self?.scanContinuation = nil } } self.queue.async { guard self.centralManager.state == .poweredOn else { return } BleLog.i("CBCentral startScan", "BLE") self.centralManager.scanForPeripherals( withServices: serviceUUIDs, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] ) } } } /// 停止扫描。 func stopScan() { queue.async { [weak self] in self?.centralManager.stopScan() BleLog.i("CBCentral stopScan", "BLE") } scanContinuation?.finish() scanContinuation = nil } /// 连接已发现的外设。 /// - Parameters: /// - deviceId: 目标外设的 UUID 字符串(来源于扫描阶段)。 /// - timeout: 连接超时,默认 30 秒。 /// - Returns: 成功连接的 `CBPeripheral`。 /// - Throws: 蓝牙未开启、找不到设备、超时或连接失败等错误。 func connect(deviceId: String, timeout: TimeInterval = 30) async throws -> CBPeripheral { guard centralManager.state == .poweredOn else { throw DuooomiBleError.bluetoothNotPoweredOn(String(describing: centralManager.state)) } guard let uuid = UUID(uuidString: deviceId), let peripheral = discoveredPeripherals[uuid] else { throw DuooomiBleError.connectionFailed("Device not found: \(deviceId)") } return try await withCheckedThrowingContinuation { continuation in self.connectContinuation = continuation self.queue.async { BleLog.i("Connecting to peripheral: \(peripheral.identifier.uuidString)", "BLE") self.centralManager.connect(peripheral, options: nil) } // Timeout self.queue.asyncAfter(deadline: .now() + timeout) { [weak self] in if let cont = self?.connectContinuation { self?.connectContinuation = nil self?.centralManager.cancelPeripheralConnection(peripheral) BleLog.w("Connection timeout: \(peripheral.identifier.uuidString)", "BLE") cont.resume(throwing: DuooomiBleError.connectionFailed("Connection timeout after \(Int(timeout))s")) } } } } /// 断开当前连接。 /// - Throws: 如在超时后强制清理,仍会完成回调,不抛错;异常流程可能抛底层错误。 func disconnect() async throws { guard let peripheral = connectedPeripheral else { return } try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in self.disconnectContinuation = continuation self.queue.async { BleLog.i("Cancel connection: \(peripheral.identifier.uuidString)", "BLE") self.centralManager.cancelPeripheralConnection(peripheral) } // Timeout for disconnect self.queue.asyncAfter(deadline: .now() + 5) { [weak self] in if let cont = self?.disconnectContinuation { self?.disconnectContinuation = nil BleLog.w("Force-finish disconnect after timeout", "BLE") self?.cleanup() cont.resume() } } } } /// 无响应写入(GATT Write Without Response)。 /// - Parameter data: 要写入的二进制数据。 /// - Throws: 未连接等错误。 func writeWithoutResponse(_ data: Data) throws { guard let peripheral = connectedPeripheral, let characteristic = writeCharacteristic else { throw DuooomiBleError.notConnected } BleLog.d("Write w/o response, len=\(data.count)", "BLE") peripheral.writeValue(data, for: characteristic, type: .withoutResponse) } /// 查询系统层面已连接、且具备目标 Service 的外设。 /// - Returns: 已连接外设的精简信息列表。 func retrieveConnectedDevices() -> [DiscoveredDevice] { let peripherals = centralManager.retrieveConnectedPeripherals(withServices: [BleUUIDs.service]) return peripherals.map { peripheral in DiscoveredDevice( id: peripheral.identifier.uuidString, name: peripheral.name, rssi: 0 ) } } /// 订阅并接收通知特征的原始二进制数据流。 /// - Returns: 通知数据的异步流。 func notifications() -> AsyncStream { AsyncStream { continuation in self.notificationContinuation = continuation continuation.onTermination = { [weak self] _ in self?.notificationContinuation = nil } BleLog.i("Notifications stream attached", "BLE") } } // MARK: - Private private func cleanup() { if let readChar = readCharacteristic, let peripheral = connectedPeripheral { peripheral.setNotifyValue(false, for: readChar) } connectedPeripheral = nil writeCharacteristic = nil readCharacteristic = nil notificationContinuation?.finish() notificationContinuation = nil BleLog.i("Client state cleaned up", "BLE") } } // MARK: - CBCentralManagerDelegate extension BleClient: CBCentralManagerDelegate { func centralManagerDidUpdateState(_ central: CBCentralManager) { // If scanning was requested before poweredOn, start now if central.state == .poweredOn, scanContinuation != nil { BleLog.d("State poweredOn; resume scan", "BLE") central.scanForPeripherals( withServices: [BleUUIDs.service], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] ) } BleLog.d("Central state=\(central.state.rawValue)", "BLE") } func centralManager( _ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber ) { discoveredPeripherals[peripheral.identifier] = peripheral let device = DiscoveredDevice( id: peripheral.identifier.uuidString, name: peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String, rssi: RSSI.intValue ) BleLog.d("Did discover: id=\(device.id), rssi=\(device.rssi)", "BLE") scanContinuation?.yield(device) } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { connectedPeripheral = peripheral peripheral.delegate = self BleLog.i("Did connect: \(peripheral.identifier.uuidString)", "BLE") peripheral.discoverServices([BleUUIDs.service]) } func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { let cont = connectContinuation connectContinuation = nil BleLog.e("Fail to connect: \(peripheral.identifier.uuidString), error=\(error?.localizedDescription ?? "-")", "BLE") cont?.resume(throwing: DuooomiBleError.connectionFailed(error?.localizedDescription ?? "Unknown error")) } func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { // If disconnect happens mid-setup (after didConnect but before discoverCharacteristics // completes), the connect continuation must be resolved or the caller hangs forever. if let connectCont = connectContinuation { connectContinuation = nil connectCont.resume(throwing: DuooomiBleError.connectionFailed( error?.localizedDescription ?? "Disconnected during setup" )) } cleanup() if let cont = disconnectContinuation { disconnectContinuation = nil cont.resume() } BleLog.w("Did disconnect: \(peripheral.identifier.uuidString), error=\(error?.localizedDescription ?? "-")", "BLE") onDisconnected?() } } // MARK: - CBPeripheralDelegate extension BleClient: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { if let error = error { let cont = connectContinuation connectContinuation = nil BleLog.e("Discover services error: \(error.localizedDescription)", "BLE") cont?.resume(throwing: DuooomiBleError.connectionFailed(error.localizedDescription)) return } guard let service = peripheral.services?.first(where: { $0.uuid == BleUUIDs.service }) else { let cont = connectContinuation connectContinuation = nil BleLog.e("Service not found", "BLE") cont?.resume(throwing: DuooomiBleError.serviceNotFound) return } BleLog.d("Service found: \(service.uuid.uuidString)", "BLE") peripheral.discoverCharacteristics( [BleUUIDs.writeCharacteristic, BleUUIDs.readCharacteristic], for: service ) } func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { if let error = error { let cont = connectContinuation connectContinuation = nil BleLog.e("Discover characteristics error: \(error.localizedDescription)", "BLE") cont?.resume(throwing: DuooomiBleError.connectionFailed(error.localizedDescription)) return } guard let characteristics = service.characteristics else { let cont = connectContinuation connectContinuation = nil cont?.resume(throwing: 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 cont = connectContinuation connectContinuation = nil BleLog.e("Required characteristics missing", "BLE") cont?.resume(throwing: DuooomiBleError.characteristicNotFound) return } // Connection fully ready let cont = connectContinuation connectContinuation = nil BleLog.i("Characteristics ready; connection established", "BLE") cont?.resume(returning: peripheral) } func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { guard error == nil, characteristic.uuid == BleUUIDs.readCharacteristic, let value = characteristic.value else { return } BleLog.d("Notification received, len=\(value.count)", "BLE") notificationContinuation?.yield(value) } }