Files
duooomi-ios-sdk/Sources/DuooomiBleSDK/Core/BleClient.swift

342 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<CBPeripheral, Error>?
private var disconnectContinuation: CheckedContinuation<Void, Error>?
// MARK: - Streams
private var scanContinuation: AsyncStream<DiscoveredDevice>.Continuation?
private var notificationContinuation: AsyncStream<Data>.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<DiscoveredDevice> {
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<Void, Error>) 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<Data> {
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)
}
}