Files
duooomi-ios-sdk/docs/superpowers/plans/2026-04-16-ios12-compat-rewrite.md
km2023 1a7bbdc625 feat: 重写 SDK 支持 iOS 12.2,移除 async/await 和 Combine
- 所有公开 API 改为 completion handler (Result<T, Error>)
- 状态通知改为 DuooomiBleSDKDelegate 协议
- 移除 Sendable、@MainActor、AsyncStream、CheckedContinuation
- 网络请求改为 URLSession.dataTask 回调
- BLE 通信改为 delegate + 闭包模式
- deployment target 降至 iOS 12.2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:59:39 +08:00

66 KiB

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<T, Error>), 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.sleepDispatchQueue.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

// Before:
public struct DuooomiBleConfig: Sendable {
// After:
public struct DuooomiBleConfig {
  • Step 2: BleTypes.swift — Remove Sendable from all types
// 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
// Before:
public enum CommandType: UInt8, Sendable {
// After:
public enum CommandType: UInt8 {
  • Step 4: ProtocolFrame.swift — Remove Sendable
// Before:
public struct ProtocolFrame: Sendable {
// After:
public struct ProtocolFrame {
  • Step 5: All model files — Remove Sendable

DeviceInfo.swift:

// Before:
public struct DeviceInfo: Sendable, Equatable {
// After:
public struct DeviceInfo: Equatable {

VersionInfo.swift:

// Before:
public struct VersionInfo: Sendable, Equatable {
// After:
public struct VersionInfo: Equatable {

BindingResponse.swift:

// Before:
public struct BindingResponse: Codable, Sendable, Equatable {
// After:
public struct BindingResponse: Codable, Equatable {

UnbindResponse.swift:

// Before:
public struct UnbindResponse: Codable, Sendable, Equatable {
// After:
public struct UnbindResponse: Codable, Equatable {

DeleteFileResponse.swift:

// Before:
public struct DeleteFileResponse: Codable, Sendable, Equatable {
// After:
public struct DeleteFileResponse: Codable, Equatable {

PrepareTransferResponse.swift:

// Before:
public struct PrepareTransferResponse: Codable, Sendable, Equatable {
// After:
public struct PrepareTransferResponse: Codable, Equatable {

FirmwareService.swift (FirmwareInfo struct at top):

// Before:
public struct FirmwareInfo: Codable, Equatable, Sendable {
// After:
public struct FirmwareInfo: Codable, Equatable {
  • Step 6: Commit
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

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
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:

import CoreBluetooth
import Foundation

/// BLE 客户端内部回调协议
protocol BleClientDelegate: AnyObject {
    func bleClient(_ client: BleClient, didDiscoverDevice device: DiscoveredDevice)
    func bleClient(_ client: BleClient, didReceiveData data: Data)
}

final class BleClient: NSObject {

    weak var delegate: BleClientDelegate?
    var onConnectionStateChange: ((ConnectionState) -> Void)?
    var onDisconnected: (() -> Void)?

    private let queue = DispatchQueue(label: "com.duooomi.ble.client", qos: .userInitiated)
    private var centralManager: CBCentralManager!
    private var connectedPeripheral: CBPeripheral?
    private var writeCharacteristic: CBCharacteristic?
    private var readCharacteristic: CBCharacteristic?

    // Connect/Disconnect callbacks
    private var connectCompletion: ((Result<CBPeripheral, Error>) -> Void)?
    private var disconnectCompletion: ((Result<Void, Error>) -> Void)?
    private var connectTimeoutWork: DispatchWorkItem?

    var bluetoothState: CBManagerState {
        centralManager.state
    }

    var maximumWriteLength: Int {
        connectedPeripheral?.maximumWriteValueLength(for: .withoutResponse) ?? 182
    }

    override init() {
        super.init()
        centralManager = CBCentralManager(delegate: self, queue: queue)
    }

    // MARK: - Scan

    func scan(serviceUUIDs: [CBUUID] = [BleUUIDs.service]) {
        guard centralManager.state == .poweredOn else {
            BleLog.w("Bluetooth not powered on", "BLE")
            return
        }
        centralManager.scanForPeripherals(
            withServices: serviceUUIDs,
            options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
        )
    }

    func stopScan() {
        centralManager.stopScan()
    }

    // MARK: - Connect

    func connect(deviceId: String, timeout: TimeInterval = 30, completion: @escaping (Result<CBPeripheral, Error>) -> Void) {
        guard centralManager.state == .poweredOn else {
            completion(.failure(DuooomiBleError.bluetoothNotPoweredOn))
            return
        }

        let peripherals = centralManager.retrievePeripherals(withIdentifiers: [UUID(uuidString: deviceId)!])
        guard let peripheral = peripherals.first else {
            completion(.failure(DuooomiBleError.connectionFailed("Device not found: \(deviceId)")))
            return
        }

        self.connectCompletion = completion
        peripheral.delegate = self
        centralManager.connect(peripheral, options: nil)

        // Timeout
        let work = DispatchWorkItem { [weak self] in
            guard let self = self, self.connectCompletion != nil else { return }
            self.centralManager.cancelPeripheralConnection(peripheral)
            let cb = self.connectCompletion
            self.connectCompletion = nil
            cb?(.failure(DuooomiBleError.connectionFailed("Connection timeout")))
        }
        connectTimeoutWork = work
        queue.asyncAfter(deadline: .now() + timeout, execute: work)
    }

    // MARK: - Disconnect

    func disconnect(completion: @escaping (Result<Void, Error>) -> Void) {
        guard let peripheral = connectedPeripheral else {
            completion(.success(()))
            return
        }
        disconnectCompletion = completion
        centralManager.cancelPeripheralConnection(peripheral)
    }

    // MARK: - Write

    func writeWithoutResponse(_ data: Data) throws {
        guard let peripheral = connectedPeripheral, let char = writeCharacteristic else {
            throw DuooomiBleError.notConnected
        }
        peripheral.writeValue(data, for: char, type: .withoutResponse)
    }

    // MARK: - Retrieve Connected

    func retrieveConnectedDevices() -> [DiscoveredDevice] {
        centralManager.retrieveConnectedPeripherals(withServices: [BleUUIDs.service]).map {
            DiscoveredDevice(id: $0.identifier.uuidString, name: $0.name, rssi: 0)
        }
    }
}

// MARK: - CBCentralManagerDelegate

extension BleClient: CBCentralManagerDelegate {

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        BleLog.i("Bluetooth state: \(central.state.rawValue)", "BLE")
    }

    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
                         advertisementData: [String: Any], rssi RSSI: NSNumber) {
        let device = DiscoveredDevice(
            id: peripheral.identifier.uuidString,
            name: peripheral.name,
            rssi: RSSI.intValue
        )
        delegate?.bleClient(self, didDiscoverDevice: device)
    }

    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        connectTimeoutWork?.cancel()
        connectTimeoutWork = nil
        connectedPeripheral = peripheral
        peripheral.delegate = self
        peripheral.discoverServices([BleUUIDs.service])
    }

    func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
        connectTimeoutWork?.cancel()
        connectTimeoutWork = nil
        let cb = connectCompletion
        connectCompletion = nil
        cb?(.failure(error ?? DuooomiBleError.connectionFailed("Unknown")))
    }

    func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
        connectedPeripheral = nil
        writeCharacteristic = nil
        readCharacteristic = nil

        if let cb = disconnectCompletion {
            disconnectCompletion = nil
            if let error = error {
                cb(.failure(error))
            } else {
                cb(.success(()))
            }
        }
        DispatchQueue.main.async { [weak self] in
            self?.onDisconnected?()
        }
    }
}

// MARK: - CBPeripheralDelegate

extension BleClient: CBPeripheralDelegate {

    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
        if let error = error {
            let cb = connectCompletion
            connectCompletion = nil
            cb?(.failure(error))
            return
        }
        guard let service = peripheral.services?.first(where: { $0.uuid == BleUUIDs.service }) else {
            let cb = connectCompletion
            connectCompletion = nil
            cb?(.failure(DuooomiBleError.serviceNotFound))
            return
        }
        peripheral.discoverCharacteristics(
            [BleUUIDs.writeCharacteristic, BleUUIDs.readCharacteristic],
            for: service
        )
    }

    func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
        if let error = error {
            let cb = connectCompletion
            connectCompletion = nil
            cb?(.failure(error))
            return
        }
        guard let characteristics = service.characteristics else {
            let cb = connectCompletion
            connectCompletion = nil
            cb?(.failure(DuooomiBleError.characteristicNotFound))
            return
        }

        for char in characteristics {
            if char.uuid == BleUUIDs.writeCharacteristic {
                writeCharacteristic = char
            } else if char.uuid == BleUUIDs.readCharacteristic {
                readCharacteristic = char
                peripheral.setNotifyValue(true, for: char)
            }
        }

        guard writeCharacteristic != nil, readCharacteristic != nil else {
            let cb = connectCompletion
            connectCompletion = nil
            cb?(.failure(DuooomiBleError.characteristicNotFound))
            return
        }

        let cb = connectCompletion
        connectCompletion = nil
        cb?(.success(peripheral))
    }

    func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
        guard error == nil, let data = characteristic.value else { return }
        delegate?.bleClient(self, didReceiveData: data)
    }
}
  • 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
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:

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, Error>) -> 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<T: Encodable>(type: CommandType, payload: T, completion: @escaping (Result<Void, Error>) -> 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<T: Encodable>(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
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:

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
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:

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, Error>) -> 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<Data, Error>) -> 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
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:

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<String, Error>) -> 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<String, Error>) -> 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
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:

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<FirmwareInfo?, Error>) -> 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
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

  • TaskDispatchQueue

  • Add delegate-based state notification

  • Step 1: Rewrite DuooomiBleSDK.swift

Replace the entire file content with:

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<Data, Error>) -> 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<DiscoveredDevice, Error>) -> 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, Error>) -> 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<DeviceInfo, Error>) -> 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<VersionInfo, Error>) -> 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<BindingResponse, Error>) -> 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<UnbindResponse, Error>) -> 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<DeleteFileResponse, Error>) -> 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<PrepareTransferResponse, Error>) -> 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, Error>) -> 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, Error>) -> 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, Error>) -> 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<FirmwareInfo?, Error>) -> 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, Error>) -> 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<Int, Error>) -> 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<Int, Error>) -> 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<Data, Error>) -> 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<Data, Error>) -> 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<T>(completion: @escaping (Result<T, Error>) -> Void) -> Bool {
        guard connectedDevice != nil else {
            BleLog.w("Operation requires connection", "SDK")
            completion(.failure(DuooomiBleError.notConnected))
            return false
        }
        return true
    }

    private func decodeResponse<T: Decodable>(_ 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) ?? "<non-utf8, \(cleaned.count) bytes>"
            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<Data, Error>) -> 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<Data, Error>) -> 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
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

// Before:
.iOS(.v16)
// After:
.iOS(.v12)

Note: SPM's .iOS enum may not have .v12. If so, use:

.iOS(.init("12.2"))
  • Step 2: project.yml — Change deployment target
# Before:
deploymentTarget:
  iOS: "16.0"
# After:
deploymentTarget:
  iOS: "12.2"
  • Step 3: DuooomiBleSDK.podspec — Change deployment target
# Before:
s.ios.deployment_target = '12.2'
# After (already correct if changed):
s.ios.deployment_target = '12.2'
  • Step 4: Commit
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
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

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
git add -A
git commit -m "fix: resolve remaining iOS 12.2 compat issues"