- 所有公开 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>
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.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
// 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 -
withThrowingTaskGrouprequest-response →DispatchWorkItem+ timeout -
Task→DispatchQueue -
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
DuooomiBleSDKDelegateinstead of observing@Published -
Replace
@StateObject/@ObservedObjectwith 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/awaitto 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"