demo app + SDK updates:\n- Add direct ANI transfer (prepare -> transfer)\n- Add firmware update flow (fetch latest w/ x-api-key, OTA transfer)\n- Extract x-api-key to NetworkConstants\n- Rename example app to demo; move folder and entry to DemoApp\n- Switch from SPM package dependency to local framework target\n- Enable automatic Info.plist generation for framework target\n- Add placeholder test to satisfy SwiftPM test target\n- Regenerate XcodeGen project (demo.xcodeproj)
This commit is contained in:
341
Sources/DuooomiBleSDK/Core/BleClient.swift
Normal file
341
Sources/DuooomiBleSDK/Core/BleClient.swift
Normal file
@@ -0,0 +1,341 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
69
Sources/DuooomiBleSDK/Core/BleTypes.swift
Normal file
69
Sources/DuooomiBleSDK/Core/BleTypes.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
|
||||
public enum ConnectionState: String, Sendable {
|
||||
case idle
|
||||
case scanning
|
||||
case connecting
|
||||
case connected
|
||||
case disconnecting
|
||||
case disconnected
|
||||
}
|
||||
|
||||
public struct DiscoveredDevice: Identifiable, Sendable, Equatable {
|
||||
public let id: String
|
||||
public let name: String?
|
||||
public let rssi: Int
|
||||
|
||||
public init(id: String, name: String?, rssi: Int) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.rssi = rssi
|
||||
}
|
||||
}
|
||||
|
||||
public enum DuooomiBleError: LocalizedError, Sendable {
|
||||
case bluetoothNotPoweredOn(String)
|
||||
case notConnected
|
||||
case timeout(command: CommandType)
|
||||
case connectionFailed(String)
|
||||
case serviceNotFound
|
||||
case characteristicNotFound
|
||||
case bindingFailed(String)
|
||||
case unbindFailed
|
||||
case deleteFileFailed(status: Int)
|
||||
case prepareTransferFailed(status: String)
|
||||
case transferFailed(String)
|
||||
case invalidFrame
|
||||
case permissionDenied
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .bluetoothNotPoweredOn(let state):
|
||||
return "Bluetooth is not powered on. State: \(state)"
|
||||
case .notConnected:
|
||||
return "No device connected"
|
||||
case .timeout(let command):
|
||||
return "Operation timeout: \(command)"
|
||||
case .connectionFailed(let reason):
|
||||
return "Connection failed: \(reason)"
|
||||
case .serviceNotFound:
|
||||
return "BLE service not found"
|
||||
case .characteristicNotFound:
|
||||
return "BLE characteristic not found"
|
||||
case .bindingFailed(let reason):
|
||||
return "Binding failed: \(reason)"
|
||||
case .unbindFailed:
|
||||
return "Unbind failed"
|
||||
case .deleteFileFailed(let status):
|
||||
return "Delete file failed with status: \(status)"
|
||||
case .prepareTransferFailed(let status):
|
||||
return "Prepare transfer failed: \(status)"
|
||||
case .transferFailed(let reason):
|
||||
return "Transfer failed: \(reason)"
|
||||
case .invalidFrame:
|
||||
return "Invalid protocol frame"
|
||||
case .permissionDenied:
|
||||
return "Bluetooth permission denied"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user