From 1a7bbdc625cbd9502763e486472abaafd114b601 Mon Sep 17 00:00:00 2001 From: km2023 Date: Thu, 16 Apr 2026 16:59:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E5=86=99=20SDK=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20iOS=2012.2=EF=BC=8C=E7=A7=BB=E9=99=A4=20async/await?= =?UTF-8?q?=20=E5=92=8C=20Combine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 所有公开 API 改为 completion handler (Result) - 状态通知改为 DuooomiBleSDKDelegate 协议 - 移除 Sendable、@MainActor、AsyncStream、CheckedContinuation - 网络请求改为 URLSession.dataTask 回调 - BLE 通信改为 delegate + 闭包模式 - deployment target 降至 iOS 12.2 Co-Authored-By: Claude Opus 4.6 (1M context) --- DuooomiBleSDK.podspec | 4 +- Package.swift | 4 +- Podfile | 6 + Podfile.lock | 16 + README.md | 130 +- Sources/DuooomiBleSDK/Core/BleClient.swift | 358 ++- Sources/DuooomiBleSDK/Core/BleTypes.swift | 6 +- Sources/DuooomiBleSDK/DuooomiBleConfig.swift | 2 +- Sources/DuooomiBleSDK/DuooomiBleSDK.swift | 886 +++++--- .../DuooomiBleSDK/DuooomiBleSDKDelegate.swift | 34 + .../Models/BindingResponse.swift | 2 +- .../Models/DeleteFileResponse.swift | 2 +- Sources/DuooomiBleSDK/Models/DeviceInfo.swift | 2 +- .../Models/PrepareTransferResponse.swift | 2 +- .../DuooomiBleSDK/Models/UnbindResponse.swift | 2 +- .../DuooomiBleSDK/Models/VersionInfo.swift | 2 +- .../Protocol/ProtocolConstants.swift | 2 +- .../Protocol/ProtocolFrame.swift | 2 +- .../DuooomiBleSDK/Services/AniConverter.swift | 79 +- .../Services/BleProtocolService.swift | 143 +- .../Services/DeviceInfoService.swift | 44 +- .../Services/FileTransferService.swift | 64 +- .../Services/FirmwareService.swift | 53 +- demo/Sources/DemoApp.swift | 62 +- demo/Sources/WrapperTestView.swift | 286 +-- .../plans/2026-04-16-ios12-compat-rewrite.md | 1962 +++++++++++++++++ 26 files changed, 3211 insertions(+), 944 deletions(-) create mode 100644 Podfile create mode 100644 Podfile.lock create mode 100644 Sources/DuooomiBleSDK/DuooomiBleSDKDelegate.swift create mode 100644 docs/superpowers/plans/2026-04-16-ios12-compat-rewrite.md diff --git a/DuooomiBleSDK.podspec b/DuooomiBleSDK.podspec index d5c51d7..53aacd6 100644 --- a/DuooomiBleSDK.podspec +++ b/DuooomiBleSDK.podspec @@ -8,10 +8,10 @@ Pod::Spec.new do |s| s.author = { 'comi-logic' => 'dev@bowong.cc' } s.source = { :git => 'https://gitea.bowong.cc/comi-logic/duooomi-ios-sdk.git', :tag => s.version.to_s } - s.ios.deployment_target = '16.0' + s.ios.deployment_target = '12.2' s.swift_version = '5.0' s.source_files = 'Sources/DuooomiBleSDK/**/*.swift' - s.frameworks = 'CoreBluetooth', 'Foundation', 'Combine' + s.frameworks = 'CoreBluetooth', 'Foundation' end diff --git a/Package.swift b/Package.swift index e26e64e..e89c465 100644 --- a/Package.swift +++ b/Package.swift @@ -1,11 +1,11 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 5.0 import PackageDescription let package = Package( name: "DuooomiBleSDK", platforms: [ - .iOS(.v16) + .iOS(.v12) ], products: [ .library( diff --git a/Podfile b/Podfile new file mode 100644 index 0000000..fc807f8 --- /dev/null +++ b/Podfile @@ -0,0 +1,6 @@ +platform :ios, '16.0' + +target 'demo' do + use_frameworks! + pod 'DuooomiBleSDK', :path => './' +end diff --git a/Podfile.lock b/Podfile.lock new file mode 100644 index 0000000..797e4c5 --- /dev/null +++ b/Podfile.lock @@ -0,0 +1,16 @@ +PODS: + - DuooomiBleSDK (0.1.0) + +DEPENDENCIES: + - DuooomiBleSDK (from `./`) + +EXTERNAL SOURCES: + DuooomiBleSDK: + :path: "./" + +SPEC CHECKSUMS: + DuooomiBleSDK: e80989b9a674116a25ef59b3ddfa3edabccfba6f + +PODFILE CHECKSUM: aa25954b2fc8a5cf1a3f6b2eaa0d37f103cc9b81 + +COCOAPODS: 1.16.2 diff --git a/README.md b/README.md index f8ca048..a80347c 100644 --- a/README.md +++ b/README.md @@ -28,70 +28,119 @@ let sdk = DuooomiBleSDK(config: .init( apiKey: "your-api-key" // 必填 // apiHost: URL(...)!, // 默认 https://api.duooomi.com // cdnHost: "https://cdn.bowong.cc/", // 默认 + // firmwareIdentifier: "duomi", // 默认 + // firmwareStatus: "DRAFT" // 默认 // aniWidth: 360, // ANI 转换宽度,默认 360 // aniHeight: 360, // ANI 转换高度,默认 360 // aniFps: "24", // ANI 转换帧率,默认 24 - // firmwareIdentifier: "duomi", // 默认 - // firmwareStatus: "DRAFT" // 默认 )) + +// 设置代理接收状态变化 +sdk.delegate = self +``` + +## 代理(Delegate) + +通过 `DuooomiBleSDKDelegate` 接收状态变化,所有方法在主线程调用,均有默认空实现: + +```swift +extension ViewController: DuooomiBleSDKDelegate { + func sdk(_ sdk: DuooomiBleSDK, didChangeState state: ConnectionState) { } + func sdk(_ sdk: DuooomiBleSDK, didUpdateDevices devices: [DiscoveredDevice]) { } + func sdk(_ sdk: DuooomiBleSDK, didConnectDevice device: DiscoveredDevice?) { } + func sdk(_ sdk: DuooomiBleSDK, didUpdateDeviceInfo info: DeviceInfo?) { } + func sdk(_ sdk: DuooomiBleSDK, didUpdateVersion version: String) { } + func sdk(_ sdk: DuooomiBleSDK, didChangeActivation isActivated: Bool) { } + func sdk(_ sdk: DuooomiBleSDK, didUpdateProgress progress: Int) { } + func sdk(_ sdk: DuooomiBleSDK, didEncounterError error: String?) { } +} ``` ## API +所有异步方法使用 `completion` 回调,返回 `Result`。 + ### 扫描 -| 方法 | 返回 | 说明 | -|------|------|------| -| `scan()` | `Void` | 开始扫描,结果通过 `discoveredDevices` 观察 | -| `stopScan()` | `Void` | 停止扫描 | +| 方法 | 说明 | +|------|------| +| `scan()` | 开始扫描,结果通过 delegate `didUpdateDevices` 回调 | +| `stopScan()` | 停止扫描 | ### 连接 -| 方法 | 返回 | 说明 | -|------|------|------| -| `connect(deviceId: String)` | `DiscoveredDevice` | 连接设备 | -| `disconnect()` | `Void` | 断开连接 | -| `getConnectedDevices()` | `[DiscoveredDevice]` | 获取系统已连接设备 | +```swift +sdk.connect(deviceId: deviceId) { result in + switch result { + case .success(let device): print("Connected: \(device.name ?? device.id)") + case .failure(let error): print("Failed: \(error)") + } +} + +sdk.disconnect { result in + // ... +} +``` ### 设备命令 -| 方法 | 返回 | 说明 | -|------|------|------| -| `getDeviceInfo()` | `DeviceInfo` | 品牌、容量、电量、型号 | -| `getVersion()` | `VersionInfo` | 固件版本 | -| `bind(userId: String)` | `BindingResponse` | 绑定设备,返回 sn + 文件列表。**绑定后才能查看设备文件** | -| `unbind(userId: String)` | `UnbindResponse` | 解绑设备。**解绑后该账号的设备文件将被删除** | -| `deleteFile(key: String)` | `DeleteFileResponse` | 删除设备上的单个文件 | +```swift +sdk.getDeviceInfo { result in + if case .success(let info) = result { + print("Brand: \(info.brand), Power: \(info.powerlevel)%") + } +} + +sdk.getVersion { result in + if case .success(let info) = result { + print("Version: \(info.version)") + } +} + +sdk.bind(userId: "user-id") { result in + if case .success(let resp) = result { + print("SN: \(resp.sn), Files: \(resp.contents.count)") + } +} + +sdk.unbind(userId: "user-id") { result in /* ... */ } +sdk.deleteFile(key: "file-key") { result in /* ... */ } +``` ### 文件传输 -| 方法 | 返回 | 说明 | -|------|------|------| -| `transferMedia(fileUrl: String)` | `Void` | 输入文件 URL(mp4/jpg/png),内部自动完成 ANI 转换 → prepare → 传输。进度通过 `transferProgress` 观察 | +```swift +// 一步完成:自动 ANI 转换 → prepare → transfer +sdk.transferMedia(fileUrl: "https://example.com/video.mp4") { result in + switch result { + case .success: print("Transfer complete") + case .failure(let error): print("Transfer failed: \(error)") + } +} +// 进度通过 delegate didUpdateProgress 回调 +``` ### 固件升级 -| 方法 | 返回 | 说明 | -|------|------|------| -| `fetchLatestFirmware(identifier:status:)` | `FirmwareInfo?` | 查询最新固件,nil 表示无可用版本 | -| `hasNewerFirmware(deviceVersion:serverVersion:)` | `Bool` | 对比版本号末尾10位时间戳,判断是否有新版本。参数无效返回 false | -| `upgradeFirmware(fileUrl: String)` | `Void` | OTA 传输固件包到设备 | - ```swift // 1. 查询最新固件 -let info = try await sdk.fetchLatestFirmware() +sdk.fetchLatestFirmware { result in + guard case .success(let info?) = result else { return } -// 2. 检查是否有新版本 -let needUpdate = sdk.hasNewerFirmware( - deviceVersion: sdk.version, // 当前设备版本 - serverVersion: info?.version // 服务端最新版本 -) + // 2. 检查是否有新版本 + let needUpdate = sdk.hasNewerFirmware( + deviceVersion: sdk.version, + serverVersion: info.version + ) -// 3. OTA 升级 -try await sdk.upgradeFirmware(fileUrl: info.fileUrl) + // 3. OTA 升级 + if needUpdate { + sdk.upgradeFirmware(fileUrl: info.fileUrl) { result in /* ... */ } + } +} ``` -### 可观察状态 +### 可读取状态 | 属性 | 类型 | 说明 | |------|------|------| @@ -124,8 +173,8 @@ public struct DeviceInfo { let brand: String // 品牌 let size: String // 屏幕尺寸 let powerlevel: Int // 电量百分比 - let allspace: Int // 总存储(字节) - let freespace: Int // 可用存储(字节) + let allspace: UInt64 // 总存储(字节) + let freespace: UInt64 // 可用存储(字节) } // BindingResponse — bind 返回 @@ -145,5 +194,6 @@ public struct BindingResponse { ## 技术规格 -- iOS 16.0+ / Swift 5.9+ / 零依赖 -- 公开 API @MainActor,BLE 操作专用串行队列 +- iOS 12.2+ / Swift 5.0+ / 零依赖 +- Delegate 回调模式,所有状态变化在主线程通知 +- BLE 操作专用串行队列 diff --git a/Sources/DuooomiBleSDK/Core/BleClient.swift b/Sources/DuooomiBleSDK/Core/BleClient.swift index 9d38df9..249620e 100644 --- a/Sources/DuooomiBleSDK/Core/BleClient.swift +++ b/Sources/DuooomiBleSDK/Core/BleClient.swift @@ -1,198 +1,116 @@ 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") +/// BLE 客户端内部回调协议 +protocol BleClientDelegate: AnyObject { + func bleClient(_ client: BleClient, didDiscoverDevice device: DiscoveredDevice) + func bleClient(_ client: BleClient, didReceiveData data: Data) +} - 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 +final class BleClient: NSObject { + weak var delegate: BleClientDelegate? var onConnectionStateChange: ((ConnectionState) -> Void)? var onDisconnected: (() -> Void)? - // MARK: - Init + 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? - override init() { - super.init() - centralManager = CBCentralManager(delegate: self, queue: queue) - BleLog.i("BleClient initialized", "BLE") - } + // Connect/Disconnect callbacks + private var connectCompletion: ((Result) -> Void)? + private var disconnectCompletion: ((Result) -> Void)? + private var connectTimeoutWork: DispatchWorkItem? - // MARK: - Public API - - /// 当前蓝牙状态。 var bluetoothState: CBManagerState { centralManager.state } - /// 当前连接在 `.withoutResponse` 模式下允许写入的最大长度。 var maximumWriteLength: Int { - connectedPeripheral?.maximumWriteValueLength(for: .withoutResponse) ?? 20 + connectedPeripheral?.maximumWriteValueLength(for: .withoutResponse) ?? 182 } - /// 扫描包含目标 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] - ) - } - } + override init() { + super.init() + centralManager = CBCentralManager(delegate: self, queue: queue) } - /// 停止扫描。 - func stopScan() { - queue.async { [weak self] in - self?.centralManager.stopScan() - BleLog.i("CBCentral stopScan", "BLE") - } - scanContinuation?.finish() - scanContinuation = nil - } + // MARK: - Scan - /// 连接已发现的外设。 - /// - Parameters: - /// - deviceId: 目标外设的 UUID 字符串(来源于扫描阶段)。 - /// - timeout: 连接超时,默认 30 秒。 - /// - Returns: 成功连接的 `CBPeripheral`。 - /// - Throws: 蓝牙未开启、找不到设备、超时或连接失败等错误。 - func connect(deviceId: String, timeout: TimeInterval = 30) async throws -> CBPeripheral { + func scan(serviceUUIDs: [CBUUID] = [BleUUIDs.service]) { 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")) - } - } + BleLog.w("Bluetooth not powered on", "BLE") + return } + centralManager.scanForPeripherals( + withServices: serviceUUIDs, + options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] + ) } - /// 断开当前连接。 - /// - 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() - } - } - } + func stopScan() { + centralManager.stopScan() } - /// 无响应写入(GATT Write Without Response)。 - /// - Parameter data: 要写入的二进制数据。 - /// - Throws: 未连接等错误。 + // MARK: - Connect + + func connect(deviceId: String, timeout: TimeInterval = 30, completion: @escaping (Result) -> 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) { + 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 characteristic = writeCharacteristic else { + guard let peripheral = connectedPeripheral, let char = writeCharacteristic else { throw DuooomiBleError.notConnected } - BleLog.d("Write w/o response, len=\(data.count)", "BLE") - peripheral.writeValue(data, for: characteristic, type: .withoutResponse) + peripheral.writeValue(data, for: char, type: .withoutResponse) } - /// 查询系统层面已连接、且具备目标 Service 的外设。 - /// - Returns: 已连接外设的精简信息列表。 + // MARK: - Retrieve Connected + 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 - ) + centralManager.retrieveConnectedPeripherals(withServices: [BleUUIDs.service]).map { + DiscoveredDevice(id: $0.identifier.uuidString, name: $0.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 @@ -200,67 +118,51 @@ final class BleClient: NSObject, @unchecked Sendable { 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") + BleLog.i("Bluetooth state: \(central.state.rawValue)", "BLE") } - func centralManager( - _ central: CBCentralManager, - didDiscover peripheral: CBPeripheral, - advertisementData: [String: Any], - rssi RSSI: NSNumber - ) { - discoveredPeripherals[peripheral.identifier] = peripheral - + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], rssi RSSI: NSNumber) { let device = DiscoveredDevice( id: peripheral.identifier.uuidString, - name: peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String, + name: peripheral.name, rssi: RSSI.intValue ) - BleLog.d("Did discover: id=\(device.id), rssi=\(device.rssi)", "BLE") - scanContinuation?.yield(device) + delegate?.bleClient(self, didDiscoverDevice: device) } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + connectTimeoutWork?.cancel() + connectTimeoutWork = nil 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")) + connectTimeoutWork?.cancel() + connectTimeoutWork = nil + let cb = connectCompletion + connectCompletion = nil + cb?(.failure(error ?? DuooomiBleError.connectionFailed("Unknown"))) } 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" - )) + connectedPeripheral = nil + writeCharacteristic = nil + readCharacteristic = nil + + if let cb = disconnectCompletion { + disconnectCompletion = nil + if let error = error { + cb(.failure(error)) + } else { + cb(.success(())) + } } - - cleanup() - - if let cont = disconnectContinuation { - disconnectContinuation = nil - cont.resume() + DispatchQueue.main.async { [weak self] in + self?.onDisconnected?() } - - BleLog.w("Did disconnect: \(peripheral.identifier.uuidString), error=\(error?.localizedDescription ?? "-")", "BLE") - onDisconnected?() } } @@ -270,22 +172,17 @@ 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)) + let cb = connectCompletion + connectCompletion = nil + cb?(.failure(error)) 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) + let cb = connectCompletion + connectCompletion = nil + cb?(.failure(DuooomiBleError.serviceNotFound)) return } - - BleLog.d("Service found: \(service.uuid.uuidString)", "BLE") peripheral.discoverCharacteristics( [BleUUIDs.writeCharacteristic, BleUUIDs.readCharacteristic], for: service @@ -294,17 +191,15 @@ extension BleClient: CBPeripheralDelegate { 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)) + let cb = connectCompletion + connectCompletion = nil + cb?(.failure(error)) return } - guard let characteristics = service.characteristics else { - let cont = connectContinuation - connectContinuation = nil - cont?.resume(throwing: DuooomiBleError.characteristicNotFound) + let cb = connectCompletion + connectCompletion = nil + cb?(.failure(DuooomiBleError.characteristicNotFound)) return } @@ -318,24 +213,19 @@ extension BleClient: CBPeripheralDelegate { } guard writeCharacteristic != nil, readCharacteristic != nil else { - let cont = connectContinuation - connectContinuation = nil - BleLog.e("Required characteristics missing", "BLE") - cont?.resume(throwing: DuooomiBleError.characteristicNotFound) + let cb = connectCompletion + connectCompletion = nil + cb?(.failure(DuooomiBleError.characteristicNotFound)) return } - // Connection fully ready - let cont = connectContinuation - connectContinuation = nil - BleLog.i("Characteristics ready; connection established", "BLE") - cont?.resume(returning: peripheral) + let cb = connectCompletion + connectCompletion = nil + cb?(.success(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) + guard error == nil, let data = characteristic.value else { return } + delegate?.bleClient(self, didReceiveData: data) } } diff --git a/Sources/DuooomiBleSDK/Core/BleTypes.swift b/Sources/DuooomiBleSDK/Core/BleTypes.swift index 08c2bc3..2497e2a 100644 --- a/Sources/DuooomiBleSDK/Core/BleTypes.swift +++ b/Sources/DuooomiBleSDK/Core/BleTypes.swift @@ -1,6 +1,6 @@ import Foundation -public enum ConnectionState: String, Sendable { +public enum ConnectionState: String { case idle case scanning case connecting @@ -9,7 +9,7 @@ public enum ConnectionState: String, Sendable { case disconnected } -public struct DiscoveredDevice: Identifiable, Sendable, Equatable { +public struct DiscoveredDevice: Identifiable, Equatable { public let id: String public let name: String? public let rssi: Int @@ -21,7 +21,7 @@ public struct DiscoveredDevice: Identifiable, Sendable, Equatable { } } -public enum DuooomiBleError: LocalizedError, Sendable { +public enum DuooomiBleError: LocalizedError { case bluetoothNotPoweredOn(String) case notConnected case timeout(command: CommandType) diff --git a/Sources/DuooomiBleSDK/DuooomiBleConfig.swift b/Sources/DuooomiBleSDK/DuooomiBleConfig.swift index a7264cb..4f45811 100644 --- a/Sources/DuooomiBleSDK/DuooomiBleConfig.swift +++ b/Sources/DuooomiBleSDK/DuooomiBleConfig.swift @@ -1,7 +1,7 @@ import Foundation /// SDK 全局配置,在初始化时一次性传入。 -public struct DuooomiBleConfig: Sendable { +public struct DuooomiBleConfig { /// API 服务地址 public let apiHost: URL /// API 鉴权 key diff --git a/Sources/DuooomiBleSDK/DuooomiBleSDK.swift b/Sources/DuooomiBleSDK/DuooomiBleSDK.swift index 95ccab3..69a32af 100644 --- a/Sources/DuooomiBleSDK/DuooomiBleSDK.swift +++ b/Sources/DuooomiBleSDK/DuooomiBleSDK.swift @@ -1,4 +1,3 @@ -import Combine import CoreBluetooth import Foundation @@ -6,19 +5,52 @@ import Foundation /// /// 提供设备扫描、连接、命令交互、文件传输等原子蓝牙操作。 /// 不做业务编排,调用方自行串联多步流程。 -@MainActor -public final class DuooomiBleSDK: ObservableObject { +public final class DuooomiBleSDK: NSObject { // MARK: - Observable State - @Published public private(set) var btState: ConnectionState = .idle - @Published public private(set) var connectedDevice: DiscoveredDevice? = nil - @Published public private(set) var deviceInfo: DeviceInfo? = nil - @Published public private(set) var version: String = "" - @Published public private(set) var isActivated: Bool = false - @Published public private(set) var transferProgress: Int = 0 - @Published public private(set) var error: String? = nil - @Published public private(set) var discoveredDevices: [DiscoveredDevice] = [] + public weak var delegate: DuooomiBleSDKDelegate? + + public private(set) var btState: ConnectionState = .idle { + didSet { + guard btState != oldValue else { return } + notifyMain { self.delegate?.sdk(self, didChangeState: self.btState) } + } + } + + public private(set) var connectedDevice: DiscoveredDevice? = nil { + didSet { notifyMain { self.delegate?.sdk(self, didConnectDevice: self.connectedDevice) } } + } + + public private(set) var deviceInfo: DeviceInfo? = nil { + didSet { notifyMain { self.delegate?.sdk(self, didUpdateDeviceInfo: self.deviceInfo) } } + } + + public private(set) var version: String = "" { + didSet { notifyMain { self.delegate?.sdk(self, didUpdateVersion: self.version) } } + } + + public private(set) var isActivated: Bool = false { + didSet { + guard isActivated != oldValue else { return } + notifyMain { self.delegate?.sdk(self, didChangeActivation: self.isActivated) } + } + } + + public private(set) var transferProgress: Int = 0 { + didSet { + guard transferProgress != oldValue else { return } + notifyMain { self.delegate?.sdk(self, didUpdateProgress: self.transferProgress) } + } + } + + public private(set) var error: String? = nil { + didSet { notifyMain { self.delegate?.sdk(self, didEncounterError: self.error) } } + } + + public private(set) var discoveredDevices: [DiscoveredDevice] = [] { + didSet { notifyMain { self.delegate?.sdk(self, didUpdateDevices: self.discoveredDevices) } } + } // MARK: - Configuration @@ -35,14 +67,15 @@ public final class DuooomiBleSDK: ObservableObject { // MARK: - Request-Response - private var pendingContinuations: [String: CheckedContinuation] = [:] - private var messageListenerTask: Task? + private var pendingCallbacks: [String: (Result) -> Void] = [:] + private var pendingTimeouts: [String: DispatchWorkItem] = [:] + private let callbackQueue = DispatchQueue(label: "com.duooomi.ble.callback") // MARK: - Scan Batching - private var scanTask: Task? - private var pendingDevices: [DiscoveredDevice] = [] + private var scanDelegate: ScanBleClientDelegate? private var allDiscoveredDevices: [DiscoveredDevice] = [] + private var pendingDevices: [DiscoveredDevice] = [] private var flushWorkItem: DispatchWorkItem? // MARK: - Init @@ -56,30 +89,30 @@ public final class DuooomiBleSDK: ObservableObject { aniConverter = AniConverter(config: config) firmwareService = FirmwareService(config: config) + super.init() + + protocolService.delegate = self BleLog.i("SDK initialized (apiHost=\(config.apiHost), firmware=\(config.firmwareIdentifier)/\(config.firmwareStatus))", "SDK") setupDisconnectHandler() } private func setupDisconnectHandler() { bleClient.onDisconnected = { [weak self] in - Task { @MainActor [weak self] in - guard let self else { return } - BleLog.w("Peripheral disconnected; resetting state", "SDK") - self.connectedDevice = nil - self.deviceInfo = nil - self.version = "" - self.isActivated = false - self.btState = .disconnected - self.protocolService.stopListening() - self.cancelAllPending(error: DuooomiBleError.notConnected) - } + guard let self = self else { return } + BleLog.w("Peripheral disconnected; resetting state", "SDK") + self.connectedDevice = nil + self.deviceInfo = nil + self.version = "" + self.isActivated = false + self.btState = .disconnected + self.protocolService.stopListening() + self.cancelAllPending(error: DuooomiBleError.notConnected) } } // MARK: - Scanning /// 开始扫描设备。 - /// - Note: 扫描使用 500ms 批量刷新以减少 UI 抖动,调用 `stopScan()` 可停止。 public func scan() { stopScan() btState = .scanning @@ -88,23 +121,21 @@ public final class DuooomiBleSDK: ObservableObject { pendingDevices = [] BleLog.i("Start scanning...", "Scan") - let stream = bleClient.scan() - scanTask = Task { [weak self] in - for await device in stream { - guard let self, !Task.isCancelled else { break } - BleLog.d("Discovered: id=\(device.id), name=\(device.name ?? "-"), rssi=\(device.rssi)", "Scan") - await self.queueDevice(device) - } - } + // Create scan proxy to forward discovered devices to SDK while + // keeping protocol service as the data receiver + let proxy = ScanBleClientDelegate(sdk: self, original: protocolService) + scanDelegate = proxy + bleClient.delegate = proxy + bleClient.scan() } /// 停止扫描设备。 - /// - Note: 会立即刷新已缓存的发现列表;若保持已连接,`btState` 回到 `.connected`。 public func stopScan() { - scanTask?.cancel() - scanTask = nil bleClient.stopScan() flushDevices() + // Restore protocol service as delegate + scanDelegate = nil + bleClient.delegate = protocolService if connectedDevice != nil { btState = .connected @@ -117,74 +148,61 @@ public final class DuooomiBleSDK: ObservableObject { // MARK: - Connection /// 连接到指定设备。 - /// - Parameter deviceId: 通过扫描得到的 `DiscoveredDevice.id`(CBPeripheral UUID 字符串)。 - /// - Returns: 成功连接的设备信息。 - /// - Throws: `DuooomiBleError.connectionFailed` 等连接相关错误。 - public func connect(deviceId: String) async throws -> DiscoveredDevice { + public func connect(deviceId: String, completion: @escaping (Result) -> Void) { stopScan() btState = .connecting BleLog.i("Connecting to \(deviceId)...", "Connect") - do { - let peripheral = try await bleClient.connect(deviceId: deviceId) + bleClient.connect(deviceId: deviceId) { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let peripheral): + self.protocolService.startListening() - // Start protocol listener - protocolService.startListening() - startMessageListener() + let device = DiscoveredDevice( + id: peripheral.identifier.uuidString, + name: peripheral.name, + rssi: 0 + ) + self.connectedDevice = device + self.btState = .connected + BleLog.i("Connected: \(device.id)", "Connect") + completion(.success(device)) - let device = DiscoveredDevice( - id: peripheral.identifier.uuidString, - name: peripheral.name, - rssi: 0 - ) - connectedDevice = device - btState = .connected - BleLog.i("Connected: \(device.id)", "Connect") - return device - } catch { - btState = .idle - self.error = error.localizedDescription - BleLog.e("Connect failed: \(error.localizedDescription)", "Connect") - throw error + case .failure(let error): + self.btState = .idle + self.error = error.localizedDescription + BleLog.e("Connect failed: \(error.localizedDescription)", "Connect") + completion(.failure(error)) + } } } /// 断开当前连接。 - /// - Throws: 断开过程中发生的底层错误(若有)。 - public func disconnect() async throws { + public func disconnect(completion: @escaping (Result) -> Void) { btState = .disconnecting BleLog.i("Disconnect requested", "Connect") protocolService.stopListening() - messageListenerTask?.cancel() - messageListenerTask = nil cancelAllPending(error: DuooomiBleError.notConnected) - // Capture the underlying disconnect error so we can clean up state regardless, - // but still surface it to the caller. UI consumers may also see it via `error`. - var disconnectError: Error? - do { - try await bleClient.disconnect() - } catch { - disconnectError = error - self.error = error.localizedDescription - BleLog.e("Disconnect error: \(error.localizedDescription)", "Connect") - } + bleClient.disconnect { [weak self] result in + guard let self = self else { return } + if case .failure(let error) = result { + self.error = error.localizedDescription + BleLog.e("Disconnect error: \(error.localizedDescription)", "Connect") + } - connectedDevice = nil - deviceInfo = nil - version = "" - isActivated = false - btState = .disconnected - BleLog.i("Disconnected", "Connect") - - if let disconnectError { - throw disconnectError + self.connectedDevice = nil + self.deviceInfo = nil + self.version = "" + self.isActivated = false + self.btState = .disconnected + BleLog.i("Disconnected", "Connect") + completion(result) } } - /// 返回系统已连接的、属于本 SDK service 的外设(不一定是被本 SDK 主动连接的) - /// 返回系统层面已连接、且包含本 SDK 所需 Service 的外设。 - /// - Returns: 符合条件的已连接外设列表。 + /// 返回系统已连接的设备 public func getConnectedDevices() -> [DiscoveredDevice] { let list = bleClient.retrieveConnectedDevices() BleLog.d("System-connected devices: \(list.count)", "Connect") @@ -193,204 +211,274 @@ public final class DuooomiBleSDK: ObservableObject { // MARK: - Device Commands - /// 读取设备信息(容量、电量、品牌、型号等)。 - /// - Returns: 设备信息结构体。 - /// - Throws: 未连接或超时、解析失败等错误。 - public func getDeviceInfo() async throws -> DeviceInfo { - try ensureConnected() + /// 读取设备信息 + public func getDeviceInfo(completion: @escaping (Result) -> Void) { + guard ensureConnected(completion: completion) else { return } BleLog.d("Sending getDeviceInfo", "Command") - let data = try await sendAndWait(commandType: .getDeviceInfo) { - try await self.deviceInfoService.getDeviceInfo() - } - let info = try decodeResponse(DeviceInfo.self, from: data) - BleLog.i("DeviceInfo received: name=\(info.name), brand=\(info.brand)", "Command") - self.deviceInfo = info - return info + + sendAndWait(commandType: .getDeviceInfo, completion: { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let data): + do { + let info = try self.decodeResponse(DeviceInfo.self, from: data) + BleLog.i("DeviceInfo received: name=\(info.name), brand=\(info.brand)", "Command") + self.deviceInfo = info + completion(.success(info)) + } catch { + completion(.failure(error)) + } + } + }, send: { + try self.deviceInfoService.getDeviceInfo() + }) } - /// 读取设备固件版本。 - /// - Returns: 版本信息。 - /// - Throws: 未连接或超时、解析失败等错误。 - public func getVersion() async throws -> VersionInfo { - try ensureConnected() + /// 读取设备固件版本 + public func getVersion(completion: @escaping (Result) -> Void) { + guard ensureConnected(completion: completion) else { return } BleLog.d("Sending getDeviceVersion", "Command") - let data = try await sendAndWait(commandType: .getDeviceVersion) { - try await self.deviceInfoService.getDeviceVersion() - } - let info = try decodeResponse(VersionInfo.self, from: data) - self.version = info.version - BleLog.i("Version received: \(info.version) (type=\(info.type))", "Command") - return info + + sendAndWait(commandType: .getDeviceVersion, completion: { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let data): + do { + let info = try self.decodeResponse(VersionInfo.self, from: data) + self.version = info.version + BleLog.i("Version received: \(info.version) (type=\(info.type))", "Command") + completion(.success(info)) + } catch { + completion(.failure(error)) + } + } + }, send: { + try self.deviceInfoService.getDeviceVersion() + }) } - /// 绑定设备到用户。 - /// - Parameter userId: 业务侧用户唯一标识。 - /// - Returns: 绑定结果,包含 SN 与设备文件清单。 - /// - Throws: 已被他人绑定或其他错误时抛出。 - public func bind(userId: String) async throws -> BindingResponse { - try ensureConnected() + /// 绑定设备到用户 + public func bind(userId: String, completion: @escaping (Result) -> Void) { + guard ensureConnected(completion: completion) else { return } BleLog.d("Sending bind (userId=\(userId))", "Command") - let data = try await sendAndWait(commandType: .bindDevice) { - try await self.deviceInfoService.bindDevice(userId: userId) - } - let resp = try decodeResponse(BindingResponse.self, from: data) - isActivated = resp.success == 1 - if resp.success != 1 { - BleLog.w("Bind failed: device already bound", "Command") - throw DuooomiBleError.bindingFailed("Device already bound to another user") - } - BleLog.i("Bind success: sn=\(resp.sn)", "Command") - return resp + + sendAndWait(commandType: .bindDevice, completion: { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let data): + do { + let resp = try self.decodeResponse(BindingResponse.self, from: data) + self.isActivated = resp.success == 1 + if resp.success != 1 { + BleLog.w("Bind failed: device already bound", "Command") + completion(.failure(DuooomiBleError.bindingFailed("Device already bound to another user"))) + } else { + BleLog.i("Bind success: sn=\(resp.sn)", "Command") + completion(.success(resp)) + } + } catch { + completion(.failure(error)) + } + } + }, send: { + try self.deviceInfoService.bindDevice(userId: userId) + }) } - /// 解除设备与用户的绑定关系。 - /// - Parameter userId: 业务侧用户唯一标识。 - /// - Returns: 解绑结果。 - /// - Throws: 未连接或设备拒绝等错误。 - public func unbind(userId: String) async throws -> UnbindResponse { - try ensureConnected() + /// 解除设备绑定 + public func unbind(userId: String, completion: @escaping (Result) -> Void) { + guard ensureConnected(completion: completion) else { return } BleLog.d("Sending unbind (userId=\(userId))", "Command") - let data = try await sendAndWait(commandType: .unbindDevice) { - try await self.deviceInfoService.unbindDevice(userId: userId) - } - let resp = try decodeResponse(UnbindResponse.self, from: data) - if resp.success == 1 { - isActivated = false - BleLog.i("Unbind success", "Command") - } else { - BleLog.w("Unbind failed", "Command") - throw DuooomiBleError.unbindFailed - } - return resp + + sendAndWait(commandType: .unbindDevice, completion: { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let data): + do { + let resp = try self.decodeResponse(UnbindResponse.self, from: data) + if resp.success == 1 { + self.isActivated = false + BleLog.i("Unbind success", "Command") + completion(.success(resp)) + } else { + BleLog.w("Unbind failed", "Command") + completion(.failure(DuooomiBleError.unbindFailed)) + } + } catch { + completion(.failure(error)) + } + } + }, send: { + try self.deviceInfoService.unbindDevice(userId: userId) + }) } - /// 从设备中删除指定 key 的文件。 - /// - Parameter key: 设备中文件对应的 key 或完整 URL。 - /// - Returns: 删除结果。 - /// - Throws: 文件不存在或删除失败时抛出。 - public func deleteFile(key: String) async throws -> DeleteFileResponse { - try ensureConnected() + /// 删除设备文件 + public func deleteFile(key: String, completion: @escaping (Result) -> Void) { + guard ensureConnected(completion: completion) else { return } BleLog.d("Sending deleteFile (key=\(key))", "Command") - let data = try await sendAndWait(commandType: .deleteFile) { - try await self.deviceInfoService.deleteFile(key: key) - } - let resp = try decodeResponse(DeleteFileResponse.self, from: data) - if resp.success != 0 { - BleLog.w("Delete failed with status=\(resp.success)", "Command") - throw DuooomiBleError.deleteFileFailed(status: resp.success) - } - BleLog.i("Delete success (key=\(key))", "Command") - return resp + + sendAndWait(commandType: .deleteFile, completion: { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let data): + do { + let resp = try self.decodeResponse(DeleteFileResponse.self, from: data) + if resp.success != 0 { + BleLog.w("Delete failed with status=\(resp.success)", "Command") + completion(.failure(DuooomiBleError.deleteFileFailed(status: resp.success))) + } else { + BleLog.i("Delete success (key=\(key))", "Command") + completion(.success(resp)) + } + } catch { + completion(.failure(error)) + } + } + }, send: { + try self.deviceInfoService.deleteFile(key: key) + }) } - /// 进行传输前的准备校验(空间、重复等)。 - /// - Parameters: - /// - key: 用于标识该文件的唯一 key(可直接使用源文件 URL)。 - /// - size: 即将传输的字节大小。 - /// - Returns: 设备返回的准备状态,`status == "ready"` 表示可传。 - /// - Throws: 未连接或设备拒绝等错误。 - public func prepareTransfer(key: String, size: Int) async throws -> PrepareTransferResponse { - try ensureConnected() + /// 传输前准备校验 + public func prepareTransfer(key: String, size: Int, completion: @escaping (Result) -> Void) { + guard ensureConnected(completion: completion) else { return } let opId = "\(CommandType.prepareTransfer.rawValue)_\(key)" BleLog.d("Sending prepareTransfer (key=\(key), size=\(size))", "Transfer") - let data = try await sendAndWait(opId: opId, command: .prepareTransfer) { - try await self.deviceInfoService.prepareTransfer(key: key, size: size) - } - let resp = try decodeResponse(PrepareTransferResponse.self, from: data) - if resp.status != "ready" { - BleLog.w("PrepareTransfer not ready: status=\(resp.status)", "Transfer") - throw DuooomiBleError.prepareTransferFailed(status: resp.status) - } - BleLog.i("PrepareTransfer ready (key=\(key))", "Transfer") - return resp + + sendAndWait(opId: opId, commandType: .prepareTransfer, completion: { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let data): + do { + let resp = try self.decodeResponse(PrepareTransferResponse.self, from: data) + if resp.status != "ready" { + BleLog.w("PrepareTransfer not ready: status=\(resp.status)", "Transfer") + completion(.failure(DuooomiBleError.prepareTransferFailed(status: resp.status))) + } else { + BleLog.i("PrepareTransfer ready (key=\(key))", "Transfer") + completion(.success(resp)) + } + } catch { + completion(.failure(error)) + } + } + }, send: { + try self.deviceInfoService.prepareTransfer(key: key, size: size) + }) } // MARK: - File Transfer - /// 传输文件到设备。 - /// - Parameters: - /// - fileUri: `file://` 本地路径或 `https://` 远程 URL。 - /// - commandType: 传输命令类型(如 `.transferAniVideo`、`.otaPackage`)。 - /// - Throws: 下载失败、蓝牙发送失败等错误。 + /// 传输文件到设备 public func transferFile( fileUri: String, - commandType: CommandType = .transferAniVideo - ) async throws { - try ensureConnected() + commandType: CommandType = .transferAniVideo, + completion: @escaping (Result) -> Void + ) { + guard connectedDevice != nil else { + completion(.failure(DuooomiBleError.notConnected)) + return + } transferProgress = 0 BleLog.i("Transfer start: uri=\(fileUri), cmd=\(commandType)", "Transfer") - try await fileTransferService.transferFile( + fileTransferService.transferFile( fileUri: fileUri, - commandType: commandType - ) { [weak self] progress in - Task { @MainActor [weak self] in - self?.transferProgress = Int(progress * 100) - if let p = self?.transferProgress, p % 10 == 0 { - BleLog.d("Progress: \(p)%", "Transfer") + commandType: commandType, + onProgress: { [weak self] progress in + DispatchQueue.main.async { + let p = Int(progress * 100) + self?.transferProgress = p + if p % 10 == 0 { + BleLog.d("Progress: \(p)%", "Transfer") + } } + }, + completion: { [weak self] result in + if case .success = result { + self?.transferProgress = 100 + BleLog.i("Transfer completed", "Transfer") + } + completion(result) } - } - - transferProgress = 100 - BleLog.i("Transfer completed", "Transfer") + ) } // MARK: - High-Level APIs - /// 传输媒体文件到设备(一步完成)。 - /// - /// 内部自动处理:视频/图片 → ANI 转换 → 获取大小 → prepareTransfer → transferFile。 - /// - Parameter fileUrl: 文件的公网 URL(支持 mp4/jpg/png 等,会自动转 ANI;.ani 文件直接传输)。 - /// - Throws: 转换失败、设备空间不足、传输失败等。 - public func transferMedia(fileUrl: String) async throws { - try ensureConnected() + /// 传输媒体文件到设备(一步完成) + public func transferMedia(fileUrl: String, completion: @escaping (Result) -> Void) { + guard connectedDevice != nil else { + completion(.failure(DuooomiBleError.notConnected)) + return + } let url = fileUrl.trimmingCharacters(in: .whitespacesAndNewlines) - guard !url.isEmpty else { throw DuooomiBleError.transferFailed("Empty file URL") } - - let isAni = url.lowercased().hasSuffix(".ani") - let aniUrl: String - - if isAni { - aniUrl = url - BleLog.i("Direct ANI transfer: \(url)", "Transfer") - } else { - BleLog.i("Converting to ANI: \(url)", "Transfer") - do { - aniUrl = try await aniConverter.convert(fileUrl: url) - } catch { - throw DuooomiBleError.transferFailed("ANI conversion failed: \(error.localizedDescription)") - } - BleLog.i("ANI ready: \(aniUrl)", "Transfer") + guard !url.isEmpty else { + completion(.failure(DuooomiBleError.transferFailed("Empty file URL"))) + return } - // 获取文件大小 - let aniSize = try await getRemoteFileSize(url: aniUrl) - BleLog.d("ANI size: \(aniSize) bytes", "Transfer") + let isAni = url.lowercased().hasSuffix(".ani") - // prepareTransfer(key 用原始文件地址) - let key = url - _ = try await prepareTransfer(key: key, size: aniSize) - - // 传输 - try await transferFile(fileUri: aniUrl, commandType: .transferAniVideo) + if isAni { + BleLog.i("Direct ANI transfer: \(url)", "Transfer") + transferMediaStep2(aniUrl: url, originalUrl: url, completion: completion) + } else { + BleLog.i("Converting to ANI: \(url)", "Transfer") + aniConverter.convert(fileUrl: url) { [weak self] result in + switch result { + case .failure(let error): + completion(.failure(DuooomiBleError.transferFailed("ANI conversion failed: \(error.localizedDescription)"))) + case .success(let aniUrl): + BleLog.i("ANI ready: \(aniUrl)", "Transfer") + self?.transferMediaStep2(aniUrl: aniUrl, originalUrl: url, completion: completion) + } + } + } } - /// 获取最新固件信息。 - /// - Parameters: - /// - identifier: 设备标识,默认使用 config 中的 firmwareIdentifier。 - /// - status: 固件状态,默认使用 config 中的 firmwareStatus。 - /// - Returns: 固件信息,nil 表示无可用固件。 - public func fetchLatestFirmware(identifier: String? = nil, status: String? = nil) async throws -> FirmwareInfo? { - return try await firmwareService.fetchLatest(identifier: identifier, status: status) + private func transferMediaStep2(aniUrl: String, originalUrl: String, completion: @escaping (Result) -> Void) { + getRemoteFileSize(url: aniUrl) { [weak self] result in + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let aniSize): + BleLog.d("ANI size: \(aniSize) bytes", "Transfer") + self?.prepareTransfer(key: originalUrl, size: aniSize) { [weak self] result in + switch result { + case .failure(let error): + completion(.failure(error)) + case .success: + self?.transferFile(fileUri: aniUrl, commandType: .transferAniVideo, completion: completion) + } + } + } + } } - /// 比较设备版本和服务端版本,判断是否有新固件可用。 - /// 对比规则:取版本号末尾 10 位时间戳比较大小。 - /// - Parameters: - /// - deviceVersion: 当前设备版本号。 - /// - serverVersion: 服务端最新版本号。 - /// - Returns: 服务端版本更新返回 true,参数无效返回 false。 + /// 获取最新固件信息 + public func fetchLatestFirmware( + identifier: String? = nil, + status: String? = nil, + completion: @escaping (Result) -> Void + ) { + firmwareService.fetchLatest(identifier: identifier, status: status, completion: completion) + } + + /// 比较设备版本和服务端版本 public func hasNewerFirmware(deviceVersion: String?, serverVersion: String?) -> Bool { guard let device = deviceVersion, let server = serverVersion, device.count >= 10, server.count >= 10 else { return false } @@ -400,46 +488,74 @@ public final class DuooomiBleSDK: ObservableObject { return s > d } - /// 传输固件包到设备(OTA 升级)。 - /// - Parameter fileUrl: 固件文件的下载地址(从 `fetchLatestFirmware()` 获取)。 - /// - Throws: 未连接、下载失败、传输失败等。 - public func upgradeFirmware(fileUrl: String) async throws { - try ensureConnected() + /// OTA 固件升级 + public func upgradeFirmware(fileUrl: String, completion: @escaping (Result) -> Void) { + guard connectedDevice != nil else { + completion(.failure(DuooomiBleError.notConnected)) + return + } BleLog.i("OTA upgrade start: \(fileUrl)", "Firmware") - try await transferFile(fileUri: fileUrl, commandType: .otaPackage) - BleLog.i("OTA upgrade completed", "Firmware") + transferFile(fileUri: fileUrl, commandType: .otaPackage) { result in + if case .success = result { + BleLog.i("OTA upgrade completed", "Firmware") + } + completion(result) + } } // MARK: - Internal Helpers - private func getRemoteFileSize(url: String) async throws -> Int { + private func getRemoteFileSize(url: String, completion: @escaping (Result) -> Void) { guard let fileUrl = URL(string: url) else { - throw DuooomiBleError.transferFailed("Invalid URL: \(url)") + completion(.failure(DuooomiBleError.transferFailed("Invalid URL: \(url)"))) + return } + var request = URLRequest(url: fileUrl) request.httpMethod = "HEAD" - let (_, response) = try await URLSession.shared.data(for: request) - if let http = response as? HTTPURLResponse, http.expectedContentLength > 0 { - return Int(http.expectedContentLength) - } - // HEAD 无法获取大小,用 Content-Length 头部字段兜底 - if let http = response as? HTTPURLResponse, - let lengthStr = http.value(forHTTPHeaderField: "Content-Length"), - let length = Int(lengthStr), length > 0 { - return length - } - // 最终回退:Range 请求获取大小,避免全量下载 + + URLSession.shared.dataTask(with: request) { [weak self] _, response, error in + if let error = error { + completion(.failure(error)) + return + } + + if let http = response as? HTTPURLResponse { + if let lengthStr = http.allHeaderFields["Content-Length"] as? String, + let length = Int(lengthStr), length > 0 { + completion(.success(length)) + return + } + if http.expectedContentLength > 0 { + completion(.success(Int(http.expectedContentLength))) + return + } + } + + // Fallback: Range request + self?.getFileSizeViaRange(fileUrl: fileUrl, completion: completion) + }.resume() + } + + private func getFileSizeViaRange(fileUrl: URL, completion: @escaping (Result) -> Void) { var rangeReq = URLRequest(url: fileUrl) rangeReq.httpMethod = "GET" rangeReq.setValue("bytes=0-0", forHTTPHeaderField: "Range") - let (_, rangeResp) = try await URLSession.shared.data(for: rangeReq) - if let http = rangeResp as? HTTPURLResponse, - let rangeHeader = http.value(forHTTPHeaderField: "Content-Range"), - let totalStr = rangeHeader.split(separator: "/").last, - let total = Int(totalStr), total > 0 { - return total - } - throw DuooomiBleError.transferFailed("Cannot determine file size: \(url)") + + URLSession.shared.dataTask(with: rangeReq) { _, response, error in + if let error = error { + completion(.failure(error)) + return + } + if let http = response as? HTTPURLResponse, + let rangeHeader = http.allHeaderFields["Content-Range"] as? String, + let totalStr = rangeHeader.split(separator: "/").last, + let total = Int(totalStr), total > 0 { + completion(.success(total)) + return + } + completion(.failure(DuooomiBleError.transferFailed("Cannot determine file size: \(fileUrl.absoluteString)"))) + }.resume() } // MARK: - Request-Response Pattern @@ -447,113 +563,74 @@ public final class DuooomiBleSDK: ObservableObject { private func sendAndWait( commandType: CommandType, timeout: TimeInterval = 10, - send: @escaping () async throws -> Void - ) async throws -> Data { - try await sendAndWait( - opId: "\(commandType.rawValue)", - command: commandType, - timeout: timeout, - send: send - ) + completion: @escaping (Result) -> Void, + send: @escaping () throws -> Void + ) { + let opId = "\(commandType.rawValue)" + sendAndWait(opId: opId, commandType: commandType, timeout: timeout, completion: completion, send: send) } private func sendAndWait( opId: String, - command: CommandType, + commandType: CommandType, timeout: TimeInterval = 10, - send: @escaping () async throws -> Void - ) async throws -> Data { - BleLog.d("Register opId=\(opId), cmd=\(command)", "RPC") - // Register continuation BEFORE sending so a fast device response can't race past us. - // Use a defer block to guarantee opId removal regardless of timeout/send/cancel outcome. + completion: @escaping (Result) -> Void, + send: @escaping () throws -> Void + ) { + BleLog.d("Register opId=\(opId), cmd=\(commandType)", "RPC") + + // Register callback BEFORE sending + callbackQueue.sync { + self.pendingCallbacks[opId] = { result in + DispatchQueue.main.async { + completion(result) + } + } + } + + // Setup timeout + let timeoutWork = DispatchWorkItem { [weak self] in + guard let self = self else { return } + self.callbackQueue.sync { + if let cb = self.pendingCallbacks.removeValue(forKey: opId) { + self.pendingTimeouts.removeValue(forKey: opId) + cb(.failure(DuooomiBleError.timeout(command: commandType))) + } + } + } + callbackQueue.sync { + pendingTimeouts[opId] = timeoutWork + } + DispatchQueue.global().asyncAfter(deadline: .now() + timeout, execute: timeoutWork) + + // Send command do { - return try await withThrowingTaskGroup(of: Data.self) { group in - group.addTask { @MainActor [self] in - try await withCheckedThrowingContinuation { continuation in - self.pendingContinuations[opId] = continuation - BleLog.d("Continuation stored for opId=\(opId)", "RPC") - } - } - - group.addTask { - // Send first; if send fails, propagate immediately instead of waiting timeout. - try await send() - BleLog.d("Command sent for opId=\(opId)", "RPC") - try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) - throw DuooomiBleError.timeout(command: command) - } - - defer { group.cancelAll() } - guard let result = try await group.next() else { - throw DuooomiBleError.timeout(command: command) - } - BleLog.d("Result received for opId=\(opId)", "RPC") - return result - } + try send() + BleLog.d("Command sent for opId=\(opId)", "RPC") } catch { - // Whatever the failure (timeout, cancellation, send error), make sure no stale - // continuation is left in the dictionary so a late device response cannot resume - // a continuation that has already been released. - await MainActor.run { - if let stale = self.pendingContinuations.removeValue(forKey: opId) { - stale.resume(throwing: error) - } + callbackQueue.sync { + self.pendingCallbacks.removeValue(forKey: opId) + self.pendingTimeouts.removeValue(forKey: opId)?.cancel() } - BleLog.e("RPC failed for opId=\(opId): \(error.localizedDescription)", "RPC") - throw error - } - } - - // MARK: - Message Listener - - private func startMessageListener() { - messageListenerTask?.cancel() - messageListenerTask = Task { [weak self] in - guard let self else { return } - BleLog.i("Message listener started", "RPC") - for await (commandType, data) in self.protocolService.incomingMessages { - guard !Task.isCancelled else { break } - BleLog.d("Incoming message: type=\(commandType), size=\(data.count)", "RPC") - await self.handleIncomingMessage(commandType: commandType, data: data) + BleLog.e("Send failed for opId=\(opId): \(error.localizedDescription)", "RPC") + DispatchQueue.main.async { + completion(.failure(error)) } } } - @MainActor - private func handleIncomingMessage(commandType: UInt8, data: Data) { - let opId = "\(commandType)" - - // Check for prepareTransfer with key-based opId - if commandType == CommandType.prepareTransfer.rawValue { - if let resp = try? decodeResponse(PrepareTransferResponse.self, from: data) { - let keyedOpId = "\(commandType)_\(resp.key)" - if let continuation = pendingContinuations.removeValue(forKey: keyedOpId) { - BleLog.d("Resuming keyed opId=\(keyedOpId)", "RPC") - continuation.resume(returning: data) - return - } - } - } - - if let continuation = pendingContinuations.removeValue(forKey: opId) { - BleLog.d("Resuming opId=\(opId)", "RPC") - continuation.resume(returning: data) - } else { - BleLog.w("No pending continuation for opId=\(opId)", "RPC") - } - } - // MARK: - Helpers - private func ensureConnected() throws { + private func ensureConnected(completion: @escaping (Result) -> Void) -> Bool { guard connectedDevice != nil else { BleLog.w("Operation requires connection", "SDK") - throw DuooomiBleError.notConnected + completion(.failure(DuooomiBleError.notConnected)) + return false } + return true } private func decodeResponse(_ type: T.Type, from data: Data) throws -> T { - // Device responses may contain null bytes let cleaned = data.filter { $0 != 0 } do { return try JSONDecoder().decode(type, from: cleaned) @@ -565,16 +642,30 @@ public final class DuooomiBleSDK: ObservableObject { } private func cancelAllPending(error: Error) { - let continuations = pendingContinuations - pendingContinuations.removeAll() - for (_, continuation) in continuations { - continuation.resume(throwing: error) + callbackQueue.sync { + let callbacks = self.pendingCallbacks + self.pendingCallbacks.removeAll() + for (_, timeout) in self.pendingTimeouts { + timeout.cancel() + } + self.pendingTimeouts.removeAll() + for (_, cb) in callbacks { + cb(.failure(error)) + } + } + } + + private func notifyMain(_ block: @escaping () -> Void) { + if Thread.isMainThread { + block() + } else { + DispatchQueue.main.async(execute: block) } } // MARK: - Scan Batching (500ms throttle) - private func queueDevice(_ device: DiscoveredDevice) { + fileprivate func queueDevice(_ device: DiscoveredDevice) { guard !allDiscoveredDevices.contains(where: { $0.id == device.id }) else { return } allDiscoveredDevices.append(device) @@ -582,9 +673,7 @@ public final class DuooomiBleSDK: ObservableObject { flushWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in - Task { @MainActor [weak self] in - self?.flushDevices() - } + self?.flushDevices() } flushWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: workItem) @@ -597,3 +686,66 @@ public final class DuooomiBleSDK: ObservableObject { BleLog.d("Discovered devices flushed: total=\(discoveredDevices.count)", "Scan") } } + +// MARK: - BleProtocolServiceDelegate (incoming messages) + +extension DuooomiBleSDK: BleProtocolServiceDelegate { + + func protocolService(_ service: BleProtocolService, didReceiveMessage commandType: UInt8, data: Data) { + BleLog.d("Incoming message: type=\(commandType), size=\(data.count)", "RPC") + + let opId = "\(commandType)" + + // Check for prepareTransfer with key-based opId + if commandType == CommandType.prepareTransfer.rawValue { + if let resp = try? decodeResponse(PrepareTransferResponse.self, from: data) { + let keyedOpId = "\(commandType)_\(resp.key)" + var cb: ((Result) -> Void)? + callbackQueue.sync { + cb = self.pendingCallbacks.removeValue(forKey: keyedOpId) + self.pendingTimeouts.removeValue(forKey: keyedOpId)?.cancel() + } + if let cb = cb { + BleLog.d("Resuming keyed opId=\(keyedOpId)", "RPC") + cb(.success(data)) + return + } + } + } + + var cb: ((Result) -> Void)? + callbackQueue.sync { + cb = self.pendingCallbacks.removeValue(forKey: opId) + self.pendingTimeouts.removeValue(forKey: opId)?.cancel() + } + + if let cb = cb { + BleLog.d("Resuming opId=\(opId)", "RPC") + cb(.success(data)) + } else { + BleLog.w("No pending callback for opId=\(opId)", "RPC") + } + } +} + +// MARK: - Scan Proxy + +/// Forwards scan results to SDK while keeping protocol service as data receiver. +private class ScanBleClientDelegate: BleClientDelegate { + private weak var sdk: DuooomiBleSDK? + private let original: BleClientDelegate? + + init(sdk: DuooomiBleSDK, original: BleClientDelegate?) { + self.sdk = sdk + self.original = original + } + + func bleClient(_ client: BleClient, didDiscoverDevice device: DiscoveredDevice) { + BleLog.d("Discovered: id=\(device.id), name=\(device.name ?? "-"), rssi=\(device.rssi)", "Scan") + sdk?.queueDevice(device) + } + + func bleClient(_ client: BleClient, didReceiveData data: Data) { + original?.bleClient(client, didReceiveData: data) + } +} diff --git a/Sources/DuooomiBleSDK/DuooomiBleSDKDelegate.swift b/Sources/DuooomiBleSDK/DuooomiBleSDKDelegate.swift new file mode 100644 index 0000000..9f99111 --- /dev/null +++ b/Sources/DuooomiBleSDK/DuooomiBleSDKDelegate.swift @@ -0,0 +1,34 @@ +import Foundation + +/// SDK 状态变化回调协议,替代 Combine @Published 观察。 +/// 所有方法在主线程调用。 +public protocol DuooomiBleSDKDelegate: AnyObject { + /// 蓝牙连接状态变化 + func sdk(_ sdk: DuooomiBleSDK, didChangeState state: ConnectionState) + /// 扫描到的设备列表更新 + func sdk(_ sdk: DuooomiBleSDK, didUpdateDevices devices: [DiscoveredDevice]) + /// 已连接设备变化 + func sdk(_ sdk: DuooomiBleSDK, didConnectDevice device: DiscoveredDevice?) + /// 设备信息更新 + func sdk(_ sdk: DuooomiBleSDK, didUpdateDeviceInfo info: DeviceInfo?) + /// 固件版本更新 + func sdk(_ sdk: DuooomiBleSDK, didUpdateVersion version: String) + /// 激活状态变化 + func sdk(_ sdk: DuooomiBleSDK, didChangeActivation isActivated: Bool) + /// 传输进度更新 (0-100) + func sdk(_ sdk: DuooomiBleSDK, didUpdateProgress progress: Int) + /// 错误发生 + func sdk(_ sdk: DuooomiBleSDK, didEncounterError error: String?) +} + +/// 所有 delegate 方法提供默认空实现,调用方只需实现关心的方法。 +public extension DuooomiBleSDKDelegate { + func sdk(_ sdk: DuooomiBleSDK, didChangeState state: ConnectionState) {} + func sdk(_ sdk: DuooomiBleSDK, didUpdateDevices devices: [DiscoveredDevice]) {} + func sdk(_ sdk: DuooomiBleSDK, didConnectDevice device: DiscoveredDevice?) {} + func sdk(_ sdk: DuooomiBleSDK, didUpdateDeviceInfo info: DeviceInfo?) {} + func sdk(_ sdk: DuooomiBleSDK, didUpdateVersion version: String) {} + func sdk(_ sdk: DuooomiBleSDK, didChangeActivation isActivated: Bool) {} + func sdk(_ sdk: DuooomiBleSDK, didUpdateProgress progress: Int) {} + func sdk(_ sdk: DuooomiBleSDK, didEncounterError error: String?) {} +} diff --git a/Sources/DuooomiBleSDK/Models/BindingResponse.swift b/Sources/DuooomiBleSDK/Models/BindingResponse.swift index 79a4baf..3667304 100644 --- a/Sources/DuooomiBleSDK/Models/BindingResponse.swift +++ b/Sources/DuooomiBleSDK/Models/BindingResponse.swift @@ -1,6 +1,6 @@ import Foundation -public struct BindingResponse: Codable, Sendable, Equatable { +public struct BindingResponse: Codable, Equatable { public let type: Int /// 设备 SN 码 (14 位) public let sn: String diff --git a/Sources/DuooomiBleSDK/Models/DeleteFileResponse.swift b/Sources/DuooomiBleSDK/Models/DeleteFileResponse.swift index f1f9c47..9d757b6 100644 --- a/Sources/DuooomiBleSDK/Models/DeleteFileResponse.swift +++ b/Sources/DuooomiBleSDK/Models/DeleteFileResponse.swift @@ -1,6 +1,6 @@ import Foundation -public struct DeleteFileResponse: Codable, Sendable, Equatable { +public struct DeleteFileResponse: Codable, Equatable { public let type: Int /// 0 = 成功, 1 = 失败, 2 = 文件不存在 public let success: Int diff --git a/Sources/DuooomiBleSDK/Models/DeviceInfo.swift b/Sources/DuooomiBleSDK/Models/DeviceInfo.swift index c4dcbc2..366d33a 100644 --- a/Sources/DuooomiBleSDK/Models/DeviceInfo.swift +++ b/Sources/DuooomiBleSDK/Models/DeviceInfo.swift @@ -1,6 +1,6 @@ import Foundation -public struct DeviceInfo: Sendable, Equatable { +public struct DeviceInfo: Equatable { public let allspace: UInt64 public let freespace: UInt64 public let name: String diff --git a/Sources/DuooomiBleSDK/Models/PrepareTransferResponse.swift b/Sources/DuooomiBleSDK/Models/PrepareTransferResponse.swift index 14f3127..3d0fff3 100644 --- a/Sources/DuooomiBleSDK/Models/PrepareTransferResponse.swift +++ b/Sources/DuooomiBleSDK/Models/PrepareTransferResponse.swift @@ -1,6 +1,6 @@ import Foundation -public struct PrepareTransferResponse: Codable, Sendable, Equatable { +public struct PrepareTransferResponse: Codable, Equatable { public let type: Int public let key: String /// "ready" | "no_space" | "duplicated" diff --git a/Sources/DuooomiBleSDK/Models/UnbindResponse.swift b/Sources/DuooomiBleSDK/Models/UnbindResponse.swift index e0aa098..a49c6dd 100644 --- a/Sources/DuooomiBleSDK/Models/UnbindResponse.swift +++ b/Sources/DuooomiBleSDK/Models/UnbindResponse.swift @@ -1,6 +1,6 @@ import Foundation -public struct UnbindResponse: Codable, Sendable, Equatable { +public struct UnbindResponse: Codable, Equatable { /// 1 = 成功, 0 = 失败 public let success: Int } diff --git a/Sources/DuooomiBleSDK/Models/VersionInfo.swift b/Sources/DuooomiBleSDK/Models/VersionInfo.swift index bea080e..d22b4b2 100644 --- a/Sources/DuooomiBleSDK/Models/VersionInfo.swift +++ b/Sources/DuooomiBleSDK/Models/VersionInfo.swift @@ -1,6 +1,6 @@ import Foundation -public struct VersionInfo: Sendable, Equatable { +public struct VersionInfo: Equatable { public let version: String /// 命令类型标识,设备可能返回 Int (如 7) 或 String (如 "0x07") public let type: String diff --git a/Sources/DuooomiBleSDK/Protocol/ProtocolConstants.swift b/Sources/DuooomiBleSDK/Protocol/ProtocolConstants.swift index e4841a5..3b7684c 100644 --- a/Sources/DuooomiBleSDK/Protocol/ProtocolConstants.swift +++ b/Sources/DuooomiBleSDK/Protocol/ProtocolConstants.swift @@ -21,7 +21,7 @@ public enum FrameConstants { public static let screenSize = 360 } -public enum CommandType: UInt8, Sendable { +public enum CommandType: UInt8 { case otaPackage = 0x02 case transferBootAnimation = 0x03 case transferAniVideo = 0x05 diff --git a/Sources/DuooomiBleSDK/Protocol/ProtocolFrame.swift b/Sources/DuooomiBleSDK/Protocol/ProtocolFrame.swift index 8fb38f8..0fc67a6 100644 --- a/Sources/DuooomiBleSDK/Protocol/ProtocolFrame.swift +++ b/Sources/DuooomiBleSDK/Protocol/ProtocolFrame.swift @@ -2,7 +2,7 @@ import Foundation /// BLE 协议帧结构 /// 格式: [head:1][type:1][subpageTotal:2][curPage:2][dataLen:2][data:N][checksum:1] -public struct ProtocolFrame: Sendable { +public struct ProtocolFrame { /// 传输方向标识 (0xC7 = APP→Device, 0xB0 = Device→APP) public let head: UInt8 /// 命令类型,对应 CommandType diff --git a/Sources/DuooomiBleSDK/Services/AniConverter.swift b/Sources/DuooomiBleSDK/Services/AniConverter.swift index 77ee4a7..ef1482c 100644 --- a/Sources/DuooomiBleSDK/Services/AniConverter.swift +++ b/Sources/DuooomiBleSDK/Services/AniConverter.swift @@ -10,7 +10,7 @@ final class AniConverter { private let config: DuooomiBleConfig private var endpoint: URL { - config.apiHost.appendingPathComponent("api/auth/loomart/file/convert-to-ani") + URL(string: "\(config.apiHost)/api/auth/loomart/file/convert-to-ani")! } private let session: URLSession = { @@ -26,15 +26,23 @@ final class AniConverter { } /// 调用 ANI 转换接口,返回 ani 文件的 CDN URL。 - func convert(fileUrl: String) async throws -> String { - do { - return try await performConvert(fileUrl: fileUrl) - } catch let error as NSError where error.code == NSURLErrorNetworkConnectionLost { - return try await performConvert(fileUrl: fileUrl) + func convert(fileUrl: String, completion: @escaping (Result) -> Void) { + performConvert(fileUrl: fileUrl) { result in + switch result { + case .success: + completion(result) + case .failure(let error): + // Retry once on connection lost + if (error as NSError).code == NSURLErrorNetworkConnectionLost { + self.performConvert(fileUrl: fileUrl, completion: completion) + } else { + completion(.failure(error)) + } + } } } - private func performConvert(fileUrl: String) async throws -> String { + private func performConvert(fileUrl: String, completion: @escaping (Result) -> Void) { var request = URLRequest(url: endpoint) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "content-type") @@ -47,32 +55,49 @@ final class AniConverter { "height": config.aniHeight, "fps": config.aniFps, ] - request.httpBody = try JSONSerialization.data(withJSONObject: body) - let (data, response) = try await session.data(for: request) - - if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { - throw AniConverterError(message: "HTTP \(http.statusCode)") + do { + request.httpBody = try JSONSerialization.data(withJSONObject: body) + } catch { + completion(.failure(error)) + return } - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw AniConverterError(message: "Invalid JSON response") - } + session.dataTask(with: request) { data, response, error in + if let error = error { + completion(.failure(error)) + return + } - guard (json["success"] as? Bool) == true, - let outer = json["data"] as? [String: Any] else { - throw AniConverterError(message: "ANI API reported failure") - } + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + completion(.failure(AniConverterError(message: "HTTP \(http.statusCode)"))) + return + } - if (outer["status"] as? Bool) != true { - let msg = (outer["msg"] as? String) ?? "ANI conversion failed" - throw AniConverterError(message: msg) - } + guard let data = data, + let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { + completion(.failure(AniConverterError(message: "Invalid JSON response"))) + return + } - guard let aniUrl = outer["data"] as? String, !aniUrl.isEmpty else { - throw AniConverterError(message: "Missing ani url in response") - } + guard (json["success"] as? Bool) == true, + let outer = json["data"] as? [String: Any] else { + completion(.failure(AniConverterError(message: "ANI API reported failure"))) + return + } - return aniUrl + if (outer["status"] as? Bool) != true { + let msg = (outer["msg"] as? String) ?? "ANI conversion failed" + completion(.failure(AniConverterError(message: msg))) + return + } + + guard let aniUrl = outer["data"] as? String, !aniUrl.isEmpty else { + completion(.failure(AniConverterError(message: "Missing ani url in response"))) + return + } + + completion(.success(aniUrl)) + }.resume() } } diff --git a/Sources/DuooomiBleSDK/Services/BleProtocolService.swift b/Sources/DuooomiBleSDK/Services/BleProtocolService.swift index f62ca68..b251271 100644 --- a/Sources/DuooomiBleSDK/Services/BleProtocolService.swift +++ b/Sources/DuooomiBleSDK/Services/BleProtocolService.swift @@ -1,8 +1,16 @@ import Foundation +/// 协议服务内部回调 +protocol BleProtocolServiceDelegate: AnyObject { + func protocolService(_ service: BleProtocolService, didReceiveMessage commandType: UInt8, data: Data) +} + /// 协议通信服务:帧收发、分包重组 -final class BleProtocolService: @unchecked Sendable { +final class BleProtocolService: BleClientDelegate { + + weak var delegate: BleProtocolServiceDelegate? private let client: BleClient + private var isListening = false /// 分包重组会话 private struct FragmentSession { @@ -11,63 +19,36 @@ final class BleProtocolService: @unchecked Sendable { } private var fragments: [String: FragmentSession] = [:] - private var listenerTask: Task? - - private var messageContinuation: AsyncStream<(commandType: UInt8, data: Data)>.Continuation? - private(set) var incomingMessages: AsyncStream<(commandType: UInt8, data: Data)>! init(client: BleClient) { self.client = client - incomingMessages = AsyncStream { continuation in - self.messageContinuation = continuation - } BleLog.i("Protocol service created", "Protocol") } // MARK: - Start/Stop Listening - /// 开始监听底层通知并进行帧解析与重组。 - /// - Note: 重新创建消息流并清空历史分片缓存。 func startListening() { - // Recreate the stream in case it was previously finished - incomingMessages = AsyncStream { continuation in - self.messageContinuation = continuation - } - + isListening = true + client.delegate = self fragments.removeAll() - - listenerTask = Task { [weak self] in - guard let self else { return } - BleLog.i("Start listening for notifications", "Protocol") - for await data in self.client.notifications() { - self.handleRawData(data) - } - } + BleLog.i("Start listening for notifications", "Protocol") } - /// 停止监听,结束消息流并清理缓存。 func stopListening() { - listenerTask?.cancel() - listenerTask = nil - messageContinuation?.finish() - messageContinuation = nil + isListening = false fragments.removeAll() BleLog.i("Stop listening; fragments cleared", "Protocol") } // MARK: - Send - /// 发送二进制数据(自动拆分为协议帧)。 - /// - Parameters: - /// - type: 命令类型值。 - /// - data: 需要发送的原始负载。 - /// - onProgress: 发送进度回调(0.0~1.0)。 - /// - Throws: 写入失败或负载过大等错误。 + /// 发送二进制数据(自动拆分为协议帧),使用回调。 func send( type: UInt8, data: Data, - onProgress: ((Double) -> Void)? = nil - ) async throws { + onProgress: ((Double) -> Void)? = nil, + completion: @escaping (Result) -> Void + ) { let maxWriteLen = client.maximumWriteLength let maxDataSize = min( maxWriteLen - FrameConstants.headerSize - FrameConstants.footerSize, @@ -84,27 +65,84 @@ final class BleProtocolService: @unchecked Sendable { guard !frames.isEmpty else { BleLog.e("Payload too large; frames empty", "Protocol") - throw DuooomiBleError.transferFailed("Payload too large: exceeds 65535-page limit") + completion(.failure(DuooomiBleError.transferFailed("Payload too large: exceeds 65535-page limit"))) + return } let total = frames.count BleLog.d("Sending frames: total=\(total), maxChunk=\(safeMaxDataSize)", "Protocol") - for (index, frame) in frames.enumerated() { - try client.writeWithoutResponse(frame) - try await Task.sleep(nanoseconds: FrameConstants.frameIntervalNanos) + + func sendFrame(at index: Int) { + guard index < total else { + onProgress?(1.0) + BleLog.i("Frames sent: total=\(total)", "Protocol") + completion(.success(())) + return + } + + do { + try self.client.writeWithoutResponse(frames[index]) + } catch { + completion(.failure(error)) + return + } + onProgress?(Double(index + 1) / Double(total)) + + if index + 1 < total { + // Inter-frame delay 35ms + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.035) { + sendFrame(at: index + 1) + } + } else { + onProgress?(1.0) + BleLog.i("Frames sent: total=\(total)", "Protocol") + completion(.success(())) + } } - BleLog.i("Frames sent: total=\(total)", "Protocol") + + sendFrame(at: 0) } - /// 发送 JSON 命令,内部完成编码与帧拆分。 - /// - Parameters: - /// - type: 命令类型。 - /// - payload: 可编码的命令体。 - /// - Throws: 编码/发送失败时抛出。 - func sendJSON(type: CommandType, payload: T) async throws { + /// 发送 JSON 命令(带回调) + func sendJSON(type: CommandType, payload: T, completion: @escaping (Result) -> Void) { + do { + let data = try JSONEncoder().encode(payload) + send(type: type.rawValue, data: data, completion: completion) + } catch { + completion(.failure(error)) + } + } + + /// 发送 JSON 命令(同步,fire-and-forget,仅发第一帧,用于简单命令) + func sendJSON(type: CommandType, payload: T) throws { let jsonData = try JSONEncoder().encode(payload) - try await send(type: type.rawValue, data: jsonData) + let maxWriteLen = client.maximumWriteLength + let maxDataSize = min( + maxWriteLen - FrameConstants.headerSize - FrameConstants.footerSize, + FrameConstants.maxDataSize + ) + let safeMaxDataSize = max(1, maxDataSize) + let frames = ProtocolManager.createFrames( + type: type.rawValue, + data: jsonData, + head: .appToDevice, + maxDataSize: safeMaxDataSize + ) + for frame in frames { + try client.writeWithoutResponse(frame) + } + } + + // MARK: - BleClientDelegate + + func bleClient(_ client: BleClient, didDiscoverDevice device: DiscoveredDevice) { + // Not handled here — scan results forwarded by SDK + } + + func bleClient(_ client: BleClient, didReceiveData data: Data) { + guard isListening else { return } + handleRawData(data) } // MARK: - Receive & Reassemble @@ -119,15 +157,12 @@ final class BleProtocolService: @unchecked Sendable { handleFragment(frame) } else { BleLog.d("Single frame received: type=\(frame.type), len=\(frame.data.count)", "Protocol") - messageContinuation?.yield((commandType: frame.type, data: frame.data)) + delegate?.protocolService(self, didReceiveMessage: frame.type, data: frame.data) } } private func handleFragment(_ frame: ProtocolFrame) { - // Match TS reference behavior: scope reassembly per peripheral so concurrent transfers - // (or stale fragments from a previous device after reconnect) cannot pollute each other. - let deviceKey = client.connectedPeripheral?.identifier.uuidString ?? "unknown" - let key = "\(deviceKey)_\(frame.type)" + let key = "\(frame.type)" if fragments[key] == nil { fragments[key] = FragmentSession(total: Int(frame.subpageTotal), frames: [:]) @@ -153,6 +188,6 @@ final class BleProtocolService: @unchecked Sendable { fragments.removeValue(forKey: key) BleLog.i("Fragment reassembled: key=\(key), size=\(combined.count)", "Protocol") - messageContinuation?.yield((commandType: frame.type, data: combined)) + delegate?.protocolService(self, didReceiveMessage: frame.type, data: combined) } } diff --git a/Sources/DuooomiBleSDK/Services/DeviceInfoService.swift b/Sources/DuooomiBleSDK/Services/DeviceInfoService.swift index 4ac7b04..2794867 100644 --- a/Sources/DuooomiBleSDK/Services/DeviceInfoService.swift +++ b/Sources/DuooomiBleSDK/Services/DeviceInfoService.swift @@ -1,6 +1,6 @@ import Foundation -/// 设备命令服务:发送 JSON 命令,等待协议响应 +/// 设备命令服务:发送 JSON 命令(fire-and-forget,响应由协议层回调处理) final class DeviceInfoService { private let protocolService: BleProtocolService @@ -10,61 +10,43 @@ final class DeviceInfoService { // MARK: - Commands - /// 发送获取设备信息命令。 - /// - Throws: 发送失败时抛出。 - func getDeviceInfo() async throws { - try await protocolService.sendJSON( + func getDeviceInfo() throws { + try protocolService.sendJSON( type: .getDeviceInfo, payload: CommandPayload(type: CommandType.getDeviceInfo.rawValue) ) } - /// 发送获取设备版本命令。 - /// - Throws: 发送失败时抛出。 - func getDeviceVersion() async throws { - try await protocolService.sendJSON( + func getDeviceVersion() throws { + try protocolService.sendJSON( type: .getDeviceVersion, payload: CommandPayload(type: CommandType.getDeviceVersion.rawValue) ) } - /// 发送绑定命令。 - /// - Parameter userId: 用户唯一标识。 - /// - Throws: 发送失败时抛出。 - func bindDevice(userId: String) async throws { - try await protocolService.sendJSON( + func bindDevice(userId: String) throws { + try protocolService.sendJSON( type: .bindDevice, payload: BindPayload(type: CommandType.bindDevice.rawValue, userId: userId) ) } - /// 发送解绑命令。 - /// - Parameter userId: 用户唯一标识。 - /// - Throws: 发送失败时抛出。 - func unbindDevice(userId: String) async throws { - try await protocolService.sendJSON( + func unbindDevice(userId: String) throws { + try protocolService.sendJSON( type: .unbindDevice, payload: BindPayload(type: CommandType.unbindDevice.rawValue, userId: userId) ) } - /// 发送删除文件命令。 - /// - Parameter key: 文件 key 或完整 URL。 - /// - Throws: 发送失败时抛出。 - func deleteFile(key: String) async throws { - try await protocolService.sendJSON( + func deleteFile(key: String) throws { + try protocolService.sendJSON( type: .deleteFile, payload: FileKeyPayload(type: CommandType.deleteFile.rawValue, key: key) ) } - /// 发送传输准备命令。 - /// - Parameters: - /// - key: 文件标识 key。 - /// - size: 文件大小(字节)。 - /// - Throws: 发送失败时抛出。 - func prepareTransfer(key: String, size: Int) async throws { - try await protocolService.sendJSON( + func prepareTransfer(key: String, size: Int) throws { + try protocolService.sendJSON( type: .prepareTransfer, payload: PrepareTransferPayload( type: CommandType.prepareTransfer.rawValue, diff --git a/Sources/DuooomiBleSDK/Services/FileTransferService.swift b/Sources/DuooomiBleSDK/Services/FileTransferService.swift index 5bf6bce..a6712bc 100644 --- a/Sources/DuooomiBleSDK/Services/FileTransferService.swift +++ b/Sources/DuooomiBleSDK/Services/FileTransferService.swift @@ -9,40 +9,60 @@ final class FileTransferService { } /// 传输文件到设备 - /// - Parameters: - /// - fileUri: 本地 file:// URL 或远程 https:// URL - /// - commandType: 传输命令类型 - /// - onProgress: 进度回调 0.0 ~ 1.0 func transferFile( fileUri: String, commandType: CommandType, - onProgress: ((Double) -> Void)? = nil - ) async throws { + onProgress: ((Double) -> Void)? = nil, + completion: @escaping (Result) -> Void + ) { BleLog.i("Load file: \(fileUri)", "Transfer") - let data = try await loadFileData(from: fileUri) - BleLog.d("File loaded: size=\(data.count) bytes", "Transfer") - - try await protocolService.send( - type: commandType.rawValue, - data: data, - onProgress: onProgress - ) + loadFileData(from: fileUri) { [weak self] result in + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let data): + BleLog.d("File loaded: size=\(data.count) bytes", "Transfer") + self?.protocolService.send( + type: commandType.rawValue, + data: data, + onProgress: onProgress, + completion: completion + ) + } + } } - private func loadFileData(from uri: String) async throws -> Data { + private func loadFileData(from uri: String, completion: @escaping (Result) -> Void) { guard let url = URL(string: uri) else { - throw DuooomiBleError.transferFailed("Invalid URL: \(uri)") + completion(.failure(DuooomiBleError.transferFailed("Invalid URL: \(uri)"))) + return } if url.isFileURL { - return try Data(contentsOf: url) - } else { - let (data, response) = try await URLSession.shared.data(from: url) + do { + let data = try Data(contentsOf: url) + completion(.success(data)) + } catch { + completion(.failure(DuooomiBleError.transferFailed("Failed to read local file: \(error.localizedDescription)"))) + } + return + } + + URLSession.shared.dataTask(with: url) { data, response, error in + if let error = error { + completion(.failure(DuooomiBleError.transferFailed("Download failed: \(error.localizedDescription)"))) + return + } guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { - throw DuooomiBleError.transferFailed("Failed to download file: \(uri)") + completion(.failure(DuooomiBleError.transferFailed("Failed to download file: \(uri)"))) + return } - return data - } + guard let data = data, !data.isEmpty else { + completion(.failure(DuooomiBleError.transferFailed("Empty download response"))) + return + } + completion(.success(data)) + }.resume() } } diff --git a/Sources/DuooomiBleSDK/Services/FirmwareService.swift b/Sources/DuooomiBleSDK/Services/FirmwareService.swift index 131bba6..39c6aa6 100644 --- a/Sources/DuooomiBleSDK/Services/FirmwareService.swift +++ b/Sources/DuooomiBleSDK/Services/FirmwareService.swift @@ -1,7 +1,7 @@ import Foundation /// 固件信息 -public struct FirmwareInfo: Codable, Equatable, Sendable { +public struct FirmwareInfo: Codable, Equatable { public let version: String public let fileUrl: String public let description: String? @@ -25,20 +25,30 @@ final class FirmwareService { self.config = config } - func fetchLatest(identifier: String? = nil, status: String? = nil) async throws -> FirmwareInfo? { + func fetchLatest( + identifier: String? = nil, + status: String? = nil, + completion: @escaping (Result) -> Void + ) { let id = (identifier ?? config.firmwareIdentifier).trimmingCharacters(in: .whitespacesAndNewlines) let st = status ?? config.firmwareStatus guard !id.isEmpty else { - throw DuooomiBleError.transferFailed("Invalid firmware identifier") + completion(.failure(DuooomiBleError.transferFailed("Invalid firmware identifier"))) + return } - var comps = URLComponents(url: config.apiHost.appendingPathComponent(latestPath), resolvingAgainstBaseURL: false)! + let urlString = "\(config.apiHost)/\(latestPath)" + guard var comps = URLComponents(string: urlString) else { + completion(.failure(DuooomiBleError.transferFailed("Invalid firmware URL"))) + return + } comps.queryItems = [ URLQueryItem(name: "identifier", value: id), URLQueryItem(name: "status", value: st) ] guard let url = comps.url else { - throw DuooomiBleError.transferFailed("Invalid firmware URL") + completion(.failure(DuooomiBleError.transferFailed("Invalid firmware URL"))) + return } var req = URLRequest(url: url) @@ -46,15 +56,30 @@ final class FirmwareService { req.setValue(config.apiKey, forHTTPHeaderField: "x-api-key") req.setValue("application/json", forHTTPHeaderField: "accept") - let (data, resp) = try await URLSession.shared.data(for: req) - guard let http = resp as? HTTPURLResponse else { - throw DuooomiBleError.transferFailed("Invalid response") - } - guard (200...299).contains(http.statusCode) else { - throw DuooomiBleError.transferFailed("HTTP \(http.statusCode)") - } + URLSession.shared.dataTask(with: req) { data, resp, error in + if let error = error { + completion(.failure(error)) + return + } + guard let http = resp as? HTTPURLResponse else { + completion(.failure(DuooomiBleError.transferFailed("Invalid response"))) + return + } + guard (200...299).contains(http.statusCode) else { + completion(.failure(DuooomiBleError.transferFailed("HTTP \(http.statusCode)"))) + return + } + guard let data = data else { + completion(.failure(DuooomiBleError.transferFailed("Empty response"))) + return + } - let decoded = try JSONDecoder().decode(FirmwareResponse.self, from: data) - return decoded.data + do { + let decoded = try JSONDecoder().decode(FirmwareResponse.self, from: data) + completion(.success(decoded.data)) + } catch { + completion(.failure(error)) + } + }.resume() } } diff --git a/demo/Sources/DemoApp.swift b/demo/Sources/DemoApp.swift index a6cde0e..80cdd48 100644 --- a/demo/Sources/DemoApp.swift +++ b/demo/Sources/DemoApp.swift @@ -2,19 +2,69 @@ import SwiftUI import DuooomiBleSDK /// Demo 入口:初始化 SDK 并注入到视图树。 -/// 只需传 apiKey,其余配置(apiHost/cdnHost/firmwareIdentifier/firmwareStatus)使用默认值。 @main struct DemoApp: App { - @StateObject private var sdk = DuooomiBleSDK(config: .init( - apiKey: "" - )) + @StateObject private var viewModel = DemoViewModel() var body: some Scene { WindowGroup { NavigationView { - WrapperTestView() + WrapperTestView(viewModel: viewModel) } - .environmentObject(sdk) } } } + +/// ViewModel 包装 SDK callback API,通过 DuooomiBleSDKDelegate 驱动 UI 更新。 +class DemoViewModel: NSObject, ObservableObject, DuooomiBleSDKDelegate { + let sdk: DuooomiBleSDK + + @Published var btState: ConnectionState = .idle + @Published var connectedDevice: DiscoveredDevice? = nil + @Published var discoveredDevices: [DiscoveredDevice] = [] + @Published var deviceInfo: DeviceInfo? = nil + @Published var version: String = "" + @Published var isActivated: Bool = false + @Published var transferProgress: Int = 0 + @Published var error: String? = nil + + override init() { + sdk = DuooomiBleSDK(config: .init(apiKey: "")) + super.init() + sdk.delegate = self + } + + // MARK: - DuooomiBleSDKDelegate + + func sdk(_ sdk: DuooomiBleSDK, didChangeState state: ConnectionState) { + btState = state + } + + func sdk(_ sdk: DuooomiBleSDK, didUpdateDevices devices: [DiscoveredDevice]) { + discoveredDevices = devices + } + + func sdk(_ sdk: DuooomiBleSDK, didConnectDevice device: DiscoveredDevice?) { + connectedDevice = device + } + + func sdk(_ sdk: DuooomiBleSDK, didUpdateDeviceInfo info: DeviceInfo?) { + deviceInfo = info + } + + func sdk(_ sdk: DuooomiBleSDK, didUpdateVersion version: String) { + self.version = version + } + + func sdk(_ sdk: DuooomiBleSDK, didChangeActivation isActivated: Bool) { + self.isActivated = isActivated + } + + func sdk(_ sdk: DuooomiBleSDK, didUpdateProgress progress: Int) { + transferProgress = progress + } + + func sdk(_ sdk: DuooomiBleSDK, didEncounterError error: String?) { + self.error = error + } +} diff --git a/demo/Sources/WrapperTestView.swift b/demo/Sources/WrapperTestView.swift index bc43a9a..de41608 100644 --- a/demo/Sources/WrapperTestView.swift +++ b/demo/Sources/WrapperTestView.swift @@ -21,18 +21,9 @@ private enum CDNHelper { } // MARK: - View -// -// Demo 演示 SDK 全部功能: -// 1. Scan & Connect — sdk.scan() / sdk.connect(deviceId:) / sdk.disconnect() -// 2. Device — sdk.getDeviceInfo() / sdk.getVersion() / sdk.bind(userId:) / sdk.unbind(userId:) -// 3. Transfer — sdk.transferMedia(fileUrl:) 一步传输(内部自动 ANI 转换 + prepare + transfer) -// 4. Firmware — sdk.fetchLatestFirmware() 查询 + sdk.transferFile(fileUri:commandType:.otaPackage) OTA -// 5. Device Files — sdk.deleteFile(key:) 删除设备文件 -// -// 可观察状态:btState / connectedDevice / deviceInfo / version / transferProgress / error struct WrapperTestView: View { - @EnvironmentObject var sdk: DuooomiBleSDK + @ObservedObject var viewModel: DemoViewModel @State private var userId = "test-user-001" @State private var fileUrl = "https://cdn.bowong.cc/material/569f48a8e29f47859b3a9808be37f94c.mp4" @@ -68,9 +59,9 @@ struct WrapperTestView: View { .navigationTitle("SDK Demo") .scrollDismissesKeyboard(.interactively) .onAppear { - CDNHelper.cdnHost = sdk.config.cdnHost - firmwareBrand = sdk.config.firmwareIdentifier - firmwareStatus = sdk.config.firmwareStatus + CDNHelper.cdnHost = viewModel.sdk.config.cdnHost + firmwareBrand = viewModel.sdk.config.firmwareIdentifier + firmwareStatus = viewModel.sdk.config.firmwareStatus } } @@ -78,15 +69,15 @@ struct WrapperTestView: View { private var statusSection: some View { Section("Status") { - LabeledContent("BLE", value: sdk.btState.rawValue) - LabeledContent("Device", value: sdk.connectedDevice?.name ?? "—") - LabeledContent("Version", value: sdk.version.isEmpty ? "—" : sdk.version) - if sdk.transferProgress > 0 { - ProgressView(value: Double(sdk.transferProgress), total: 100) { - Text("\(sdk.transferProgress)%").font(.caption) + LabeledContent("BLE", value: viewModel.btState.rawValue) + LabeledContent("Device", value: viewModel.connectedDevice?.name ?? "—") + LabeledContent("Version", value: viewModel.version.isEmpty ? "—" : viewModel.version) + if viewModel.transferProgress > 0 { + ProgressView(value: Double(viewModel.transferProgress), total: 100) { + Text("\(viewModel.transferProgress)%").font(.caption) } } - if let err = sdk.error { + if let err = viewModel.error { Text(err).foregroundStyle(.red).font(.caption) } } @@ -96,19 +87,19 @@ struct WrapperTestView: View { Section("Scan & Connect") { HStack { Button("Scan") { - sdk.scan() + viewModel.sdk.scan() log("→ scan()") } .buttonStyle(.borderedProminent) Button("Stop") { - sdk.stopScan() + viewModel.sdk.stopScan() log("→ stopScan()") } .buttonStyle(.bordered) } - ForEach(sdk.discoveredDevices) { device in + ForEach(viewModel.discoveredDevices) { device in HStack { VStack(alignment: .leading) { Text(device.name ?? "Unknown") @@ -117,9 +108,9 @@ struct WrapperTestView: View { Spacer() Text("\(device.rssi) dBm").font(.caption).foregroundStyle(.secondary) - if sdk.connectedDevice?.id == device.id { + if viewModel.connectedDevice?.id == device.id { Button("Disconnect") { - Task { await runDisconnect() } + runDisconnect() } .buttonStyle(.bordered) .controlSize(.small) @@ -128,7 +119,7 @@ struct WrapperTestView: View { ProgressView().controlSize(.small) } else { Button("Connect") { - Task { await runConnect(deviceId: device.id) } + runConnect(deviceId: device.id) } .buttonStyle(.bordered) .controlSize(.small) @@ -141,23 +132,23 @@ struct WrapperTestView: View { private var deviceSection: some View { Section("Device") { - LabeledContent("Brand", value: sdk.deviceInfo?.brand ?? "—") - LabeledContent("Size", value: sdk.deviceInfo?.size ?? "—") - LabeledContent("Power", value: sdk.deviceInfo.map { "\($0.powerlevel)%" } ?? "—") - LabeledContent("Storage", value: sdk.deviceInfo.map { "\($0.freespace)/\($0.allspace)" } ?? "—") + LabeledContent("Brand", value: viewModel.deviceInfo?.brand ?? "—") + LabeledContent("Size", value: viewModel.deviceInfo?.size ?? "—") + LabeledContent("Power", value: viewModel.deviceInfo.map { "\($0.powerlevel)%" } ?? "—") + LabeledContent("Storage", value: viewModel.deviceInfo.map { "\($0.freespace)/\($0.allspace)" } ?? "—") HStack { - Button("getDeviceInfo") { Task { await runGetDeviceInfo() } } - Button("getVersion") { Task { await runGetVersion() } } + Button("getDeviceInfo") { runGetDeviceInfo() } + Button("getVersion") { runGetVersion() } } .buttonStyle(.bordered) HStack { TextField("userId", text: $userId) .textFieldStyle(.roundedBorder) - Button("bind") { Task { await runBind() } } + Button("bind") { runBind() } .buttonStyle(.bordered) - Button("unbind") { Task { await runUnbind() } } + Button("unbind") { runUnbind() } .buttonStyle(.bordered) } } @@ -171,10 +162,10 @@ struct WrapperTestView: View { .disableAutocorrection(true) Button("Transfer") { - Task { await runTransfer() } + runTransfer() } .buttonStyle(.borderedProminent) - .disabled(fileUrl.isEmpty || sdk.connectedDevice == nil || isBusy) + .disabled(fileUrl.isEmpty || viewModel.connectedDevice == nil || isBusy) } } @@ -182,7 +173,7 @@ struct WrapperTestView: View { private var firmwareSection: some View { Section("Firmware Update") { - LabeledContent("Current Version", value: sdk.version.isEmpty ? "—" : sdk.version) + LabeledContent("Current Version", value: viewModel.version.isEmpty ? "—" : viewModel.version) HStack { Text("Identifier") @@ -200,7 +191,7 @@ struct WrapperTestView: View { if let info = firmwareInfo { VStack(alignment: .leading, spacing: 6) { LabeledContent("Latest", value: info.version) - LabeledContent("Has Update", value: sdk.hasNewerFirmware(deviceVersion: sdk.version, serverVersion: info.version) ? "Yes" : "No") + LabeledContent("Has Update", value: viewModel.sdk.hasNewerFirmware(deviceVersion: viewModel.version, serverVersion: info.version) ? "Yes" : "No") if let size = info.fileSize { let bytes = Int(size) ?? 0 LabeledContent("Size", value: ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)) @@ -228,16 +219,16 @@ struct WrapperTestView: View { HStack { Button("Get Update Info") { - Task { await runFetchFirmwareInfo() } + runFetchFirmwareInfo() } .buttonStyle(.bordered) .disabled(firmwareLoading || firmwareBrand.isEmpty) Button("Upgrade") { - Task { await runUpgradeFirmware() } + runUpgradeFirmware() } .buttonStyle(.borderedProminent) - .disabled(firmwareInfo?.fileUrl.isEmpty != false || sdk.connectedDevice == nil || isBusy || firmwareLoading) + .disabled(firmwareInfo?.fileUrl.isEmpty != false || viewModel.connectedDevice == nil || isBusy || firmwareLoading) } } } @@ -278,7 +269,7 @@ struct WrapperTestView: View { Spacer() Button(role: .destructive) { - Task { await runDeleteFile(rawKey) } + runDeleteFile(rawKey) } label: { Image(systemName: "trash") } @@ -306,117 +297,139 @@ struct WrapperTestView: View { if logs.count > 200 { logs.removeLast() } } - // MARK: - Actions - // 每个 run* 方法对应一个 SDK API 调用,展示输入/输出和错误处理 + // MARK: - Actions (callback-based) - /// sdk.connect(deviceId:) → DiscoveredDevice - private func runConnect(deviceId: String) async { + private func runConnect(deviceId: String) { connectingDeviceId = deviceId - defer { connectingDeviceId = nil } log("→ connect(\(deviceId))") - do { - let device = try await sdk.connect(deviceId: deviceId) - log("connect ✓ \(device.name ?? device.id)", level: .success) - } catch { - log("connect ✗ \(error.localizedDescription)", level: .error) + viewModel.sdk.connect(deviceId: deviceId) { [self] result in + DispatchQueue.main.async { + self.connectingDeviceId = nil + switch result { + case .success(let device): + self.log("connect ✓ \(device.name ?? device.id)", level: .success) + case .failure(let error): + self.log("connect ✗ \(error.localizedDescription)", level: .error) + } + } } } - /// sdk.disconnect() - private func runDisconnect() async { + private func runDisconnect() { log("→ disconnect") - do { - try await sdk.disconnect() - deviceFiles = [] - log("disconnect ✓", level: .success) - } catch { - log("disconnect ✗ \(error.localizedDescription)", level: .error) + viewModel.sdk.disconnect { [self] result in + DispatchQueue.main.async { + switch result { + case .success: + self.deviceFiles = [] + self.log("disconnect ✓", level: .success) + case .failure(let error): + self.log("disconnect ✗ \(error.localizedDescription)", level: .error) + } + } } } - /// sdk.getDeviceInfo() → DeviceInfo - private func runGetDeviceInfo() async { + private func runGetDeviceInfo() { isBusy = true - defer { isBusy = false } log("→ getDeviceInfo") - do { - let info = try await sdk.getDeviceInfo() - log("getDeviceInfo ✓ brand=\(info.brand) power=\(info.powerlevel)%", level: .success) - } catch { - log("getDeviceInfo ✗ \(error.localizedDescription)", level: .error) + viewModel.sdk.getDeviceInfo { [self] result in + DispatchQueue.main.async { + self.isBusy = false + switch result { + case .success(let info): + self.log("getDeviceInfo ✓ brand=\(info.brand) power=\(info.powerlevel)%", level: .success) + case .failure(let error): + self.log("getDeviceInfo ✗ \(error.localizedDescription)", level: .error) + } + } } } - /// sdk.getVersion() → VersionInfo - private func runGetVersion() async { + private func runGetVersion() { isBusy = true - defer { isBusy = false } log("→ getVersion") - do { - let info = try await sdk.getVersion() - log("getVersion ✓ \(info.version)", level: .success) - } catch { - log("getVersion ✗ \(error.localizedDescription)", level: .error) + viewModel.sdk.getVersion { [self] result in + DispatchQueue.main.async { + self.isBusy = false + switch result { + case .success(let info): + self.log("getVersion ✓ \(info.version)", level: .success) + case .failure(let error): + self.log("getVersion ✗ \(error.localizedDescription)", level: .error) + } + } } } - /// sdk.bind(userId:) → BindingResponse (sn + contents)。绑定后才能查看设备文件 - private func runBind() async { + private func runBind() { isBusy = true - defer { isBusy = false } log("→ bind(\(userId))") - do { - let result = try await sdk.bind(userId: userId) - log("bind ✓ sn=\(result.sn) contents=\(result.contents.count)", level: .success) - deviceFiles = result.contents - } catch { - log("bind ✗ \(error.localizedDescription)", level: .error) + viewModel.sdk.bind(userId: userId) { [self] result in + DispatchQueue.main.async { + self.isBusy = false + switch result { + case .success(let resp): + self.log("bind ✓ sn=\(resp.sn) contents=\(resp.contents.count)", level: .success) + self.deviceFiles = resp.contents + case .failure(let error): + self.log("bind ✗ \(error.localizedDescription)", level: .error) + } + } } } - /// sdk.unbind(userId:) → UnbindResponse。解绑后该账号的设备文件将被删除 - private func runUnbind() async { + private func runUnbind() { isBusy = true - defer { isBusy = false } log("→ unbind(\(userId))") - do { - _ = try await sdk.unbind(userId: userId) - deviceFiles = [] - log("unbind ✓", level: .success) - } catch { - log("unbind ✗ \(error.localizedDescription)", level: .error) + viewModel.sdk.unbind(userId: userId) { [self] result in + DispatchQueue.main.async { + self.isBusy = false + switch result { + case .success: + self.deviceFiles = [] + self.log("unbind ✓", level: .success) + case .failure(let error): + self.log("unbind ✗ \(error.localizedDescription)", level: .error) + } + } } } - /// sdk.transferMedia(fileUrl:) — 一步完成:自动转换 ANI + prepare + transfer - private func runTransfer() async { + private func runTransfer() { isBusy = true - defer { isBusy = false } log("→ transferMedia(\(fileUrl))") - do { - try await sdk.transferMedia(fileUrl: fileUrl) - log("transfer ✓", level: .success) - } catch { - log("transfer ✗ \(error.localizedDescription)", level: .error) + viewModel.sdk.transferMedia(fileUrl: fileUrl) { [self] result in + DispatchQueue.main.async { + self.isBusy = false + switch result { + case .success: + self.log("transfer ✓", level: .success) + case .failure(let error): + self.log("transfer ✗ \(error.localizedDescription)", level: .error) + } + } } } - /// sdk.deleteFile(key:) → DeleteFileResponse - private func runDeleteFile(_ rawKey: String) async { + private func runDeleteFile(_ rawKey: String) { isBusy = true - defer { isBusy = false } log("→ deleteFile(\(rawKey))") - do { - _ = try await sdk.deleteFile(key: rawKey) - log("deleteFile ✓", level: .success) - deviceFiles.removeAll { $0 == rawKey } - } catch { - log("deleteFile ✗ \(error.localizedDescription)", level: .error) + viewModel.sdk.deleteFile(key: rawKey) { [self] result in + DispatchQueue.main.async { + self.isBusy = false + switch result { + case .success: + self.log("deleteFile ✓", level: .success) + self.deviceFiles.removeAll { $0 == rawKey } + case .failure(let error): + self.log("deleteFile ✗ \(error.localizedDescription)", level: .error) + } + } } } - /// sdk.fetchLatestFirmware(identifier:status:) → FirmwareInfo? - private func runFetchFirmwareInfo() async { + private func runFetchFirmwareInfo() { let brand = firmwareBrand.trimmingCharacters(in: .whitespacesAndNewlines) guard !brand.isEmpty else { firmwareError = "No identifier specified" @@ -424,34 +437,41 @@ struct WrapperTestView: View { } firmwareLoading = true firmwareError = nil - defer { firmwareLoading = false } log("→ fetchLatestFirmware(\(brand), \(firmwareStatus))") - do { - let info = try await sdk.fetchLatestFirmware(identifier: brand, status: firmwareStatus) - firmwareInfo = info - if let info { - let hasUpdate = sdk.hasNewerFirmware(deviceVersion: sdk.version, serverVersion: info.version) - log("firmware latest: \(info.version), hasUpdate=\(hasUpdate)", level: .success) - } else { - log("no firmware available", level: .info) + viewModel.sdk.fetchLatestFirmware(identifier: brand, status: firmwareStatus) { [self] result in + DispatchQueue.main.async { + self.firmwareLoading = false + switch result { + case .success(let info): + self.firmwareInfo = info + if let info = info { + let hasUpdate = self.viewModel.sdk.hasNewerFirmware(deviceVersion: self.viewModel.version, serverVersion: info.version) + self.log("firmware latest: \(info.version), hasUpdate=\(hasUpdate)", level: .success) + } else { + self.log("no firmware available", level: .info) + } + case .failure(let error): + self.firmwareError = error.localizedDescription + self.log("firmware info ✗ \(error.localizedDescription)", level: .error) + } } - } catch { - firmwareError = error.localizedDescription - log("firmware info ✗ \(error.localizedDescription)", level: .error) } } - /// 固件升级第二步:sdk.upgradeFirmware(fileUrl:) - private func runUpgradeFirmware() async { + private func runUpgradeFirmware() { guard let info = firmwareInfo, !info.fileUrl.isEmpty else { return } isBusy = true - defer { isBusy = false } log("→ upgradeFirmware \(info.version)") - do { - try await sdk.upgradeFirmware(fileUrl: info.fileUrl) - log("OTA ✓", level: .success) - } catch { - log("OTA ✗ \(error.localizedDescription)", level: .error) + viewModel.sdk.upgradeFirmware(fileUrl: info.fileUrl) { [self] result in + DispatchQueue.main.async { + self.isBusy = false + switch result { + case .success: + self.log("OTA ✓", level: .success) + case .failure(let error): + self.log("OTA ✗ \(error.localizedDescription)", level: .error) + } + } } } } diff --git a/docs/superpowers/plans/2026-04-16-ios12-compat-rewrite.md b/docs/superpowers/plans/2026-04-16-ios12-compat-rewrite.md new file mode 100644 index 0000000..066bd0e --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-ios12-compat-rewrite.md @@ -0,0 +1,1962 @@ +# iOS 12.2 Compatibility Rewrite + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rewrite DuooomiBleSDK to support iOS 12.2+ by replacing all async/await, Combine, AsyncStream, and Swift Concurrency APIs with callback-based alternatives. + +**Architecture:** Replace async/await with completion handlers (`Result`), replace `@Published`/`ObservableObject` with a delegate protocol, replace `AsyncStream` with delegate callbacks, replace `Task.sleep` with `DispatchQueue.asyncAfter`, replace `withThrowingTaskGroup` timeout pattern with `DispatchWorkItem` + timer. + +**Tech Stack:** Swift 5.0, CoreBluetooth, Foundation, GCD (DispatchQueue/DispatchWorkItem) + +--- + +## File Structure + +### Files to Modify (no path change) + +| File | Changes | +|------|---------| +| `Sources/DuooomiBleSDK/DuooomiBleConfig.swift` | Remove `Sendable` | +| `Sources/DuooomiBleSDK/Core/BleTypes.swift` | Remove `Sendable` from all types | +| `Sources/DuooomiBleSDK/Protocol/ProtocolConstants.swift` | Remove `Sendable` from `CommandType` | +| `Sources/DuooomiBleSDK/Protocol/ProtocolFrame.swift` | Remove `Sendable` | +| `Sources/DuooomiBleSDK/Protocol/ProtocolManager.swift` | No changes needed (pure functions, no async) | +| `Sources/DuooomiBleSDK/Utils/BleLogger.swift` | Remove `@autoclosure` if needed for compatibility | +| `Sources/DuooomiBleSDK/Models/DeviceInfo.swift` | Remove `Sendable` | +| `Sources/DuooomiBleSDK/Models/VersionInfo.swift` | Remove `Sendable` | +| `Sources/DuooomiBleSDK/Models/BindingResponse.swift` | Remove `Sendable` | +| `Sources/DuooomiBleSDK/Models/UnbindResponse.swift` | Remove `Sendable` | +| `Sources/DuooomiBleSDK/Models/DeleteFileResponse.swift` | Remove `Sendable` | +| `Sources/DuooomiBleSDK/Models/PrepareTransferResponse.swift` | Remove `Sendable` | + +### Files to Rewrite (same path, major changes) + +| File | Changes | +|------|---------| +| `Sources/DuooomiBleSDK/DuooomiBleSDK.swift` | Remove `@MainActor`, `ObservableObject`, `@Published`, all `async throws` methods → completion handlers, `withThrowingTaskGroup` → DispatchWorkItem+timer, `Task` → GCD | +| `Sources/DuooomiBleSDK/Core/BleClient.swift` | Remove `@unchecked Sendable`, `AsyncStream` → delegate, `CheckedContinuation` → closures | +| `Sources/DuooomiBleSDK/Protocol/BleProtocolService.swift` | Remove `@unchecked Sendable`, `AsyncStream` → delegate, `Task.sleep` → `DispatchQueue.asyncAfter`, async send → callback | +| `Sources/DuooomiBleSDK/Services/DeviceInfoService.swift` | `async throws` → completion handlers | +| `Sources/DuooomiBleSDK/Services/FileTransferService.swift` | `async throws` → completion, `URLSession.data(for:)` → `dataTask` | +| `Sources/DuooomiBleSDK/Services/AniConverter.swift` | `async throws` → completion, `URLSession.data(for:)` → `dataTask` | +| `Sources/DuooomiBleSDK/Services/FirmwareService.swift` | `async throws` → completion, `URLSession.data(for:)` → `dataTask` | + +### New Files + +| File | Purpose | +|------|---------| +| `Sources/DuooomiBleSDK/DuooomiBleSDKDelegate.swift` | Delegate protocol replacing `@Published` observability | + +### Config Files to Update + +| File | Changes | +|------|---------| +| `Package.swift` | `.iOS(.v12)` (Note: SPM supports `.iOS(.v12)`) | +| `project.yml` | `deploymentTarget: iOS: "12.2"` | +| `DuooomiBleSDK.podspec` | `s.ios.deployment_target = '12.2'` | +| `README.md` | Update minimum version, API examples to callback style | + +--- + +## Task 1: Remove Sendable from All Model & Config Types + +**Files:** +- Modify: `Sources/DuooomiBleSDK/DuooomiBleConfig.swift` +- Modify: `Sources/DuooomiBleSDK/Core/BleTypes.swift` +- Modify: `Sources/DuooomiBleSDK/Protocol/ProtocolConstants.swift` +- Modify: `Sources/DuooomiBleSDK/Protocol/ProtocolFrame.swift` +- Modify: `Sources/DuooomiBleSDK/Models/DeviceInfo.swift` +- Modify: `Sources/DuooomiBleSDK/Models/VersionInfo.swift` +- Modify: `Sources/DuooomiBleSDK/Models/BindingResponse.swift` +- Modify: `Sources/DuooomiBleSDK/Models/UnbindResponse.swift` +- Modify: `Sources/DuooomiBleSDK/Models/DeleteFileResponse.swift` +- Modify: `Sources/DuooomiBleSDK/Models/PrepareTransferResponse.swift` +- Modify: `Sources/DuooomiBleSDK/Services/FirmwareService.swift` (FirmwareInfo struct only) + +- [ ] **Step 1: DuooomiBleConfig.swift — Remove Sendable** + +```swift +// Before: +public struct DuooomiBleConfig: Sendable { +// After: +public struct DuooomiBleConfig { +``` + +- [ ] **Step 2: BleTypes.swift — Remove Sendable from all types** + +```swift +// Before: +public enum ConnectionState: String, Sendable { +// After: +public enum ConnectionState: String { + +// Before: +public struct DiscoveredDevice: Identifiable, Sendable, Equatable { +// After: +public struct DiscoveredDevice: Identifiable, Equatable { + +// Before: +public enum DuooomiBleError: LocalizedError, Sendable { +// After: +public enum DuooomiBleError: LocalizedError { +``` + +- [ ] **Step 3: ProtocolConstants.swift — Remove Sendable from CommandType** + +```swift +// Before: +public enum CommandType: UInt8, Sendable { +// After: +public enum CommandType: UInt8 { +``` + +- [ ] **Step 4: ProtocolFrame.swift — Remove Sendable** + +```swift +// Before: +public struct ProtocolFrame: Sendable { +// After: +public struct ProtocolFrame { +``` + +- [ ] **Step 5: All model files — Remove Sendable** + +`DeviceInfo.swift`: +```swift +// Before: +public struct DeviceInfo: Sendable, Equatable { +// After: +public struct DeviceInfo: Equatable { +``` + +`VersionInfo.swift`: +```swift +// Before: +public struct VersionInfo: Sendable, Equatable { +// After: +public struct VersionInfo: Equatable { +``` + +`BindingResponse.swift`: +```swift +// Before: +public struct BindingResponse: Codable, Sendable, Equatable { +// After: +public struct BindingResponse: Codable, Equatable { +``` + +`UnbindResponse.swift`: +```swift +// Before: +public struct UnbindResponse: Codable, Sendable, Equatable { +// After: +public struct UnbindResponse: Codable, Equatable { +``` + +`DeleteFileResponse.swift`: +```swift +// Before: +public struct DeleteFileResponse: Codable, Sendable, Equatable { +// After: +public struct DeleteFileResponse: Codable, Equatable { +``` + +`PrepareTransferResponse.swift`: +```swift +// Before: +public struct PrepareTransferResponse: Codable, Sendable, Equatable { +// After: +public struct PrepareTransferResponse: Codable, Equatable { +``` + +`FirmwareService.swift` (FirmwareInfo struct at top): +```swift +// Before: +public struct FirmwareInfo: Codable, Equatable, Sendable { +// After: +public struct FirmwareInfo: Codable, Equatable { +``` + +- [ ] **Step 6: Commit** + +```bash +git add Sources/DuooomiBleSDK/ +git commit -m "refactor: remove Sendable conformance for iOS 12.2 compat" +``` + +--- + +## Task 2: Create Delegate Protocol + +**Files:** +- Create: `Sources/DuooomiBleSDK/DuooomiBleSDKDelegate.swift` + +- [ ] **Step 1: Create delegate protocol file** + +```swift +import Foundation + +/// SDK 状态变化回调协议,替代 Combine @Published 观察。 +/// 所有方法在主线程调用。 +public protocol DuooomiBleSDKDelegate: AnyObject { + /// 蓝牙连接状态变化 + func sdk(_ sdk: DuooomiBleSDK, didChangeState state: ConnectionState) + /// 扫描到的设备列表更新 + func sdk(_ sdk: DuooomiBleSDK, didUpdateDevices devices: [DiscoveredDevice]) + /// 已连接设备变化 + func sdk(_ sdk: DuooomiBleSDK, didConnectDevice device: DiscoveredDevice?) + /// 设备信息更新 + func sdk(_ sdk: DuooomiBleSDK, didUpdateDeviceInfo info: DeviceInfo?) + /// 固件版本更新 + func sdk(_ sdk: DuooomiBleSDK, didUpdateVersion version: String) + /// 激活状态变化 + func sdk(_ sdk: DuooomiBleSDK, didChangeActivation isActivated: Bool) + /// 传输进度更新 (0-100) + func sdk(_ sdk: DuooomiBleSDK, didUpdateProgress progress: Int) + /// 错误发生 + func sdk(_ sdk: DuooomiBleSDK, didEncounterError error: String?) +} + +/// 所有 delegate 方法提供默认空实现,调用方只需实现关心的方法。 +public extension DuooomiBleSDKDelegate { + func sdk(_ sdk: DuooomiBleSDK, didChangeState state: ConnectionState) {} + func sdk(_ sdk: DuooomiBleSDK, didUpdateDevices devices: [DiscoveredDevice]) {} + func sdk(_ sdk: DuooomiBleSDK, didConnectDevice device: DiscoveredDevice?) {} + func sdk(_ sdk: DuooomiBleSDK, didUpdateDeviceInfo info: DeviceInfo?) {} + func sdk(_ sdk: DuooomiBleSDK, didUpdateVersion version: String) {} + func sdk(_ sdk: DuooomiBleSDK, didChangeActivation isActivated: Bool) {} + func sdk(_ sdk: DuooomiBleSDK, didUpdateProgress progress: Int) {} + func sdk(_ sdk: DuooomiBleSDK, didEncounterError error: String?) {} +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add Sources/DuooomiBleSDK/DuooomiBleSDKDelegate.swift +git commit -m "feat: add DuooomiBleSDKDelegate protocol" +``` + +--- + +## Task 3: Rewrite BleClient (AsyncStream + CheckedContinuation → Delegate + Closures) + +**Files:** +- Modify: `Sources/DuooomiBleSDK/Core/BleClient.swift` + +- [ ] **Step 1: Rewrite BleClient** + +Replace the entire file content with: + +```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) -> Void)? + private var disconnectCompletion: ((Result) -> 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) -> Void) { + guard centralManager.state == .poweredOn else { + completion(.failure(DuooomiBleError.bluetoothNotPoweredOn)) + 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) { + 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) + } +} +``` + +- [ ] **Step 2: Build to check compile** + +Run: `xcodebuild build -workspace demo.xcworkspace -scheme DuooomiBleSDK -destination 'generic/platform=iOS' CODE_SIGN_IDENTITY=- CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "error:|BUILD"` + +Expected: Errors in files that depend on old BleClient API (expected at this stage) + +- [ ] **Step 3: Commit** + +```bash +git add Sources/DuooomiBleSDK/Core/BleClient.swift +git commit -m "refactor: rewrite BleClient with delegate+closures (iOS 12.2)" +``` + +--- + +## Task 4: Rewrite BleProtocolService (AsyncStream → Delegate, Task.sleep → GCD) + +**Files:** +- Modify: `Sources/DuooomiBleSDK/Protocol/BleProtocolService.swift` + +- [ ] **Step 1: Rewrite BleProtocolService** + +Replace the entire file content with: + +```swift +import Foundation + +/// 协议服务内部回调 +protocol BleProtocolServiceDelegate: AnyObject { + func protocolService(_ service: BleProtocolService, didReceiveMessage commandType: UInt8, data: Data) +} + +final class BleProtocolService: BleClientDelegate { + + weak var delegate: BleProtocolServiceDelegate? + private let client: BleClient + private var isListening = false + + /// Fragment reassembly buffer: key = "\(commandType)" → accumulated data + private var fragmentBuffer: [String: Data] = [:] + private var fragmentExpected: [String: Int] = [:] + + init(client: BleClient) { + self.client = client + } + + func startListening() { + isListening = true + client.delegate = self + } + + func stopListening() { + isListening = false + fragmentBuffer.removeAll() + fragmentExpected.removeAll() + } + + // MARK: - Send + + func send(type: UInt8, data: Data, onProgress: ((Double) -> Void)? = nil, completion: @escaping (Result) -> Void) { + let frames = ProtocolManager.createFrames(type: type, data: data) + let total = frames.count + + func sendFrame(at index: Int) { + guard index < total else { + onProgress?(1.0) + completion(.success(())) + return + } + + do { + try client.writeWithoutResponse(frames[index]) + } catch { + completion(.failure(error)) + return + } + + let progress = Double(index + 1) / Double(total) + onProgress?(progress) + + if index + 1 < total { + // Inter-frame delay 35ms + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.035) { + sendFrame(at: index + 1) + } + } else { + onProgress?(1.0) + completion(.success(())) + } + } + + sendFrame(at: 0) + } + + func sendJSON(type: CommandType, payload: T, completion: @escaping (Result) -> Void) { + do { + let data = try JSONEncoder().encode(payload) + send(type: type.rawValue, data: data, completion: completion) + } catch { + completion(.failure(error)) + } + } + + // Synchronous version for fire-and-forget commands + func sendJSON(type: CommandType, payload: T) throws { + let data = try JSONEncoder().encode(payload) + let frames = ProtocolManager.createFrames(type: type.rawValue, data: data) + for frame in frames { + try client.writeWithoutResponse(frame) + } + } + + // MARK: - BleClientDelegate + + func bleClient(_ client: BleClient, didDiscoverDevice device: DiscoveredDevice) { + // Not handled here — forwarded by SDK + } + + func bleClient(_ client: BleClient, didReceiveData data: Data) { + guard isListening else { return } + guard let frame = ProtocolManager.parseFrame(data) else { + BleLog.w("Failed to parse frame (\(data.count) bytes)", "Protocol") + return + } + + let key = "\(frame.type)" + + if frame.subpageTotal <= 1 { + // Single frame + delegate?.protocolService(self, didReceiveMessage: frame.type, data: frame.data) + } else { + // Multi-frame reassembly + if fragmentBuffer[key] == nil { + fragmentBuffer[key] = Data() + fragmentExpected[key] = Int(frame.subpageTotal) + } + fragmentBuffer[key]?.append(frame.data) + + // curPage counts down: last fragment is curPage == 0 + if frame.curPage == 0 { + if let assembled = fragmentBuffer.removeValue(forKey: key) { + fragmentExpected.removeValue(forKey: key) + delegate?.protocolService(self, didReceiveMessage: frame.type, data: assembled) + } + } + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add Sources/DuooomiBleSDK/Protocol/BleProtocolService.swift +git commit -m "refactor: rewrite BleProtocolService with delegate+GCD (iOS 12.2)" +``` + +--- + +## Task 5: Rewrite DeviceInfoService (async → fire-and-forget) + +**Files:** +- Modify: `Sources/DuooomiBleSDK/Services/DeviceInfoService.swift` + +- [ ] **Step 1: Rewrite DeviceInfoService** + +Replace the entire file content with: + +```swift +import Foundation + +final class DeviceInfoService { + private let protocolService: BleProtocolService + + init(protocolService: BleProtocolService) { + self.protocolService = protocolService + } + + // All commands are fire-and-forget: send the JSON, response comes back + // via BleProtocolService delegate → DuooomiBleSDK handles matching. + + func getDeviceInfo() throws { + let payload = CommandPayload(type: CommandType.getDeviceInfo.rawValue) + try protocolService.sendJSON(type: .getDeviceInfo, payload: payload) + } + + func getDeviceVersion() throws { + let payload = CommandPayload(type: CommandType.getDeviceVersion.rawValue) + try protocolService.sendJSON(type: .getDeviceVersion, payload: payload) + } + + func bindDevice(userId: String) throws { + let payload = BindPayload(type: CommandType.bindDevice.rawValue, userId: userId) + try protocolService.sendJSON(type: .bindDevice, payload: payload) + } + + func unbindDevice(userId: String) throws { + let payload = UnbindPayload(type: CommandType.unbindDevice.rawValue, userId: userId) + try protocolService.sendJSON(type: .unbindDevice, payload: payload) + } + + func deleteFile(key: String) throws { + let payload = FileKeyPayload(type: CommandType.deleteFile.rawValue, key: key) + try protocolService.sendJSON(type: .deleteFile, payload: payload) + } + + func prepareTransfer(key: String, size: Int) throws { + let payload = PrepareTransferPayload(type: CommandType.prepareTransfer.rawValue, key: key, size: size) + try protocolService.sendJSON(type: .prepareTransfer, payload: payload) + } +} + +// MARK: - Internal Payloads + +private struct CommandPayload: Encodable { + let type: UInt8 +} + +private struct BindPayload: Encodable { + let type: UInt8 + let userId: String +} + +private struct UnbindPayload: Encodable { + let type: UInt8 + let userId: String +} + +private struct FileKeyPayload: Encodable { + let type: UInt8 + let key: String +} + +private struct PrepareTransferPayload: Encodable { + let type: UInt8 + let key: String + let size: Int +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add Sources/DuooomiBleSDK/Services/DeviceInfoService.swift +git commit -m "refactor: rewrite DeviceInfoService sync fire-and-forget (iOS 12.2)" +``` + +--- + +## Task 6: Rewrite FileTransferService (async → callback) + +**Files:** +- Modify: `Sources/DuooomiBleSDK/Services/FileTransferService.swift` + +- [ ] **Step 1: Rewrite FileTransferService** + +Replace the entire file content with: + +```swift +import Foundation + +final class FileTransferService { + private let protocolService: BleProtocolService + + init(protocolService: BleProtocolService) { + self.protocolService = protocolService + } + + /// 传输文件到设备 + func transferFile( + fileUri: String, + commandType: CommandType, + onProgress: ((Double) -> Void)? = nil, + completion: @escaping (Result) -> Void + ) { + loadFileData(from: fileUri) { [weak self] result in + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let data): + BleLog.i("File loaded: \(data.count) bytes, sending as \(commandType)", "Transfer") + self?.protocolService.send( + type: commandType.rawValue, + data: data, + onProgress: onProgress, + completion: completion + ) + } + } + } + + private func loadFileData(from uri: String, completion: @escaping (Result) -> Void) { + if uri.hasPrefix("file://"), let url = URL(string: uri) { + do { + let data = try Data(contentsOf: url) + completion(.success(data)) + } catch { + completion(.failure(DuooomiBleError.transferFailed("Failed to read local file: \(error.localizedDescription)"))) + } + return + } + + guard let url = URL(string: uri) else { + completion(.failure(DuooomiBleError.transferFailed("Invalid URL: \(uri)"))) + return + } + + let task = URLSession.shared.dataTask(with: url) { data, response, error in + if let error = error { + completion(.failure(DuooomiBleError.transferFailed("Download failed: \(error.localizedDescription)"))) + return + } + guard let data = data, !data.isEmpty else { + completion(.failure(DuooomiBleError.transferFailed("Empty download response"))) + return + } + completion(.success(data)) + } + task.resume() + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add Sources/DuooomiBleSDK/Services/FileTransferService.swift +git commit -m "refactor: rewrite FileTransferService with callbacks (iOS 12.2)" +``` + +--- + +## Task 7: Rewrite AniConverter (async → callback) + +**Files:** +- Modify: `Sources/DuooomiBleSDK/Services/AniConverter.swift` + +- [ ] **Step 1: Rewrite AniConverter** + +Replace the entire file content with: + +```swift +import Foundation + +struct AniConverterError: LocalizedError { + let message: String + var errorDescription: String? { message } +} + +final class AniConverter { + private let config: DuooomiBleConfig + + private var endpoint: URL { + URL(string: "\(config.apiHost)/api/auth/loomart/file/convert-to-ani")! + } + + private let session: URLSession = { + let cfg = URLSessionConfiguration.default + cfg.timeoutIntervalForRequest = 120 + cfg.timeoutIntervalForResource = 180 + cfg.waitsForConnectivity = true + return URLSession(configuration: cfg) + }() + + init(config: DuooomiBleConfig) { + self.config = config + } + + /// 调用 ANI 转换接口,返回 ani 文件的 CDN URL。 + func convert(fileUrl: String, completion: @escaping (Result) -> Void) { + performConvert(fileUrl: fileUrl) { result in + switch result { + case .success: + completion(result) + case .failure(let error): + // Retry once on connection lost + if (error as NSError).code == NSURLErrorNetworkConnectionLost { + self.performConvert(fileUrl: fileUrl, completion: completion) + } else { + completion(.failure(error)) + } + } + } + } + + private func performConvert(fileUrl: String, completion: @escaping (Result) -> Void) { + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.setValue(config.apiKey, forHTTPHeaderField: "x-api-key") + request.timeoutInterval = 120 + + let body: [String: Any] = [ + "videoUrl": fileUrl, + "width": config.aniWidth, + "height": config.aniHeight, + "fps": config.aniFps, + ] + + do { + request.httpBody = try JSONSerialization.data(withJSONObject: body) + } catch { + completion(.failure(error)) + return + } + + session.dataTask(with: request) { data, response, error in + if let error = error { + completion(.failure(error)) + return + } + + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + completion(.failure(AniConverterError(message: "HTTP \(http.statusCode)"))) + return + } + + guard let data = data, + let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { + completion(.failure(AniConverterError(message: "Invalid JSON response"))) + return + } + + guard (json["success"] as? Bool) == true, + let outer = json["data"] as? [String: Any] else { + completion(.failure(AniConverterError(message: "ANI API reported failure"))) + return + } + + if (outer["status"] as? Bool) != true { + let msg = (outer["msg"] as? String) ?? "ANI conversion failed" + completion(.failure(AniConverterError(message: msg))) + return + } + + guard let aniUrl = outer["data"] as? String, !aniUrl.isEmpty else { + completion(.failure(AniConverterError(message: "Missing ani url in response"))) + return + } + + completion(.success(aniUrl)) + }.resume() + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add Sources/DuooomiBleSDK/Services/AniConverter.swift +git commit -m "refactor: rewrite AniConverter with callbacks (iOS 12.2)" +``` + +--- + +## Task 8: Rewrite FirmwareService (async → callback) + +**Files:** +- Modify: `Sources/DuooomiBleSDK/Services/FirmwareService.swift` + +- [ ] **Step 1: Rewrite FirmwareService** + +Replace the entire file content with: + +```swift +import Foundation + +/// 固件信息 +public struct FirmwareInfo: Codable, Equatable { + public let version: String + public let fileUrl: String + public let description: String? + public let fileSize: String? + public let fileMd5: String? + public let identifier: String? + public let status: String? +} + +struct FirmwareResponse: Codable { + let success: Bool + let data: FirmwareInfo? +} + +/// 固件服务内部实现 +final class FirmwareService { + private let config: DuooomiBleConfig + private let latestPath = "api/auth/loomart/firmware/latest-published" + + init(config: DuooomiBleConfig) { + self.config = config + } + + func fetchLatest( + identifier: String? = nil, + status: String? = nil, + completion: @escaping (Result) -> Void + ) { + let id = (identifier ?? config.firmwareIdentifier).trimmingCharacters(in: .whitespacesAndNewlines) + let st = status ?? config.firmwareStatus + guard !id.isEmpty else { + completion(.failure(DuooomiBleError.transferFailed("Invalid firmware identifier"))) + return + } + + guard var comps = URLComponents(string: "\(config.apiHost)/\(latestPath)") else { + completion(.failure(DuooomiBleError.transferFailed("Invalid firmware URL"))) + return + } + comps.queryItems = [ + URLQueryItem(name: "identifier", value: id), + URLQueryItem(name: "status", value: st) + ] + guard let url = comps.url else { + completion(.failure(DuooomiBleError.transferFailed("Invalid firmware URL"))) + return + } + + var req = URLRequest(url: url) + req.httpMethod = "GET" + req.setValue(config.apiKey, forHTTPHeaderField: "x-api-key") + req.setValue("application/json", forHTTPHeaderField: "accept") + + URLSession.shared.dataTask(with: req) { data, resp, error in + if let error = error { + completion(.failure(error)) + return + } + guard let http = resp as? HTTPURLResponse else { + completion(.failure(DuooomiBleError.transferFailed("Invalid response"))) + return + } + guard (200...299).contains(http.statusCode) else { + completion(.failure(DuooomiBleError.transferFailed("HTTP \(http.statusCode)"))) + return + } + guard let data = data else { + completion(.failure(DuooomiBleError.transferFailed("Empty response"))) + return + } + + do { + let decoded = try JSONDecoder().decode(FirmwareResponse.self, from: data) + completion(.success(decoded.data)) + } catch { + completion(.failure(error)) + } + }.resume() + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add Sources/DuooomiBleSDK/Services/FirmwareService.swift +git commit -m "refactor: rewrite FirmwareService with callbacks (iOS 12.2)" +``` + +--- + +## Task 9: Rewrite DuooomiBleSDK Main Class + +**Files:** +- Modify: `Sources/DuooomiBleSDK/DuooomiBleSDK.swift` + +This is the largest change. The entire class is rewritten: +- Remove `@MainActor`, `ObservableObject`, `@Published`, `import Combine` +- All `async throws` → completion handlers +- `withThrowingTaskGroup` request-response → `DispatchWorkItem` + timeout +- `Task` → `DispatchQueue` +- Add delegate-based state notification + +- [ ] **Step 1: Rewrite DuooomiBleSDK.swift** + +Replace the entire file content with: + +```swift +import CoreBluetooth +import Foundation + +/// Duooomi BLE SDK - 纯原生 iOS 蓝牙 SDK +/// +/// 提供设备扫描、连接、命令交互、文件传输等原子蓝牙操作。 +/// 不做业务编排,调用方自行串联多步流程。 +public final class DuooomiBleSDK: NSObject { + + // MARK: - Observable State + + public weak var delegate: DuooomiBleSDKDelegate? + + public private(set) var btState: ConnectionState = .idle { + didSet { + guard btState != oldValue else { return } + notifyMain { self.delegate?.sdk(self, didChangeState: self.btState) } + } + } + + public private(set) var connectedDevice: DiscoveredDevice? = nil { + didSet { notifyMain { self.delegate?.sdk(self, didConnectDevice: self.connectedDevice) } } + } + + public private(set) var deviceInfo: DeviceInfo? = nil { + didSet { notifyMain { self.delegate?.sdk(self, didUpdateDeviceInfo: self.deviceInfo) } } + } + + public private(set) var version: String = "" { + didSet { notifyMain { self.delegate?.sdk(self, didUpdateVersion: self.version) } } + } + + public private(set) var isActivated: Bool = false { + didSet { + guard isActivated != oldValue else { return } + notifyMain { self.delegate?.sdk(self, didChangeActivation: self.isActivated) } + } + } + + public private(set) var transferProgress: Int = 0 { + didSet { + guard transferProgress != oldValue else { return } + notifyMain { self.delegate?.sdk(self, didUpdateProgress: self.transferProgress) } + } + } + + public private(set) var error: String? = nil { + didSet { notifyMain { self.delegate?.sdk(self, didEncounterError: self.error) } } + } + + public private(set) var discoveredDevices: [DiscoveredDevice] = [] { + didSet { notifyMain { self.delegate?.sdk(self, didUpdateDevices: self.discoveredDevices) } } + } + + // MARK: - Configuration + + public let config: DuooomiBleConfig + + // MARK: - Internal Services + + private let bleClient: BleClient + private let protocolService: BleProtocolService + private let deviceInfoService: DeviceInfoService + private let fileTransferService: FileTransferService + private let aniConverter: AniConverter + private let firmwareService: FirmwareService + + // MARK: - Request-Response + + private var pendingCallbacks: [String: (Result) -> Void] = [:] + private var pendingTimeouts: [String: DispatchWorkItem] = [:] + private let callbackQueue = DispatchQueue(label: "com.duooomi.ble.callback") + + // MARK: - Scan Batching + + private var allDiscoveredDevices: [DiscoveredDevice] = [] + private var pendingDevices: [DiscoveredDevice] = [] + private var flushWorkItem: DispatchWorkItem? + + // MARK: - Init + + public init(config: DuooomiBleConfig) { + self.config = config + bleClient = BleClient() + protocolService = BleProtocolService(client: bleClient) + deviceInfoService = DeviceInfoService(protocolService: protocolService) + fileTransferService = FileTransferService(protocolService: protocolService) + aniConverter = AniConverter(config: config) + firmwareService = FirmwareService(config: config) + + super.init() + + protocolService.delegate = self + BleLog.i("SDK initialized (apiHost=\(config.apiHost), firmware=\(config.firmwareIdentifier)/\(config.firmwareStatus))", "SDK") + setupDisconnectHandler() + } + + private func setupDisconnectHandler() { + bleClient.onDisconnected = { [weak self] in + guard let self = self else { return } + BleLog.w("Peripheral disconnected; resetting state", "SDK") + self.connectedDevice = nil + self.deviceInfo = nil + self.version = "" + self.isActivated = false + self.btState = .disconnected + self.protocolService.stopListening() + self.cancelAllPending(error: DuooomiBleError.notConnected) + } + } + + // MARK: - Scanning + + /// 开始扫描设备。 + public func scan() { + stopScan() + btState = .scanning + discoveredDevices = [] + allDiscoveredDevices = [] + pendingDevices = [] + + BleLog.i("Start scanning...", "Scan") + bleClient.delegate = protocolService + // Also listen for scan results + let originalDelegate = bleClient.delegate + bleClient.delegate = self.scanProxy(original: originalDelegate) + bleClient.scan() + } + + /// 停止扫描设备。 + public func stopScan() { + bleClient.stopScan() + flushDevices() + + if connectedDevice != nil { + btState = .connected + } else if btState == .scanning { + btState = .idle + } + BleLog.i("Stop scanning (state=\(btState.rawValue))", "Scan") + } + + // MARK: - Connection + + /// 连接到指定设备。 + public func connect(deviceId: String, completion: @escaping (Result) -> Void) { + stopScan() + btState = .connecting + BleLog.i("Connecting to \(deviceId)...", "Connect") + + bleClient.connect(deviceId: deviceId) { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let peripheral): + self.protocolService.startListening() + + let device = DiscoveredDevice( + id: peripheral.identifier.uuidString, + name: peripheral.name, + rssi: 0 + ) + self.connectedDevice = device + self.btState = .connected + BleLog.i("Connected: \(device.id)", "Connect") + completion(.success(device)) + + case .failure(let error): + self.btState = .idle + self.error = error.localizedDescription + BleLog.e("Connect failed: \(error.localizedDescription)", "Connect") + completion(.failure(error)) + } + } + } + + /// 断开当前连接。 + public func disconnect(completion: @escaping (Result) -> Void) { + btState = .disconnecting + BleLog.i("Disconnect requested", "Connect") + protocolService.stopListening() + cancelAllPending(error: DuooomiBleError.notConnected) + + bleClient.disconnect { [weak self] result in + guard let self = self else { return } + if case .failure(let error) = result { + self.error = error.localizedDescription + BleLog.e("Disconnect error: \(error.localizedDescription)", "Connect") + } + + self.connectedDevice = nil + self.deviceInfo = nil + self.version = "" + self.isActivated = false + self.btState = .disconnected + BleLog.i("Disconnected", "Connect") + completion(result) + } + } + + /// 返回系统已连接的设备 + public func getConnectedDevices() -> [DiscoveredDevice] { + let list = bleClient.retrieveConnectedDevices() + BleLog.d("System-connected devices: \(list.count)", "Connect") + return list + } + + // MARK: - Device Commands + + /// 读取设备信息 + public func getDeviceInfo(completion: @escaping (Result) -> Void) { + guard ensureConnected(completion: completion) else { return } + BleLog.d("Sending getDeviceInfo", "Command") + + sendAndWait(commandType: .getDeviceInfo) { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let data): + do { + let info = try self.decodeResponse(DeviceInfo.self, from: data) + BleLog.i("DeviceInfo received: name=\(info.name), brand=\(info.brand)", "Command") + self.deviceInfo = info + completion(.success(info)) + } catch { + completion(.failure(error)) + } + } + } send: { + try self.deviceInfoService.getDeviceInfo() + } + } + + /// 读取设备固件版本 + public func getVersion(completion: @escaping (Result) -> Void) { + guard ensureConnected(completion: completion) else { return } + BleLog.d("Sending getDeviceVersion", "Command") + + sendAndWait(commandType: .getDeviceVersion) { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let data): + do { + let info = try self.decodeResponse(VersionInfo.self, from: data) + self.version = info.version + BleLog.i("Version received: \(info.version) (type=\(info.type))", "Command") + completion(.success(info)) + } catch { + completion(.failure(error)) + } + } + } send: { + try self.deviceInfoService.getDeviceVersion() + } + } + + /// 绑定设备到用户 + public func bind(userId: String, completion: @escaping (Result) -> Void) { + guard ensureConnected(completion: completion) else { return } + BleLog.d("Sending bind (userId=\(userId))", "Command") + + sendAndWait(commandType: .bindDevice) { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let data): + do { + let resp = try self.decodeResponse(BindingResponse.self, from: data) + self.isActivated = resp.success == 1 + if resp.success != 1 { + BleLog.w("Bind failed: device already bound", "Command") + completion(.failure(DuooomiBleError.bindingFailed("Device already bound to another user"))) + } else { + BleLog.i("Bind success: sn=\(resp.sn)", "Command") + completion(.success(resp)) + } + } catch { + completion(.failure(error)) + } + } + } send: { + try self.deviceInfoService.bindDevice(userId: userId) + } + } + + /// 解除设备绑定 + public func unbind(userId: String, completion: @escaping (Result) -> Void) { + guard ensureConnected(completion: completion) else { return } + BleLog.d("Sending unbind (userId=\(userId))", "Command") + + sendAndWait(commandType: .unbindDevice) { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let data): + do { + let resp = try self.decodeResponse(UnbindResponse.self, from: data) + if resp.success == 1 { + self.isActivated = false + BleLog.i("Unbind success", "Command") + completion(.success(resp)) + } else { + BleLog.w("Unbind failed", "Command") + completion(.failure(DuooomiBleError.unbindFailed)) + } + } catch { + completion(.failure(error)) + } + } + } send: { + try self.deviceInfoService.unbindDevice(userId: userId) + } + } + + /// 删除设备文件 + public func deleteFile(key: String, completion: @escaping (Result) -> Void) { + guard ensureConnected(completion: completion) else { return } + BleLog.d("Sending deleteFile (key=\(key))", "Command") + + sendAndWait(commandType: .deleteFile) { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let data): + do { + let resp = try self.decodeResponse(DeleteFileResponse.self, from: data) + if resp.success != 0 { + BleLog.w("Delete failed with status=\(resp.success)", "Command") + completion(.failure(DuooomiBleError.deleteFileFailed(status: resp.success))) + } else { + BleLog.i("Delete success (key=\(key))", "Command") + completion(.success(resp)) + } + } catch { + completion(.failure(error)) + } + } + } send: { + try self.deviceInfoService.deleteFile(key: key) + } + } + + /// 传输前准备校验 + public func prepareTransfer(key: String, size: Int, completion: @escaping (Result) -> Void) { + guard ensureConnected(completion: completion) else { return } + let opId = "\(CommandType.prepareTransfer.rawValue)_\(key)" + BleLog.d("Sending prepareTransfer (key=\(key), size=\(size))", "Transfer") + + sendAndWait(opId: opId, commandType: .prepareTransfer) { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let data): + do { + let resp = try self.decodeResponse(PrepareTransferResponse.self, from: data) + if resp.status != "ready" { + BleLog.w("PrepareTransfer not ready: status=\(resp.status)", "Transfer") + completion(.failure(DuooomiBleError.prepareTransferFailed(status: resp.status))) + } else { + BleLog.i("PrepareTransfer ready (key=\(key))", "Transfer") + completion(.success(resp)) + } + } catch { + completion(.failure(error)) + } + } + } send: { + try self.deviceInfoService.prepareTransfer(key: key, size: size) + } + } + + // MARK: - File Transfer + + /// 传输文件到设备 + public func transferFile( + fileUri: String, + commandType: CommandType = .transferAniVideo, + completion: @escaping (Result) -> Void + ) { + guard connectedDevice != nil else { + completion(.failure(DuooomiBleError.notConnected)) + return + } + transferProgress = 0 + BleLog.i("Transfer start: uri=\(fileUri), cmd=\(commandType)", "Transfer") + + fileTransferService.transferFile( + fileUri: fileUri, + commandType: commandType, + onProgress: { [weak self] progress in + DispatchQueue.main.async { + let p = Int(progress * 100) + self?.transferProgress = p + if p % 10 == 0 { + BleLog.d("Progress: \(p)%", "Transfer") + } + } + }, + completion: { [weak self] result in + if case .success = result { + self?.transferProgress = 100 + BleLog.i("Transfer completed", "Transfer") + } + completion(result) + } + ) + } + + // MARK: - High-Level APIs + + /// 传输媒体文件到设备(一步完成) + public func transferMedia(fileUrl: String, completion: @escaping (Result) -> Void) { + guard connectedDevice != nil else { + completion(.failure(DuooomiBleError.notConnected)) + return + } + let url = fileUrl.trimmingCharacters(in: .whitespacesAndNewlines) + guard !url.isEmpty else { + completion(.failure(DuooomiBleError.transferFailed("Empty file URL"))) + return + } + + let isAni = url.lowercased().hasSuffix(".ani") + + if isAni { + BleLog.i("Direct ANI transfer: \(url)", "Transfer") + transferMediaStep2(aniUrl: url, originalUrl: url, completion: completion) + } else { + BleLog.i("Converting to ANI: \(url)", "Transfer") + aniConverter.convert(fileUrl: url) { [weak self] result in + switch result { + case .failure(let error): + completion(.failure(DuooomiBleError.transferFailed("ANI conversion failed: \(error.localizedDescription)"))) + case .success(let aniUrl): + BleLog.i("ANI ready: \(aniUrl)", "Transfer") + self?.transferMediaStep2(aniUrl: aniUrl, originalUrl: url, completion: completion) + } + } + } + } + + private func transferMediaStep2(aniUrl: String, originalUrl: String, completion: @escaping (Result) -> Void) { + getRemoteFileSize(url: aniUrl) { [weak self] result in + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let aniSize): + BleLog.d("ANI size: \(aniSize) bytes", "Transfer") + self?.prepareTransfer(key: originalUrl, size: aniSize) { [weak self] result in + switch result { + case .failure(let error): + completion(.failure(error)) + case .success: + self?.transferFile(fileUri: aniUrl, commandType: .transferAniVideo, completion: completion) + } + } + } + } + } + + /// 获取最新固件信息 + public func fetchLatestFirmware( + identifier: String? = nil, + status: String? = nil, + completion: @escaping (Result) -> Void + ) { + firmwareService.fetchLatest(identifier: identifier, status: status, completion: completion) + } + + /// 比较设备版本和服务端版本 + public func hasNewerFirmware(deviceVersion: String?, serverVersion: String?) -> Bool { + guard let device = deviceVersion, let server = serverVersion, + device.count >= 10, server.count >= 10 else { return false } + let deviceTs = device.suffix(10) + let serverTs = server.suffix(10) + guard let d = Int(deviceTs), let s = Int(serverTs) else { return false } + return s > d + } + + /// OTA 固件升级 + public func upgradeFirmware(fileUrl: String, completion: @escaping (Result) -> Void) { + guard connectedDevice != nil else { + completion(.failure(DuooomiBleError.notConnected)) + return + } + BleLog.i("OTA upgrade start: \(fileUrl)", "Firmware") + transferFile(fileUri: fileUrl, commandType: .otaPackage) { result in + if case .success = result { + BleLog.i("OTA upgrade completed", "Firmware") + } + completion(result) + } + } + + // MARK: - Internal Helpers + + private func getRemoteFileSize(url: String, completion: @escaping (Result) -> Void) { + guard let fileUrl = URL(string: url) else { + completion(.failure(DuooomiBleError.transferFailed("Invalid URL: \(url)"))) + return + } + + var request = URLRequest(url: fileUrl) + request.httpMethod = "HEAD" + + URLSession.shared.dataTask(with: request) { [weak self] _, response, error in + if let error = error { + completion(.failure(error)) + return + } + + if let http = response as? HTTPURLResponse { + // Try Content-Length header + if let lengthStr = http.value(forHTTPHeaderField: "Content-Length"), + let length = Int(lengthStr), length > 0 { + completion(.success(length)) + return + } + if http.expectedContentLength > 0 { + completion(.success(Int(http.expectedContentLength))) + return + } + } + + // Fallback: Range request + self?.getFileSizeViaRange(fileUrl: fileUrl, completion: completion) + }.resume() + } + + private func getFileSizeViaRange(fileUrl: URL, completion: @escaping (Result) -> Void) { + var rangeReq = URLRequest(url: fileUrl) + rangeReq.httpMethod = "GET" + rangeReq.setValue("bytes=0-0", forHTTPHeaderField: "Range") + + URLSession.shared.dataTask(with: rangeReq) { _, response, error in + if let error = error { + completion(.failure(error)) + return + } + if let http = response as? HTTPURLResponse, + let rangeHeader = http.value(forHTTPHeaderField: "Content-Range"), + let totalStr = rangeHeader.split(separator: "/").last, + let total = Int(totalStr), total > 0 { + completion(.success(total)) + return + } + completion(.failure(DuooomiBleError.transferFailed("Cannot determine file size: \(fileUrl.absoluteString)"))) + }.resume() + } + + // MARK: - Request-Response Pattern + + private func sendAndWait( + commandType: CommandType, + timeout: TimeInterval = 10, + completion: @escaping (Result) -> Void, + send: @escaping () throws -> Void + ) { + let opId = "\(commandType.rawValue)" + sendAndWait(opId: opId, commandType: commandType, timeout: timeout, completion: completion, send: send) + } + + private func sendAndWait( + opId: String, + commandType: CommandType, + timeout: TimeInterval = 10, + completion: @escaping (Result) -> Void, + send: @escaping () throws -> Void + ) { + BleLog.d("Register opId=\(opId), cmd=\(commandType)", "RPC") + + // Register callback BEFORE sending + callbackQueue.sync { + self.pendingCallbacks[opId] = { result in + DispatchQueue.main.async { + completion(result) + } + } + } + + // Setup timeout + let timeoutWork = DispatchWorkItem { [weak self] in + guard let self = self else { return } + self.callbackQueue.sync { + if let cb = self.pendingCallbacks.removeValue(forKey: opId) { + self.pendingTimeouts.removeValue(forKey: opId) + cb(.failure(DuooomiBleError.timeout(command: commandType))) + } + } + } + callbackQueue.sync { + pendingTimeouts[opId] = timeoutWork + } + DispatchQueue.global().asyncAfter(deadline: .now() + timeout, execute: timeoutWork) + + // Send command + do { + try send() + BleLog.d("Command sent for opId=\(opId)", "RPC") + } catch { + // Clean up on send failure + callbackQueue.sync { + self.pendingCallbacks.removeValue(forKey: opId) + self.pendingTimeouts.removeValue(forKey: opId)?.cancel() + } + BleLog.e("Send failed for opId=\(opId): \(error.localizedDescription)", "RPC") + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + + // MARK: - Helpers + + private func ensureConnected(completion: @escaping (Result) -> Void) -> Bool { + guard connectedDevice != nil else { + BleLog.w("Operation requires connection", "SDK") + completion(.failure(DuooomiBleError.notConnected)) + return false + } + return true + } + + private func decodeResponse(_ type: T.Type, from data: Data) throws -> T { + let cleaned = data.filter { $0 != 0 } + do { + return try JSONDecoder().decode(type, from: cleaned) + } catch { + let raw = String(data: cleaned, encoding: .utf8) ?? "" + BleLog.e("JSON decode \(T.self) failed: \(error.localizedDescription)\nRaw: \(raw)", "Decode") + throw error + } + } + + private func cancelAllPending(error: Error) { + callbackQueue.sync { + let callbacks = self.pendingCallbacks + self.pendingCallbacks.removeAll() + for (_, timeout) in self.pendingTimeouts { + timeout.cancel() + } + self.pendingTimeouts.removeAll() + for (_, cb) in callbacks { + cb(.failure(error)) + } + } + } + + private func notifyMain(_ block: @escaping () -> Void) { + if Thread.isMainThread { + block() + } else { + DispatchQueue.main.async(execute: block) + } + } + + // MARK: - Scan Batching (500ms throttle) + + private func queueDevice(_ device: DiscoveredDevice) { + guard !allDiscoveredDevices.contains(where: { $0.id == device.id }) else { return } + + allDiscoveredDevices.append(device) + pendingDevices.append(device) + + flushWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.flushDevices() + } + flushWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: workItem) + } + + private func flushDevices() { + guard !pendingDevices.isEmpty else { return } + pendingDevices = [] + discoveredDevices = allDiscoveredDevices + BleLog.d("Discovered devices flushed: total=\(discoveredDevices.count)", "Scan") + } +} + +// MARK: - BleProtocolServiceDelegate (incoming messages) + +extension DuooomiBleSDK: BleProtocolServiceDelegate { + + func protocolService(_ service: BleProtocolService, didReceiveMessage commandType: UInt8, data: Data) { + BleLog.d("Incoming message: type=\(commandType), size=\(data.count)", "RPC") + + let opId = "\(commandType)" + + // Check for prepareTransfer with key-based opId + if commandType == CommandType.prepareTransfer.rawValue { + if let resp = try? decodeResponse(PrepareTransferResponse.self, from: data) { + let keyedOpId = "\(commandType)_\(resp.key)" + var cb: ((Result) -> Void)? + callbackQueue.sync { + cb = self.pendingCallbacks.removeValue(forKey: keyedOpId) + self.pendingTimeouts.removeValue(forKey: keyedOpId)?.cancel() + } + if let cb = cb { + BleLog.d("Resuming keyed opId=\(keyedOpId)", "RPC") + cb(.success(data)) + return + } + } + } + + var cb: ((Result) -> Void)? + callbackQueue.sync { + cb = self.pendingCallbacks.removeValue(forKey: opId) + self.pendingTimeouts.removeValue(forKey: opId)?.cancel() + } + + if let cb = cb { + BleLog.d("Resuming opId=\(opId)", "RPC") + cb(.success(data)) + } else { + BleLog.w("No pending callback for opId=\(opId)", "RPC") + } + } +} + +// MARK: - Scan Proxy (forward scan results while keeping protocol delegate) + +private class ScanBleClientDelegate: BleClientDelegate { + private weak var sdk: DuooomiBleSDK? + private let original: BleClientDelegate? + + init(sdk: DuooomiBleSDK, original: BleClientDelegate?) { + self.sdk = sdk + self.original = original + } + + func bleClient(_ client: BleClient, didDiscoverDevice device: DiscoveredDevice) { + BleLog.d("Discovered: id=\(device.id), name=\(device.name ?? "-"), rssi=\(device.rssi)", "Scan") + sdk?.queueDevice(device) + } + + func bleClient(_ client: BleClient, didReceiveData data: Data) { + original?.bleClient(client, didReceiveData: data) + } +} + +private extension DuooomiBleSDK { + func scanProxy(original: BleClientDelegate?) -> BleClientDelegate { + return ScanBleClientDelegate(sdk: self, original: original) + } +} +``` + +- [ ] **Step 2: Build to check compile errors** + +Run: `xcodebuild build -workspace demo.xcworkspace -scheme DuooomiBleSDK -destination 'generic/platform=iOS' CODE_SIGN_IDENTITY=- CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "error:|BUILD"` + +Expected: BUILD SUCCEEDED (or errors only in demo app which still uses old API) + +- [ ] **Step 3: Commit** + +```bash +git add Sources/DuooomiBleSDK/DuooomiBleSDK.swift +git commit -m "refactor: rewrite DuooomiBleSDK with delegate+callbacks (iOS 12.2)" +``` + +--- + +## Task 10: Update Build Configuration + +**Files:** +- Modify: `Package.swift` +- Modify: `project.yml` +- Modify: `DuooomiBleSDK.podspec` + +- [ ] **Step 1: Package.swift — Change iOS target** + +```swift +// Before: +.iOS(.v16) +// After: +.iOS(.v12) +``` + +Note: SPM's `.iOS` enum may not have `.v12`. If so, use: +```swift +.iOS(.init("12.2")) +``` + +- [ ] **Step 2: project.yml — Change deployment target** + +```yaml +# Before: +deploymentTarget: + iOS: "16.0" +# After: +deploymentTarget: + iOS: "12.2" +``` + +- [ ] **Step 3: DuooomiBleSDK.podspec — Change deployment target** + +```ruby +# Before: +s.ios.deployment_target = '12.2' +# After (already correct if changed): +s.ios.deployment_target = '12.2' +``` + +- [ ] **Step 4: Commit** + +```bash +git add Package.swift project.yml DuooomiBleSDK.podspec +git commit -m "chore: set deployment target to iOS 12.2" +``` + +--- + +## Task 11: Update Demo App + +**Files:** +- Modify: `demo/Sources/DemoApp.swift` (and any other demo source files) + +The demo app needs to be updated to use the new callback-based API instead of async/await. Read all demo source files and update every SDK call. + +- [ ] **Step 1: Read all demo source files** + +Check `demo/Sources/` for all Swift files and update them to use completion handler API. + +Key changes: +- Remove `await` / `async` / `Task { }` wrappers +- Use completion-handler versions of all SDK methods +- Implement `DuooomiBleSDKDelegate` instead of observing `@Published` +- Replace `@StateObject` / `@ObservedObject` with manual state management if using SwiftUI, or use UIKit delegate pattern + +- [ ] **Step 2: Build and verify** + +Run: `xcodebuild build -workspace demo.xcworkspace -scheme demo -destination 'generic/platform=iOS' CODE_SIGN_IDENTITY=- CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "error:|BUILD"` + +Expected: BUILD SUCCEEDED + +- [ ] **Step 3: Commit** + +```bash +git add demo/ +git commit -m "refactor: update demo app for callback-based SDK API" +``` + +--- + +## Task 12: Update README + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Update README with new API style** + +Key sections to update: +- Minimum iOS version: `12.2` +- Remove mention of `@Published` / `ObservableObject` +- Add delegate setup example +- Change all API examples from `async/await` to completion handlers +- Update tech specs section + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: update README for iOS 12.2 callback-based API" +``` + +--- + +## Task 13: Full Build Verification + +- [ ] **Step 1: Clean build SDK** + +Run: `xcodebuild clean build -workspace demo.xcworkspace -scheme DuooomiBleSDK -destination 'generic/platform=iOS' CODE_SIGN_IDENTITY=- CODE_SIGNING_ALLOWED=NO 2>&1 | tail -5` + +Expected: BUILD SUCCEEDED + +- [ ] **Step 2: Clean build demo** + +Run: `xcodebuild clean build -workspace demo.xcworkspace -scheme demo -destination 'generic/platform=iOS' CODE_SIGN_IDENTITY=- CODE_SIGNING_ALLOWED=NO 2>&1 | tail -5` + +Expected: BUILD SUCCEEDED + +- [ ] **Step 3: Verify no iOS 13+ API usage remains** + +Run: `grep -rn "async \|await \|AsyncStream\|@Published\|ObservableObject\|@MainActor\|Sendable\|CheckedContinuation\|withThrowingTaskGroup\|Task {" Sources/DuooomiBleSDK/` + +Expected: No matches + +- [ ] **Step 4: Final commit if any fixes needed** + +```bash +git add -A +git commit -m "fix: resolve remaining iOS 12.2 compat issues" +```