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>
This commit is contained in:
km2023
2026-04-16 16:59:39 +08:00
parent 79af43cdef
commit 1a7bbdc625
26 changed files with 3211 additions and 944 deletions

View File

@@ -8,10 +8,10 @@ Pod::Spec.new do |s|
s.author = { 'comi-logic' => 'dev@bowong.cc' } s.author = { 'comi-logic' => 'dev@bowong.cc' }
s.source = { :git => 'https://gitea.bowong.cc/comi-logic/duooomi-ios-sdk.git', :tag => s.version.to_s } s.source = { :git => 'https://gitea.bowong.cc/comi-logic/duooomi-ios-sdk.git', :tag => s.version.to_s }
s.ios.deployment_target = '16.0' s.ios.deployment_target = '12.2'
s.swift_version = '5.0' s.swift_version = '5.0'
s.source_files = 'Sources/DuooomiBleSDK/**/*.swift' s.source_files = 'Sources/DuooomiBleSDK/**/*.swift'
s.frameworks = 'CoreBluetooth', 'Foundation', 'Combine' s.frameworks = 'CoreBluetooth', 'Foundation'
end end

View File

@@ -1,11 +1,11 @@
// swift-tools-version: 5.9 // swift-tools-version: 5.0
import PackageDescription import PackageDescription
let package = Package( let package = Package(
name: "DuooomiBleSDK", name: "DuooomiBleSDK",
platforms: [ platforms: [
.iOS(.v16) .iOS(.v12)
], ],
products: [ products: [
.library( .library(

6
Podfile Normal file
View File

@@ -0,0 +1,6 @@
platform :ios, '16.0'
target 'demo' do
use_frameworks!
pod 'DuooomiBleSDK', :path => './'
end

16
Podfile.lock Normal file
View File

@@ -0,0 +1,16 @@
PODS:
- DuooomiBleSDK (0.1.0)
DEPENDENCIES:
- DuooomiBleSDK (from `./`)
EXTERNAL SOURCES:
DuooomiBleSDK:
:path: "./"
SPEC CHECKSUMS:
DuooomiBleSDK: e80989b9a674116a25ef59b3ddfa3edabccfba6f
PODFILE CHECKSUM: aa25954b2fc8a5cf1a3f6b2eaa0d37f103cc9b81
COCOAPODS: 1.16.2

130
README.md
View File

@@ -28,70 +28,119 @@ let sdk = DuooomiBleSDK(config: .init(
apiKey: "your-api-key" // apiKey: "your-api-key" //
// apiHost: URL(...)!, // https://api.duooomi.com // apiHost: URL(...)!, // https://api.duooomi.com
// cdnHost: "https://cdn.bowong.cc/", // // cdnHost: "https://cdn.bowong.cc/", //
// firmwareIdentifier: "duomi", //
// firmwareStatus: "DRAFT" //
// aniWidth: 360, // ANI 360 // aniWidth: 360, // ANI 360
// aniHeight: 360, // ANI 360 // aniHeight: 360, // ANI 360
// aniFps: "24", // ANI 24 // aniFps: "24", // ANI 24
// firmwareIdentifier: "duomi", //
// firmwareStatus: "DRAFT" //
)) ))
//
sdk.delegate = self
```
## 代理Delegate
通过 `DuooomiBleSDKDelegate` 接收状态变化,所有方法在主线程调用,均有默认空实现:
```swift
extension ViewController: DuooomiBleSDKDelegate {
func sdk(_ sdk: DuooomiBleSDK, didChangeState state: ConnectionState) { }
func sdk(_ sdk: DuooomiBleSDK, didUpdateDevices devices: [DiscoveredDevice]) { }
func sdk(_ sdk: DuooomiBleSDK, didConnectDevice device: DiscoveredDevice?) { }
func sdk(_ sdk: DuooomiBleSDK, didUpdateDeviceInfo info: DeviceInfo?) { }
func sdk(_ sdk: DuooomiBleSDK, didUpdateVersion version: String) { }
func sdk(_ sdk: DuooomiBleSDK, didChangeActivation isActivated: Bool) { }
func sdk(_ sdk: DuooomiBleSDK, didUpdateProgress progress: Int) { }
func sdk(_ sdk: DuooomiBleSDK, didEncounterError error: String?) { }
}
``` ```
## API ## API
所有异步方法使用 `completion` 回调,返回 `Result<T, Error>`
### 扫描 ### 扫描
| 方法 | 返回 | 说明 | | 方法 | 说明 |
|------|------|------| |------|------|
| `scan()` | `Void` | 开始扫描,结果通过 `discoveredDevices` 观察 | | `scan()` | 开始扫描,结果通过 delegate `didUpdateDevices` 回调 |
| `stopScan()` | `Void` | 停止扫描 | | `stopScan()` | 停止扫描 |
### 连接 ### 连接
| 方法 | 返回 | 说明 | ```swift
|------|------|------| sdk.connect(deviceId: deviceId) { result in
| `connect(deviceId: String)` | `DiscoveredDevice` | 连接设备 | switch result {
| `disconnect()` | `Void` | 断开连接 | case .success(let device): print("Connected: \(device.name ?? device.id)")
| `getConnectedDevices()` | `[DiscoveredDevice]` | 获取系统已连接设备 | case .failure(let error): print("Failed: \(error)")
}
}
sdk.disconnect { result in
// ...
}
```
### 设备命令 ### 设备命令
| 方法 | 返回 | 说明 | ```swift
|------|------|------| sdk.getDeviceInfo { result in
| `getDeviceInfo()` | `DeviceInfo` | 品牌、容量、电量、型号 | if case .success(let info) = result {
| `getVersion()` | `VersionInfo` | 固件版本 | print("Brand: \(info.brand), Power: \(info.powerlevel)%")
| `bind(userId: String)` | `BindingResponse` | 绑定设备,返回 sn + 文件列表。**绑定后才能查看设备文件** | }
| `unbind(userId: String)` | `UnbindResponse` | 解绑设备。**解绑后该账号的设备文件将被删除** | }
| `deleteFile(key: String)` | `DeleteFileResponse` | 删除设备上的单个文件 |
sdk.getVersion { result in
if case .success(let info) = result {
print("Version: \(info.version)")
}
}
sdk.bind(userId: "user-id") { result in
if case .success(let resp) = result {
print("SN: \(resp.sn), Files: \(resp.contents.count)")
}
}
sdk.unbind(userId: "user-id") { result in /* ... */ }
sdk.deleteFile(key: "file-key") { result in /* ... */ }
```
### 文件传输 ### 文件传输
| 方法 | 返回 | 说明 | ```swift
|------|------|------| // ANI prepare transfer
| `transferMedia(fileUrl: String)` | `Void` | 输入文件 URLmp4/jpg/png内部自动完成 ANI 转换 → prepare → 传输。进度通过 `transferProgress` 观察 | sdk.transferMedia(fileUrl: "https://example.com/video.mp4") { result in
switch result {
case .success: print("Transfer complete")
case .failure(let error): print("Transfer failed: \(error)")
}
}
// delegate didUpdateProgress
```
### 固件升级 ### 固件升级
| 方法 | 返回 | 说明 |
|------|------|------|
| `fetchLatestFirmware(identifier:status:)` | `FirmwareInfo?` | 查询最新固件nil 表示无可用版本 |
| `hasNewerFirmware(deviceVersion:serverVersion:)` | `Bool` | 对比版本号末尾10位时间戳判断是否有新版本。参数无效返回 false |
| `upgradeFirmware(fileUrl: String)` | `Void` | OTA 传输固件包到设备 |
```swift ```swift
// 1. // 1.
let info = try await sdk.fetchLatestFirmware() sdk.fetchLatestFirmware { result in
guard case .success(let info?) = result else { return }
// 2. // 2.
let needUpdate = sdk.hasNewerFirmware( let needUpdate = sdk.hasNewerFirmware(
deviceVersion: sdk.version, // deviceVersion: sdk.version,
serverVersion: info?.version // serverVersion: info.version
) )
// 3. OTA // 3. OTA
try await sdk.upgradeFirmware(fileUrl: info.fileUrl) if needUpdate {
sdk.upgradeFirmware(fileUrl: info.fileUrl) { result in /* ... */ }
}
}
``` ```
### 可观察状态 ### 可读取状态
| 属性 | 类型 | 说明 | | 属性 | 类型 | 说明 |
|------|------|------| |------|------|------|
@@ -124,8 +173,8 @@ public struct DeviceInfo {
let brand: String // let brand: String //
let size: String // let size: String //
let powerlevel: Int // let powerlevel: Int //
let allspace: Int // let allspace: UInt64 //
let freespace: Int // let freespace: UInt64 //
} }
// BindingResponse bind // BindingResponse bind
@@ -145,5 +194,6 @@ public struct BindingResponse {
## 技术规格 ## 技术规格
- iOS 16.0+ / Swift 5.9+ / 零依赖 - iOS 12.2+ / Swift 5.0+ / 零依赖
- 公开 API @MainActorBLE 操作专用串行队列 - Delegate 回调模式,所有状态变化在主线程通知
- BLE 操作专用串行队列

View File

@@ -1,198 +1,116 @@
import CoreBluetooth import CoreBluetooth
import Foundation import Foundation
/// CoreBluetooth async API /// BLE
final class BleClient: NSObject, @unchecked Sendable { protocol BleClientDelegate: AnyObject {
private var centralManager: CBCentralManager! func bleClient(_ client: BleClient, didDiscoverDevice device: DiscoveredDevice)
private let queue = DispatchQueue(label: "com.duooomi.ble.client") func bleClient(_ client: BleClient, didReceiveData data: Data)
}
private(set) var connectedPeripheral: CBPeripheral? final class BleClient: NSObject {
private(set) var writeCharacteristic: CBCharacteristic?
private(set) var readCharacteristic: CBCharacteristic?
/// (scan )
private var discoveredPeripherals: [UUID: CBPeripheral] = [:]
// MARK: - Continuations
private var connectContinuation: CheckedContinuation<CBPeripheral, Error>?
private var disconnectContinuation: CheckedContinuation<Void, Error>?
// MARK: - Streams
private var scanContinuation: AsyncStream<DiscoveredDevice>.Continuation?
private var notificationContinuation: AsyncStream<Data>.Continuation?
// MARK: - Callbacks
weak var delegate: BleClientDelegate?
var onConnectionStateChange: ((ConnectionState) -> Void)? var onConnectionStateChange: ((ConnectionState) -> Void)?
var onDisconnected: (() -> Void)? var onDisconnected: (() -> Void)?
// MARK: - Init private let queue = DispatchQueue(label: "com.duooomi.ble.client", qos: .userInitiated)
private var centralManager: CBCentralManager!
private var connectedPeripheral: CBPeripheral?
private var writeCharacteristic: CBCharacteristic?
private var readCharacteristic: CBCharacteristic?
override init() { // Connect/Disconnect callbacks
super.init() private var connectCompletion: ((Result<CBPeripheral, Error>) -> Void)?
centralManager = CBCentralManager(delegate: self, queue: queue) private var disconnectCompletion: ((Result<Void, Error>) -> Void)?
BleLog.i("BleClient initialized", "BLE") private var connectTimeoutWork: DispatchWorkItem?
}
// MARK: - Public API
///
var bluetoothState: CBManagerState { var bluetoothState: CBManagerState {
centralManager.state centralManager.state
} }
/// `.withoutResponse`
var maximumWriteLength: Int { var maximumWriteLength: Int {
connectedPeripheral?.maximumWriteValueLength(for: .withoutResponse) ?? 20 connectedPeripheral?.maximumWriteValueLength(for: .withoutResponse) ?? 182
} }
/// Service override init() {
/// - Parameter serviceUUIDs: Service UUID 使 SDK super.init()
/// - Returns: centralManager = CBCentralManager(delegate: self, queue: queue)
func scan(serviceUUIDs: [CBUUID] = [BleUUIDs.service]) -> AsyncStream<DiscoveredDevice> {
stopScan()
discoveredPeripherals.removeAll()
return AsyncStream { continuation in
self.scanContinuation = continuation
continuation.onTermination = { [weak self] _ in
self?.queue.async {
self?.centralManager.stopScan()
self?.scanContinuation = nil
}
}
self.queue.async {
guard self.centralManager.state == .poweredOn else { return }
BleLog.i("CBCentral startScan", "BLE")
self.centralManager.scanForPeripherals(
withServices: serviceUUIDs,
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
)
}
}
} }
/// // MARK: - Scan
func stopScan() {
queue.async { [weak self] in
self?.centralManager.stopScan()
BleLog.i("CBCentral stopScan", "BLE")
}
scanContinuation?.finish()
scanContinuation = nil
}
/// func scan(serviceUUIDs: [CBUUID] = [BleUUIDs.service]) {
/// - Parameters:
/// - deviceId: UUID
/// - timeout: 30
/// - Returns: `CBPeripheral`
/// - Throws:
func connect(deviceId: String, timeout: TimeInterval = 30) async throws -> CBPeripheral {
guard centralManager.state == .poweredOn else { guard centralManager.state == .poweredOn else {
throw DuooomiBleError.bluetoothNotPoweredOn(String(describing: centralManager.state)) BleLog.w("Bluetooth not powered on", "BLE")
} return
guard let uuid = UUID(uuidString: deviceId),
let peripheral = discoveredPeripherals[uuid] else {
throw DuooomiBleError.connectionFailed("Device not found: \(deviceId)")
}
return try await withCheckedThrowingContinuation { continuation in
self.connectContinuation = continuation
self.queue.async {
BleLog.i("Connecting to peripheral: \(peripheral.identifier.uuidString)", "BLE")
self.centralManager.connect(peripheral, options: nil)
}
// Timeout
self.queue.asyncAfter(deadline: .now() + timeout) { [weak self] in
if let cont = self?.connectContinuation {
self?.connectContinuation = nil
self?.centralManager.cancelPeripheralConnection(peripheral)
BleLog.w("Connection timeout: \(peripheral.identifier.uuidString)", "BLE")
cont.resume(throwing: DuooomiBleError.connectionFailed("Connection timeout after \(Int(timeout))s"))
}
}
} }
centralManager.scanForPeripherals(
withServices: serviceUUIDs,
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
)
} }
/// func stopScan() {
/// - Throws: centralManager.stopScan()
func disconnect() async throws {
guard let peripheral = connectedPeripheral else { return }
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
self.disconnectContinuation = continuation
self.queue.async {
BleLog.i("Cancel connection: \(peripheral.identifier.uuidString)", "BLE")
self.centralManager.cancelPeripheralConnection(peripheral)
}
// Timeout for disconnect
self.queue.asyncAfter(deadline: .now() + 5) { [weak self] in
if let cont = self?.disconnectContinuation {
self?.disconnectContinuation = nil
BleLog.w("Force-finish disconnect after timeout", "BLE")
self?.cleanup()
cont.resume()
}
}
}
} }
/// GATT Write Without Response // MARK: - Connect
/// - Parameter data:
/// - Throws: func connect(deviceId: String, timeout: TimeInterval = 30, completion: @escaping (Result<CBPeripheral, Error>) -> Void) {
guard centralManager.state == .poweredOn else {
completion(.failure(DuooomiBleError.bluetoothNotPoweredOn("Bluetooth not powered on")))
return
}
let peripherals = centralManager.retrievePeripherals(withIdentifiers: [UUID(uuidString: deviceId)!])
guard let peripheral = peripherals.first else {
completion(.failure(DuooomiBleError.connectionFailed("Device not found: \(deviceId)")))
return
}
self.connectCompletion = completion
peripheral.delegate = self
centralManager.connect(peripheral, options: nil)
// Timeout
let work = DispatchWorkItem { [weak self] in
guard let self = self, self.connectCompletion != nil else { return }
self.centralManager.cancelPeripheralConnection(peripheral)
let cb = self.connectCompletion
self.connectCompletion = nil
cb?(.failure(DuooomiBleError.connectionFailed("Connection timeout")))
}
connectTimeoutWork = work
queue.asyncAfter(deadline: .now() + timeout, execute: work)
}
// MARK: - Disconnect
func disconnect(completion: @escaping (Result<Void, Error>) -> Void) {
guard let peripheral = connectedPeripheral else {
completion(.success(()))
return
}
disconnectCompletion = completion
centralManager.cancelPeripheralConnection(peripheral)
}
// MARK: - Write
func writeWithoutResponse(_ data: Data) throws { func writeWithoutResponse(_ data: Data) throws {
guard let peripheral = connectedPeripheral, guard let peripheral = connectedPeripheral, let char = writeCharacteristic else {
let characteristic = writeCharacteristic else {
throw DuooomiBleError.notConnected throw DuooomiBleError.notConnected
} }
BleLog.d("Write w/o response, len=\(data.count)", "BLE") peripheral.writeValue(data, for: char, type: .withoutResponse)
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
} }
/// Service // MARK: - Retrieve Connected
/// - Returns:
func retrieveConnectedDevices() -> [DiscoveredDevice] { func retrieveConnectedDevices() -> [DiscoveredDevice] {
let peripherals = centralManager.retrieveConnectedPeripherals(withServices: [BleUUIDs.service]) centralManager.retrieveConnectedPeripherals(withServices: [BleUUIDs.service]).map {
return peripherals.map { peripheral in DiscoveredDevice(id: $0.identifier.uuidString, name: $0.name, rssi: 0)
DiscoveredDevice(
id: peripheral.identifier.uuidString,
name: peripheral.name,
rssi: 0
)
} }
} }
///
/// - Returns:
func notifications() -> AsyncStream<Data> {
AsyncStream { continuation in
self.notificationContinuation = continuation
continuation.onTermination = { [weak self] _ in
self?.notificationContinuation = nil
}
BleLog.i("Notifications stream attached", "BLE")
}
}
// MARK: - Private
private func cleanup() {
if let readChar = readCharacteristic, let peripheral = connectedPeripheral {
peripheral.setNotifyValue(false, for: readChar)
}
connectedPeripheral = nil
writeCharacteristic = nil
readCharacteristic = nil
notificationContinuation?.finish()
notificationContinuation = nil
BleLog.i("Client state cleaned up", "BLE")
}
} }
// MARK: - CBCentralManagerDelegate // MARK: - CBCentralManagerDelegate
@@ -200,67 +118,51 @@ final class BleClient: NSObject, @unchecked Sendable {
extension BleClient: CBCentralManagerDelegate { extension BleClient: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) { func centralManagerDidUpdateState(_ central: CBCentralManager) {
// If scanning was requested before poweredOn, start now BleLog.i("Bluetooth state: \(central.state.rawValue)", "BLE")
if central.state == .poweredOn, scanContinuation != nil {
BleLog.d("State poweredOn; resume scan", "BLE")
central.scanForPeripherals(
withServices: [BleUUIDs.service],
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
)
}
BleLog.d("Central state=\(central.state.rawValue)", "BLE")
} }
func centralManager( func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
_ central: CBCentralManager, advertisementData: [String: Any], rssi RSSI: NSNumber) {
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber
) {
discoveredPeripherals[peripheral.identifier] = peripheral
let device = DiscoveredDevice( let device = DiscoveredDevice(
id: peripheral.identifier.uuidString, id: peripheral.identifier.uuidString,
name: peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String, name: peripheral.name,
rssi: RSSI.intValue rssi: RSSI.intValue
) )
BleLog.d("Did discover: id=\(device.id), rssi=\(device.rssi)", "BLE") delegate?.bleClient(self, didDiscoverDevice: device)
scanContinuation?.yield(device)
} }
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
connectTimeoutWork?.cancel()
connectTimeoutWork = nil
connectedPeripheral = peripheral connectedPeripheral = peripheral
peripheral.delegate = self peripheral.delegate = self
BleLog.i("Did connect: \(peripheral.identifier.uuidString)", "BLE")
peripheral.discoverServices([BleUUIDs.service]) peripheral.discoverServices([BleUUIDs.service])
} }
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
let cont = connectContinuation connectTimeoutWork?.cancel()
connectContinuation = nil connectTimeoutWork = nil
BleLog.e("Fail to connect: \(peripheral.identifier.uuidString), error=\(error?.localizedDescription ?? "-")", "BLE") let cb = connectCompletion
cont?.resume(throwing: DuooomiBleError.connectionFailed(error?.localizedDescription ?? "Unknown error")) connectCompletion = nil
cb?(.failure(error ?? DuooomiBleError.connectionFailed("Unknown")))
} }
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
// If disconnect happens mid-setup (after didConnect but before discoverCharacteristics connectedPeripheral = nil
// completes), the connect continuation must be resolved or the caller hangs forever. writeCharacteristic = nil
if let connectCont = connectContinuation { readCharacteristic = nil
connectContinuation = nil
connectCont.resume(throwing: DuooomiBleError.connectionFailed( if let cb = disconnectCompletion {
error?.localizedDescription ?? "Disconnected during setup" disconnectCompletion = nil
)) if let error = error {
cb(.failure(error))
} else {
cb(.success(()))
}
} }
DispatchQueue.main.async { [weak self] in
cleanup() self?.onDisconnected?()
if let cont = disconnectContinuation {
disconnectContinuation = nil
cont.resume()
} }
BleLog.w("Did disconnect: \(peripheral.identifier.uuidString), error=\(error?.localizedDescription ?? "-")", "BLE")
onDisconnected?()
} }
} }
@@ -270,22 +172,17 @@ extension BleClient: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
if let error = error { if let error = error {
let cont = connectContinuation let cb = connectCompletion
connectContinuation = nil connectCompletion = nil
BleLog.e("Discover services error: \(error.localizedDescription)", "BLE") cb?(.failure(error))
cont?.resume(throwing: DuooomiBleError.connectionFailed(error.localizedDescription))
return return
} }
guard let service = peripheral.services?.first(where: { $0.uuid == BleUUIDs.service }) else { guard let service = peripheral.services?.first(where: { $0.uuid == BleUUIDs.service }) else {
let cont = connectContinuation let cb = connectCompletion
connectContinuation = nil connectCompletion = nil
BleLog.e("Service not found", "BLE") cb?(.failure(DuooomiBleError.serviceNotFound))
cont?.resume(throwing: DuooomiBleError.serviceNotFound)
return return
} }
BleLog.d("Service found: \(service.uuid.uuidString)", "BLE")
peripheral.discoverCharacteristics( peripheral.discoverCharacteristics(
[BleUUIDs.writeCharacteristic, BleUUIDs.readCharacteristic], [BleUUIDs.writeCharacteristic, BleUUIDs.readCharacteristic],
for: service for: service
@@ -294,17 +191,15 @@ extension BleClient: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
if let error = error { if let error = error {
let cont = connectContinuation let cb = connectCompletion
connectContinuation = nil connectCompletion = nil
BleLog.e("Discover characteristics error: \(error.localizedDescription)", "BLE") cb?(.failure(error))
cont?.resume(throwing: DuooomiBleError.connectionFailed(error.localizedDescription))
return return
} }
guard let characteristics = service.characteristics else { guard let characteristics = service.characteristics else {
let cont = connectContinuation let cb = connectCompletion
connectContinuation = nil connectCompletion = nil
cont?.resume(throwing: DuooomiBleError.characteristicNotFound) cb?(.failure(DuooomiBleError.characteristicNotFound))
return return
} }
@@ -318,24 +213,19 @@ extension BleClient: CBPeripheralDelegate {
} }
guard writeCharacteristic != nil, readCharacteristic != nil else { guard writeCharacteristic != nil, readCharacteristic != nil else {
let cont = connectContinuation let cb = connectCompletion
connectContinuation = nil connectCompletion = nil
BleLog.e("Required characteristics missing", "BLE") cb?(.failure(DuooomiBleError.characteristicNotFound))
cont?.resume(throwing: DuooomiBleError.characteristicNotFound)
return return
} }
// Connection fully ready let cb = connectCompletion
let cont = connectContinuation connectCompletion = nil
connectContinuation = nil cb?(.success(peripheral))
BleLog.i("Characteristics ready; connection established", "BLE")
cont?.resume(returning: peripheral)
} }
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard error == nil, characteristic.uuid == BleUUIDs.readCharacteristic, guard error == nil, let data = characteristic.value else { return }
let value = characteristic.value else { return } delegate?.bleClient(self, didReceiveData: data)
BleLog.d("Notification received, len=\(value.count)", "BLE")
notificationContinuation?.yield(value)
} }
} }

View File

@@ -1,6 +1,6 @@
import Foundation import Foundation
public enum ConnectionState: String, Sendable { public enum ConnectionState: String {
case idle case idle
case scanning case scanning
case connecting case connecting
@@ -9,7 +9,7 @@ public enum ConnectionState: String, Sendable {
case disconnected case disconnected
} }
public struct DiscoveredDevice: Identifiable, Sendable, Equatable { public struct DiscoveredDevice: Identifiable, Equatable {
public let id: String public let id: String
public let name: String? public let name: String?
public let rssi: Int public let rssi: Int
@@ -21,7 +21,7 @@ public struct DiscoveredDevice: Identifiable, Sendable, Equatable {
} }
} }
public enum DuooomiBleError: LocalizedError, Sendable { public enum DuooomiBleError: LocalizedError {
case bluetoothNotPoweredOn(String) case bluetoothNotPoweredOn(String)
case notConnected case notConnected
case timeout(command: CommandType) case timeout(command: CommandType)

View File

@@ -1,7 +1,7 @@
import Foundation import Foundation
/// SDK /// SDK
public struct DuooomiBleConfig: Sendable { public struct DuooomiBleConfig {
/// API /// API
public let apiHost: URL public let apiHost: URL
/// API key /// API key

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
import Foundation
/// SDK Combine @Published
/// 线
public protocol DuooomiBleSDKDelegate: AnyObject {
///
func sdk(_ sdk: DuooomiBleSDK, didChangeState state: ConnectionState)
///
func sdk(_ sdk: DuooomiBleSDK, didUpdateDevices devices: [DiscoveredDevice])
///
func sdk(_ sdk: DuooomiBleSDK, didConnectDevice device: DiscoveredDevice?)
///
func sdk(_ sdk: DuooomiBleSDK, didUpdateDeviceInfo info: DeviceInfo?)
///
func sdk(_ sdk: DuooomiBleSDK, didUpdateVersion version: String)
///
func sdk(_ sdk: DuooomiBleSDK, didChangeActivation isActivated: Bool)
/// (0-100)
func sdk(_ sdk: DuooomiBleSDK, didUpdateProgress progress: Int)
///
func sdk(_ sdk: DuooomiBleSDK, didEncounterError error: String?)
}
/// delegate
public extension DuooomiBleSDKDelegate {
func sdk(_ sdk: DuooomiBleSDK, didChangeState state: ConnectionState) {}
func sdk(_ sdk: DuooomiBleSDK, didUpdateDevices devices: [DiscoveredDevice]) {}
func sdk(_ sdk: DuooomiBleSDK, didConnectDevice device: DiscoveredDevice?) {}
func sdk(_ sdk: DuooomiBleSDK, didUpdateDeviceInfo info: DeviceInfo?) {}
func sdk(_ sdk: DuooomiBleSDK, didUpdateVersion version: String) {}
func sdk(_ sdk: DuooomiBleSDK, didChangeActivation isActivated: Bool) {}
func sdk(_ sdk: DuooomiBleSDK, didUpdateProgress progress: Int) {}
func sdk(_ sdk: DuooomiBleSDK, didEncounterError error: String?) {}
}

View File

@@ -1,6 +1,6 @@
import Foundation import Foundation
public struct BindingResponse: Codable, Sendable, Equatable { public struct BindingResponse: Codable, Equatable {
public let type: Int public let type: Int
/// SN (14 ) /// SN (14 )
public let sn: String public let sn: String

View File

@@ -1,6 +1,6 @@
import Foundation import Foundation
public struct DeleteFileResponse: Codable, Sendable, Equatable { public struct DeleteFileResponse: Codable, Equatable {
public let type: Int public let type: Int
/// 0 = , 1 = , 2 = /// 0 = , 1 = , 2 =
public let success: Int public let success: Int

View File

@@ -1,6 +1,6 @@
import Foundation import Foundation
public struct DeviceInfo: Sendable, Equatable { public struct DeviceInfo: Equatable {
public let allspace: UInt64 public let allspace: UInt64
public let freespace: UInt64 public let freespace: UInt64
public let name: String public let name: String

View File

@@ -1,6 +1,6 @@
import Foundation import Foundation
public struct PrepareTransferResponse: Codable, Sendable, Equatable { public struct PrepareTransferResponse: Codable, Equatable {
public let type: Int public let type: Int
public let key: String public let key: String
/// "ready" | "no_space" | "duplicated" /// "ready" | "no_space" | "duplicated"

View File

@@ -1,6 +1,6 @@
import Foundation import Foundation
public struct UnbindResponse: Codable, Sendable, Equatable { public struct UnbindResponse: Codable, Equatable {
/// 1 = , 0 = /// 1 = , 0 =
public let success: Int public let success: Int
} }

View File

@@ -1,6 +1,6 @@
import Foundation import Foundation
public struct VersionInfo: Sendable, Equatable { public struct VersionInfo: Equatable {
public let version: String public let version: String
/// Int ( 7) String ( "0x07") /// Int ( 7) String ( "0x07")
public let type: String public let type: String

View File

@@ -21,7 +21,7 @@ public enum FrameConstants {
public static let screenSize = 360 public static let screenSize = 360
} }
public enum CommandType: UInt8, Sendable { public enum CommandType: UInt8 {
case otaPackage = 0x02 case otaPackage = 0x02
case transferBootAnimation = 0x03 case transferBootAnimation = 0x03
case transferAniVideo = 0x05 case transferAniVideo = 0x05

View File

@@ -2,7 +2,7 @@ import Foundation
/// BLE /// BLE
/// : [head:1][type:1][subpageTotal:2][curPage:2][dataLen:2][data:N][checksum:1] /// : [head:1][type:1][subpageTotal:2][curPage:2][dataLen:2][data:N][checksum:1]
public struct ProtocolFrame: Sendable { public struct ProtocolFrame {
/// (0xC7 = APPDevice, 0xB0 = DeviceAPP) /// (0xC7 = APPDevice, 0xB0 = DeviceAPP)
public let head: UInt8 public let head: UInt8
/// CommandType /// CommandType

View File

@@ -10,7 +10,7 @@ final class AniConverter {
private let config: DuooomiBleConfig private let config: DuooomiBleConfig
private var endpoint: URL { private var endpoint: URL {
config.apiHost.appendingPathComponent("api/auth/loomart/file/convert-to-ani") URL(string: "\(config.apiHost)/api/auth/loomart/file/convert-to-ani")!
} }
private let session: URLSession = { private let session: URLSession = {
@@ -26,15 +26,23 @@ final class AniConverter {
} }
/// ANI ani CDN URL /// ANI ani CDN URL
func convert(fileUrl: String) async throws -> String { func convert(fileUrl: String, completion: @escaping (Result<String, Error>) -> Void) {
do { performConvert(fileUrl: fileUrl) { result in
return try await performConvert(fileUrl: fileUrl) switch result {
} catch let error as NSError where error.code == NSURLErrorNetworkConnectionLost { case .success:
return try await performConvert(fileUrl: fileUrl) completion(result)
case .failure(let error):
// Retry once on connection lost
if (error as NSError).code == NSURLErrorNetworkConnectionLost {
self.performConvert(fileUrl: fileUrl, completion: completion)
} else {
completion(.failure(error))
}
}
} }
} }
private func performConvert(fileUrl: String) async throws -> String { private func performConvert(fileUrl: String, completion: @escaping (Result<String, Error>) -> Void) {
var request = URLRequest(url: endpoint) var request = URLRequest(url: endpoint)
request.httpMethod = "POST" request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "content-type") request.setValue("application/json", forHTTPHeaderField: "content-type")
@@ -47,32 +55,49 @@ final class AniConverter {
"height": config.aniHeight, "height": config.aniHeight,
"fps": config.aniFps, "fps": config.aniFps,
] ]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request) do {
request.httpBody = try JSONSerialization.data(withJSONObject: body)
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { } catch {
throw AniConverterError(message: "HTTP \(http.statusCode)") completion(.failure(error))
return
} }
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { session.dataTask(with: request) { data, response, error in
throw AniConverterError(message: "Invalid JSON response") if let error = error {
} completion(.failure(error))
return
}
guard (json["success"] as? Bool) == true, if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
let outer = json["data"] as? [String: Any] else { completion(.failure(AniConverterError(message: "HTTP \(http.statusCode)")))
throw AniConverterError(message: "ANI API reported failure") return
} }
if (outer["status"] as? Bool) != true { guard let data = data,
let msg = (outer["msg"] as? String) ?? "ANI conversion failed" let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else {
throw AniConverterError(message: msg) completion(.failure(AniConverterError(message: "Invalid JSON response")))
} return
}
guard let aniUrl = outer["data"] as? String, !aniUrl.isEmpty else { guard (json["success"] as? Bool) == true,
throw AniConverterError(message: "Missing ani url in response") let outer = json["data"] as? [String: Any] else {
} completion(.failure(AniConverterError(message: "ANI API reported failure")))
return
}
return aniUrl if (outer["status"] as? Bool) != true {
let msg = (outer["msg"] as? String) ?? "ANI conversion failed"
completion(.failure(AniConverterError(message: msg)))
return
}
guard let aniUrl = outer["data"] as? String, !aniUrl.isEmpty else {
completion(.failure(AniConverterError(message: "Missing ani url in response")))
return
}
completion(.success(aniUrl))
}.resume()
} }
} }

View File

@@ -1,8 +1,16 @@
import Foundation import Foundation
///
protocol BleProtocolServiceDelegate: AnyObject {
func protocolService(_ service: BleProtocolService, didReceiveMessage commandType: UInt8, data: Data)
}
/// ///
final class BleProtocolService: @unchecked Sendable { final class BleProtocolService: BleClientDelegate {
weak var delegate: BleProtocolServiceDelegate?
private let client: BleClient private let client: BleClient
private var isListening = false
/// ///
private struct FragmentSession { private struct FragmentSession {
@@ -11,63 +19,36 @@ final class BleProtocolService: @unchecked Sendable {
} }
private var fragments: [String: FragmentSession] = [:] private var fragments: [String: FragmentSession] = [:]
private var listenerTask: Task<Void, Never>?
private var messageContinuation: AsyncStream<(commandType: UInt8, data: Data)>.Continuation?
private(set) var incomingMessages: AsyncStream<(commandType: UInt8, data: Data)>!
init(client: BleClient) { init(client: BleClient) {
self.client = client self.client = client
incomingMessages = AsyncStream { continuation in
self.messageContinuation = continuation
}
BleLog.i("Protocol service created", "Protocol") BleLog.i("Protocol service created", "Protocol")
} }
// MARK: - Start/Stop Listening // MARK: - Start/Stop Listening
///
/// - Note:
func startListening() { func startListening() {
// Recreate the stream in case it was previously finished isListening = true
incomingMessages = AsyncStream { continuation in client.delegate = self
self.messageContinuation = continuation
}
fragments.removeAll() fragments.removeAll()
BleLog.i("Start listening for notifications", "Protocol")
listenerTask = Task { [weak self] in
guard let self else { return }
BleLog.i("Start listening for notifications", "Protocol")
for await data in self.client.notifications() {
self.handleRawData(data)
}
}
} }
///
func stopListening() { func stopListening() {
listenerTask?.cancel() isListening = false
listenerTask = nil
messageContinuation?.finish()
messageContinuation = nil
fragments.removeAll() fragments.removeAll()
BleLog.i("Stop listening; fragments cleared", "Protocol") BleLog.i("Stop listening; fragments cleared", "Protocol")
} }
// MARK: - Send // MARK: - Send
/// /// 使
/// - Parameters:
/// - type:
/// - data:
/// - onProgress: 0.0~1.0
/// - Throws:
func send( func send(
type: UInt8, type: UInt8,
data: Data, data: Data,
onProgress: ((Double) -> Void)? = nil onProgress: ((Double) -> Void)? = nil,
) async throws { completion: @escaping (Result<Void, Error>) -> Void
) {
let maxWriteLen = client.maximumWriteLength let maxWriteLen = client.maximumWriteLength
let maxDataSize = min( let maxDataSize = min(
maxWriteLen - FrameConstants.headerSize - FrameConstants.footerSize, maxWriteLen - FrameConstants.headerSize - FrameConstants.footerSize,
@@ -84,27 +65,84 @@ final class BleProtocolService: @unchecked Sendable {
guard !frames.isEmpty else { guard !frames.isEmpty else {
BleLog.e("Payload too large; frames empty", "Protocol") BleLog.e("Payload too large; frames empty", "Protocol")
throw DuooomiBleError.transferFailed("Payload too large: exceeds 65535-page limit") completion(.failure(DuooomiBleError.transferFailed("Payload too large: exceeds 65535-page limit")))
return
} }
let total = frames.count let total = frames.count
BleLog.d("Sending frames: total=\(total), maxChunk=\(safeMaxDataSize)", "Protocol") BleLog.d("Sending frames: total=\(total), maxChunk=\(safeMaxDataSize)", "Protocol")
for (index, frame) in frames.enumerated() {
try client.writeWithoutResponse(frame) func sendFrame(at index: Int) {
try await Task.sleep(nanoseconds: FrameConstants.frameIntervalNanos) guard index < total else {
onProgress?(1.0)
BleLog.i("Frames sent: total=\(total)", "Protocol")
completion(.success(()))
return
}
do {
try self.client.writeWithoutResponse(frames[index])
} catch {
completion(.failure(error))
return
}
onProgress?(Double(index + 1) / Double(total)) onProgress?(Double(index + 1) / Double(total))
if index + 1 < total {
// Inter-frame delay 35ms
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.035) {
sendFrame(at: index + 1)
}
} else {
onProgress?(1.0)
BleLog.i("Frames sent: total=\(total)", "Protocol")
completion(.success(()))
}
} }
BleLog.i("Frames sent: total=\(total)", "Protocol")
sendFrame(at: 0)
} }
/// JSON /// JSON
/// - Parameters: func sendJSON<T: Encodable>(type: CommandType, payload: T, completion: @escaping (Result<Void, Error>) -> Void) {
/// - type: do {
/// - payload: let data = try JSONEncoder().encode(payload)
/// - Throws: / send(type: type.rawValue, data: data, completion: completion)
func sendJSON<T: Encodable>(type: CommandType, payload: T) async throws { } catch {
completion(.failure(error))
}
}
/// JSON fire-and-forget
func sendJSON<T: Encodable>(type: CommandType, payload: T) throws {
let jsonData = try JSONEncoder().encode(payload) let jsonData = try JSONEncoder().encode(payload)
try await send(type: type.rawValue, data: jsonData) let maxWriteLen = client.maximumWriteLength
let maxDataSize = min(
maxWriteLen - FrameConstants.headerSize - FrameConstants.footerSize,
FrameConstants.maxDataSize
)
let safeMaxDataSize = max(1, maxDataSize)
let frames = ProtocolManager.createFrames(
type: type.rawValue,
data: jsonData,
head: .appToDevice,
maxDataSize: safeMaxDataSize
)
for frame in frames {
try client.writeWithoutResponse(frame)
}
}
// MARK: - BleClientDelegate
func bleClient(_ client: BleClient, didDiscoverDevice device: DiscoveredDevice) {
// Not handled here scan results forwarded by SDK
}
func bleClient(_ client: BleClient, didReceiveData data: Data) {
guard isListening else { return }
handleRawData(data)
} }
// MARK: - Receive & Reassemble // MARK: - Receive & Reassemble
@@ -119,15 +157,12 @@ final class BleProtocolService: @unchecked Sendable {
handleFragment(frame) handleFragment(frame)
} else { } else {
BleLog.d("Single frame received: type=\(frame.type), len=\(frame.data.count)", "Protocol") BleLog.d("Single frame received: type=\(frame.type), len=\(frame.data.count)", "Protocol")
messageContinuation?.yield((commandType: frame.type, data: frame.data)) delegate?.protocolService(self, didReceiveMessage: frame.type, data: frame.data)
} }
} }
private func handleFragment(_ frame: ProtocolFrame) { private func handleFragment(_ frame: ProtocolFrame) {
// Match TS reference behavior: scope reassembly per peripheral so concurrent transfers let key = "\(frame.type)"
// (or stale fragments from a previous device after reconnect) cannot pollute each other.
let deviceKey = client.connectedPeripheral?.identifier.uuidString ?? "unknown"
let key = "\(deviceKey)_\(frame.type)"
if fragments[key] == nil { if fragments[key] == nil {
fragments[key] = FragmentSession(total: Int(frame.subpageTotal), frames: [:]) fragments[key] = FragmentSession(total: Int(frame.subpageTotal), frames: [:])
@@ -153,6 +188,6 @@ final class BleProtocolService: @unchecked Sendable {
fragments.removeValue(forKey: key) fragments.removeValue(forKey: key)
BleLog.i("Fragment reassembled: key=\(key), size=\(combined.count)", "Protocol") BleLog.i("Fragment reassembled: key=\(key), size=\(combined.count)", "Protocol")
messageContinuation?.yield((commandType: frame.type, data: combined)) delegate?.protocolService(self, didReceiveMessage: frame.type, data: combined)
} }
} }

View File

@@ -1,6 +1,6 @@
import Foundation import Foundation
/// JSON /// JSON fire-and-forget
final class DeviceInfoService { final class DeviceInfoService {
private let protocolService: BleProtocolService private let protocolService: BleProtocolService
@@ -10,61 +10,43 @@ final class DeviceInfoService {
// MARK: - Commands // MARK: - Commands
/// func getDeviceInfo() throws {
/// - Throws: try protocolService.sendJSON(
func getDeviceInfo() async throws {
try await protocolService.sendJSON(
type: .getDeviceInfo, type: .getDeviceInfo,
payload: CommandPayload(type: CommandType.getDeviceInfo.rawValue) payload: CommandPayload(type: CommandType.getDeviceInfo.rawValue)
) )
} }
/// func getDeviceVersion() throws {
/// - Throws: try protocolService.sendJSON(
func getDeviceVersion() async throws {
try await protocolService.sendJSON(
type: .getDeviceVersion, type: .getDeviceVersion,
payload: CommandPayload(type: CommandType.getDeviceVersion.rawValue) payload: CommandPayload(type: CommandType.getDeviceVersion.rawValue)
) )
} }
/// func bindDevice(userId: String) throws {
/// - Parameter userId: try protocolService.sendJSON(
/// - Throws:
func bindDevice(userId: String) async throws {
try await protocolService.sendJSON(
type: .bindDevice, type: .bindDevice,
payload: BindPayload(type: CommandType.bindDevice.rawValue, userId: userId) payload: BindPayload(type: CommandType.bindDevice.rawValue, userId: userId)
) )
} }
/// func unbindDevice(userId: String) throws {
/// - Parameter userId: try protocolService.sendJSON(
/// - Throws:
func unbindDevice(userId: String) async throws {
try await protocolService.sendJSON(
type: .unbindDevice, type: .unbindDevice,
payload: BindPayload(type: CommandType.unbindDevice.rawValue, userId: userId) payload: BindPayload(type: CommandType.unbindDevice.rawValue, userId: userId)
) )
} }
/// func deleteFile(key: String) throws {
/// - Parameter key: key URL try protocolService.sendJSON(
/// - Throws:
func deleteFile(key: String) async throws {
try await protocolService.sendJSON(
type: .deleteFile, type: .deleteFile,
payload: FileKeyPayload(type: CommandType.deleteFile.rawValue, key: key) payload: FileKeyPayload(type: CommandType.deleteFile.rawValue, key: key)
) )
} }
/// func prepareTransfer(key: String, size: Int) throws {
/// - Parameters: try protocolService.sendJSON(
/// - key: key
/// - size:
/// - Throws:
func prepareTransfer(key: String, size: Int) async throws {
try await protocolService.sendJSON(
type: .prepareTransfer, type: .prepareTransfer,
payload: PrepareTransferPayload( payload: PrepareTransferPayload(
type: CommandType.prepareTransfer.rawValue, type: CommandType.prepareTransfer.rawValue,

View File

@@ -9,40 +9,60 @@ final class FileTransferService {
} }
/// ///
/// - Parameters:
/// - fileUri: file:// URL https:// URL
/// - commandType:
/// - onProgress: 0.0 ~ 1.0
func transferFile( func transferFile(
fileUri: String, fileUri: String,
commandType: CommandType, commandType: CommandType,
onProgress: ((Double) -> Void)? = nil onProgress: ((Double) -> Void)? = nil,
) async throws { completion: @escaping (Result<Void, Error>) -> Void
) {
BleLog.i("Load file: \(fileUri)", "Transfer") BleLog.i("Load file: \(fileUri)", "Transfer")
let data = try await loadFileData(from: fileUri) loadFileData(from: fileUri) { [weak self] result in
BleLog.d("File loaded: size=\(data.count) bytes", "Transfer") switch result {
case .failure(let error):
try await protocolService.send( completion(.failure(error))
type: commandType.rawValue, case .success(let data):
data: data, BleLog.d("File loaded: size=\(data.count) bytes", "Transfer")
onProgress: onProgress self?.protocolService.send(
) type: commandType.rawValue,
data: data,
onProgress: onProgress,
completion: completion
)
}
}
} }
private func loadFileData(from uri: String) async throws -> Data { private func loadFileData(from uri: String, completion: @escaping (Result<Data, Error>) -> Void) {
guard let url = URL(string: uri) else { guard let url = URL(string: uri) else {
throw DuooomiBleError.transferFailed("Invalid URL: \(uri)") completion(.failure(DuooomiBleError.transferFailed("Invalid URL: \(uri)")))
return
} }
if url.isFileURL { if url.isFileURL {
return try Data(contentsOf: url) do {
} else { let data = try Data(contentsOf: url)
let (data, response) = try await URLSession.shared.data(from: url) completion(.success(data))
} catch {
completion(.failure(DuooomiBleError.transferFailed("Failed to read local file: \(error.localizedDescription)")))
}
return
}
URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
completion(.failure(DuooomiBleError.transferFailed("Download failed: \(error.localizedDescription)")))
return
}
guard let httpResponse = response as? HTTPURLResponse, guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else { (200...299).contains(httpResponse.statusCode) else {
throw DuooomiBleError.transferFailed("Failed to download file: \(uri)") completion(.failure(DuooomiBleError.transferFailed("Failed to download file: \(uri)")))
return
} }
return data guard let data = data, !data.isEmpty else {
} completion(.failure(DuooomiBleError.transferFailed("Empty download response")))
return
}
completion(.success(data))
}.resume()
} }
} }

View File

@@ -1,7 +1,7 @@
import Foundation import Foundation
/// ///
public struct FirmwareInfo: Codable, Equatable, Sendable { public struct FirmwareInfo: Codable, Equatable {
public let version: String public let version: String
public let fileUrl: String public let fileUrl: String
public let description: String? public let description: String?
@@ -25,20 +25,30 @@ final class FirmwareService {
self.config = config self.config = config
} }
func fetchLatest(identifier: String? = nil, status: String? = nil) async throws -> FirmwareInfo? { func fetchLatest(
identifier: String? = nil,
status: String? = nil,
completion: @escaping (Result<FirmwareInfo?, Error>) -> Void
) {
let id = (identifier ?? config.firmwareIdentifier).trimmingCharacters(in: .whitespacesAndNewlines) let id = (identifier ?? config.firmwareIdentifier).trimmingCharacters(in: .whitespacesAndNewlines)
let st = status ?? config.firmwareStatus let st = status ?? config.firmwareStatus
guard !id.isEmpty else { guard !id.isEmpty else {
throw DuooomiBleError.transferFailed("Invalid firmware identifier") completion(.failure(DuooomiBleError.transferFailed("Invalid firmware identifier")))
return
} }
var comps = URLComponents(url: config.apiHost.appendingPathComponent(latestPath), resolvingAgainstBaseURL: false)! let urlString = "\(config.apiHost)/\(latestPath)"
guard var comps = URLComponents(string: urlString) else {
completion(.failure(DuooomiBleError.transferFailed("Invalid firmware URL")))
return
}
comps.queryItems = [ comps.queryItems = [
URLQueryItem(name: "identifier", value: id), URLQueryItem(name: "identifier", value: id),
URLQueryItem(name: "status", value: st) URLQueryItem(name: "status", value: st)
] ]
guard let url = comps.url else { guard let url = comps.url else {
throw DuooomiBleError.transferFailed("Invalid firmware URL") completion(.failure(DuooomiBleError.transferFailed("Invalid firmware URL")))
return
} }
var req = URLRequest(url: url) var req = URLRequest(url: url)
@@ -46,15 +56,30 @@ final class FirmwareService {
req.setValue(config.apiKey, forHTTPHeaderField: "x-api-key") req.setValue(config.apiKey, forHTTPHeaderField: "x-api-key")
req.setValue("application/json", forHTTPHeaderField: "accept") req.setValue("application/json", forHTTPHeaderField: "accept")
let (data, resp) = try await URLSession.shared.data(for: req) URLSession.shared.dataTask(with: req) { data, resp, error in
guard let http = resp as? HTTPURLResponse else { if let error = error {
throw DuooomiBleError.transferFailed("Invalid response") completion(.failure(error))
} return
guard (200...299).contains(http.statusCode) else { }
throw DuooomiBleError.transferFailed("HTTP \(http.statusCode)") guard let http = resp as? HTTPURLResponse else {
} completion(.failure(DuooomiBleError.transferFailed("Invalid response")))
return
}
guard (200...299).contains(http.statusCode) else {
completion(.failure(DuooomiBleError.transferFailed("HTTP \(http.statusCode)")))
return
}
guard let data = data else {
completion(.failure(DuooomiBleError.transferFailed("Empty response")))
return
}
let decoded = try JSONDecoder().decode(FirmwareResponse.self, from: data) do {
return decoded.data let decoded = try JSONDecoder().decode(FirmwareResponse.self, from: data)
completion(.success(decoded.data))
} catch {
completion(.failure(error))
}
}.resume()
} }
} }

View File

@@ -2,19 +2,69 @@ import SwiftUI
import DuooomiBleSDK import DuooomiBleSDK
/// Demo SDK /// Demo SDK
/// apiKeyapiHost/cdnHost/firmwareIdentifier/firmwareStatus使
@main @main
struct DemoApp: App { struct DemoApp: App {
@StateObject private var sdk = DuooomiBleSDK(config: .init( @StateObject private var viewModel = DemoViewModel()
apiKey: ""
))
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
NavigationView { NavigationView {
WrapperTestView() WrapperTestView(viewModel: viewModel)
} }
.environmentObject(sdk)
} }
} }
} }
/// ViewModel SDK callback API DuooomiBleSDKDelegate UI
class DemoViewModel: NSObject, ObservableObject, DuooomiBleSDKDelegate {
let sdk: DuooomiBleSDK
@Published var btState: ConnectionState = .idle
@Published var connectedDevice: DiscoveredDevice? = nil
@Published var discoveredDevices: [DiscoveredDevice] = []
@Published var deviceInfo: DeviceInfo? = nil
@Published var version: String = ""
@Published var isActivated: Bool = false
@Published var transferProgress: Int = 0
@Published var error: String? = nil
override init() {
sdk = DuooomiBleSDK(config: .init(apiKey: ""))
super.init()
sdk.delegate = self
}
// MARK: - DuooomiBleSDKDelegate
func sdk(_ sdk: DuooomiBleSDK, didChangeState state: ConnectionState) {
btState = state
}
func sdk(_ sdk: DuooomiBleSDK, didUpdateDevices devices: [DiscoveredDevice]) {
discoveredDevices = devices
}
func sdk(_ sdk: DuooomiBleSDK, didConnectDevice device: DiscoveredDevice?) {
connectedDevice = device
}
func sdk(_ sdk: DuooomiBleSDK, didUpdateDeviceInfo info: DeviceInfo?) {
deviceInfo = info
}
func sdk(_ sdk: DuooomiBleSDK, didUpdateVersion version: String) {
self.version = version
}
func sdk(_ sdk: DuooomiBleSDK, didChangeActivation isActivated: Bool) {
self.isActivated = isActivated
}
func sdk(_ sdk: DuooomiBleSDK, didUpdateProgress progress: Int) {
transferProgress = progress
}
func sdk(_ sdk: DuooomiBleSDK, didEncounterError error: String?) {
self.error = error
}
}

View File

@@ -21,18 +21,9 @@ private enum CDNHelper {
} }
// MARK: - View // MARK: - View
//
// Demo SDK
// 1. Scan & Connect sdk.scan() / sdk.connect(deviceId:) / sdk.disconnect()
// 2. Device sdk.getDeviceInfo() / sdk.getVersion() / sdk.bind(userId:) / sdk.unbind(userId:)
// 3. Transfer sdk.transferMedia(fileUrl:) ANI + prepare + transfer
// 4. Firmware sdk.fetchLatestFirmware() + sdk.transferFile(fileUri:commandType:.otaPackage) OTA
// 5. Device Files sdk.deleteFile(key:)
//
// btState / connectedDevice / deviceInfo / version / transferProgress / error
struct WrapperTestView: View { struct WrapperTestView: View {
@EnvironmentObject var sdk: DuooomiBleSDK @ObservedObject var viewModel: DemoViewModel
@State private var userId = "test-user-001" @State private var userId = "test-user-001"
@State private var fileUrl = "https://cdn.bowong.cc/material/569f48a8e29f47859b3a9808be37f94c.mp4" @State private var fileUrl = "https://cdn.bowong.cc/material/569f48a8e29f47859b3a9808be37f94c.mp4"
@@ -68,9 +59,9 @@ struct WrapperTestView: View {
.navigationTitle("SDK Demo") .navigationTitle("SDK Demo")
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.onAppear { .onAppear {
CDNHelper.cdnHost = sdk.config.cdnHost CDNHelper.cdnHost = viewModel.sdk.config.cdnHost
firmwareBrand = sdk.config.firmwareIdentifier firmwareBrand = viewModel.sdk.config.firmwareIdentifier
firmwareStatus = sdk.config.firmwareStatus firmwareStatus = viewModel.sdk.config.firmwareStatus
} }
} }
@@ -78,15 +69,15 @@ struct WrapperTestView: View {
private var statusSection: some View { private var statusSection: some View {
Section("Status") { Section("Status") {
LabeledContent("BLE", value: sdk.btState.rawValue) LabeledContent("BLE", value: viewModel.btState.rawValue)
LabeledContent("Device", value: sdk.connectedDevice?.name ?? "") LabeledContent("Device", value: viewModel.connectedDevice?.name ?? "")
LabeledContent("Version", value: sdk.version.isEmpty ? "" : sdk.version) LabeledContent("Version", value: viewModel.version.isEmpty ? "" : viewModel.version)
if sdk.transferProgress > 0 { if viewModel.transferProgress > 0 {
ProgressView(value: Double(sdk.transferProgress), total: 100) { ProgressView(value: Double(viewModel.transferProgress), total: 100) {
Text("\(sdk.transferProgress)%").font(.caption) Text("\(viewModel.transferProgress)%").font(.caption)
} }
} }
if let err = sdk.error { if let err = viewModel.error {
Text(err).foregroundStyle(.red).font(.caption) Text(err).foregroundStyle(.red).font(.caption)
} }
} }
@@ -96,19 +87,19 @@ struct WrapperTestView: View {
Section("Scan & Connect") { Section("Scan & Connect") {
HStack { HStack {
Button("Scan") { Button("Scan") {
sdk.scan() viewModel.sdk.scan()
log("→ scan()") log("→ scan()")
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
Button("Stop") { Button("Stop") {
sdk.stopScan() viewModel.sdk.stopScan()
log("→ stopScan()") log("→ stopScan()")
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
} }
ForEach(sdk.discoveredDevices) { device in ForEach(viewModel.discoveredDevices) { device in
HStack { HStack {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text(device.name ?? "Unknown") Text(device.name ?? "Unknown")
@@ -117,9 +108,9 @@ struct WrapperTestView: View {
Spacer() Spacer()
Text("\(device.rssi) dBm").font(.caption).foregroundStyle(.secondary) Text("\(device.rssi) dBm").font(.caption).foregroundStyle(.secondary)
if sdk.connectedDevice?.id == device.id { if viewModel.connectedDevice?.id == device.id {
Button("Disconnect") { Button("Disconnect") {
Task { await runDisconnect() } runDisconnect()
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.controlSize(.small) .controlSize(.small)
@@ -128,7 +119,7 @@ struct WrapperTestView: View {
ProgressView().controlSize(.small) ProgressView().controlSize(.small)
} else { } else {
Button("Connect") { Button("Connect") {
Task { await runConnect(deviceId: device.id) } runConnect(deviceId: device.id)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.controlSize(.small) .controlSize(.small)
@@ -141,23 +132,23 @@ struct WrapperTestView: View {
private var deviceSection: some View { private var deviceSection: some View {
Section("Device") { Section("Device") {
LabeledContent("Brand", value: sdk.deviceInfo?.brand ?? "") LabeledContent("Brand", value: viewModel.deviceInfo?.brand ?? "")
LabeledContent("Size", value: sdk.deviceInfo?.size ?? "") LabeledContent("Size", value: viewModel.deviceInfo?.size ?? "")
LabeledContent("Power", value: sdk.deviceInfo.map { "\($0.powerlevel)%" } ?? "") LabeledContent("Power", value: viewModel.deviceInfo.map { "\($0.powerlevel)%" } ?? "")
LabeledContent("Storage", value: sdk.deviceInfo.map { "\($0.freespace)/\($0.allspace)" } ?? "") LabeledContent("Storage", value: viewModel.deviceInfo.map { "\($0.freespace)/\($0.allspace)" } ?? "")
HStack { HStack {
Button("getDeviceInfo") { Task { await runGetDeviceInfo() } } Button("getDeviceInfo") { runGetDeviceInfo() }
Button("getVersion") { Task { await runGetVersion() } } Button("getVersion") { runGetVersion() }
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
HStack { HStack {
TextField("userId", text: $userId) TextField("userId", text: $userId)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
Button("bind") { Task { await runBind() } } Button("bind") { runBind() }
.buttonStyle(.bordered) .buttonStyle(.bordered)
Button("unbind") { Task { await runUnbind() } } Button("unbind") { runUnbind() }
.buttonStyle(.bordered) .buttonStyle(.bordered)
} }
} }
@@ -171,10 +162,10 @@ struct WrapperTestView: View {
.disableAutocorrection(true) .disableAutocorrection(true)
Button("Transfer") { Button("Transfer") {
Task { await runTransfer() } runTransfer()
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(fileUrl.isEmpty || sdk.connectedDevice == nil || isBusy) .disabled(fileUrl.isEmpty || viewModel.connectedDevice == nil || isBusy)
} }
} }
@@ -182,7 +173,7 @@ struct WrapperTestView: View {
private var firmwareSection: some View { private var firmwareSection: some View {
Section("Firmware Update") { Section("Firmware Update") {
LabeledContent("Current Version", value: sdk.version.isEmpty ? "" : sdk.version) LabeledContent("Current Version", value: viewModel.version.isEmpty ? "" : viewModel.version)
HStack { HStack {
Text("Identifier") Text("Identifier")
@@ -200,7 +191,7 @@ struct WrapperTestView: View {
if let info = firmwareInfo { if let info = firmwareInfo {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
LabeledContent("Latest", value: info.version) LabeledContent("Latest", value: info.version)
LabeledContent("Has Update", value: sdk.hasNewerFirmware(deviceVersion: sdk.version, serverVersion: info.version) ? "Yes" : "No") LabeledContent("Has Update", value: viewModel.sdk.hasNewerFirmware(deviceVersion: viewModel.version, serverVersion: info.version) ? "Yes" : "No")
if let size = info.fileSize { if let size = info.fileSize {
let bytes = Int(size) ?? 0 let bytes = Int(size) ?? 0
LabeledContent("Size", value: ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)) LabeledContent("Size", value: ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file))
@@ -228,16 +219,16 @@ struct WrapperTestView: View {
HStack { HStack {
Button("Get Update Info") { Button("Get Update Info") {
Task { await runFetchFirmwareInfo() } runFetchFirmwareInfo()
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.disabled(firmwareLoading || firmwareBrand.isEmpty) .disabled(firmwareLoading || firmwareBrand.isEmpty)
Button("Upgrade") { Button("Upgrade") {
Task { await runUpgradeFirmware() } runUpgradeFirmware()
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(firmwareInfo?.fileUrl.isEmpty != false || sdk.connectedDevice == nil || isBusy || firmwareLoading) .disabled(firmwareInfo?.fileUrl.isEmpty != false || viewModel.connectedDevice == nil || isBusy || firmwareLoading)
} }
} }
} }
@@ -278,7 +269,7 @@ struct WrapperTestView: View {
Spacer() Spacer()
Button(role: .destructive) { Button(role: .destructive) {
Task { await runDeleteFile(rawKey) } runDeleteFile(rawKey)
} label: { } label: {
Image(systemName: "trash") Image(systemName: "trash")
} }
@@ -306,117 +297,139 @@ struct WrapperTestView: View {
if logs.count > 200 { logs.removeLast() } if logs.count > 200 { logs.removeLast() }
} }
// MARK: - Actions // MARK: - Actions (callback-based)
// run* SDK API /
/// sdk.connect(deviceId:) DiscoveredDevice private func runConnect(deviceId: String) {
private func runConnect(deviceId: String) async {
connectingDeviceId = deviceId connectingDeviceId = deviceId
defer { connectingDeviceId = nil }
log("→ connect(\(deviceId))") log("→ connect(\(deviceId))")
do { viewModel.sdk.connect(deviceId: deviceId) { [self] result in
let device = try await sdk.connect(deviceId: deviceId) DispatchQueue.main.async {
log("connect ✓ \(device.name ?? device.id)", level: .success) self.connectingDeviceId = nil
} catch { switch result {
log("connect ✗ \(error.localizedDescription)", level: .error) case .success(let device):
self.log("connect ✓ \(device.name ?? device.id)", level: .success)
case .failure(let error):
self.log("connect ✗ \(error.localizedDescription)", level: .error)
}
}
} }
} }
/// sdk.disconnect() private func runDisconnect() {
private func runDisconnect() async {
log("→ disconnect") log("→ disconnect")
do { viewModel.sdk.disconnect { [self] result in
try await sdk.disconnect() DispatchQueue.main.async {
deviceFiles = [] switch result {
log("disconnect ✓", level: .success) case .success:
} catch { self.deviceFiles = []
log("disconnect \(error.localizedDescription)", level: .error) self.log("disconnect ", level: .success)
case .failure(let error):
self.log("disconnect ✗ \(error.localizedDescription)", level: .error)
}
}
} }
} }
/// sdk.getDeviceInfo() DeviceInfo private func runGetDeviceInfo() {
private func runGetDeviceInfo() async {
isBusy = true isBusy = true
defer { isBusy = false }
log("→ getDeviceInfo") log("→ getDeviceInfo")
do { viewModel.sdk.getDeviceInfo { [self] result in
let info = try await sdk.getDeviceInfo() DispatchQueue.main.async {
log("getDeviceInfo ✓ brand=\(info.brand) power=\(info.powerlevel)%", level: .success) self.isBusy = false
} catch { switch result {
log("getDeviceInfo ✗ \(error.localizedDescription)", level: .error) case .success(let info):
self.log("getDeviceInfo ✓ brand=\(info.brand) power=\(info.powerlevel)%", level: .success)
case .failure(let error):
self.log("getDeviceInfo ✗ \(error.localizedDescription)", level: .error)
}
}
} }
} }
/// sdk.getVersion() VersionInfo private func runGetVersion() {
private func runGetVersion() async {
isBusy = true isBusy = true
defer { isBusy = false }
log("→ getVersion") log("→ getVersion")
do { viewModel.sdk.getVersion { [self] result in
let info = try await sdk.getVersion() DispatchQueue.main.async {
log("getVersion ✓ \(info.version)", level: .success) self.isBusy = false
} catch { switch result {
log("getVersion ✗ \(error.localizedDescription)", level: .error) case .success(let info):
self.log("getVersion ✓ \(info.version)", level: .success)
case .failure(let error):
self.log("getVersion ✗ \(error.localizedDescription)", level: .error)
}
}
} }
} }
/// sdk.bind(userId:) BindingResponse (sn + contents) private func runBind() {
private func runBind() async {
isBusy = true isBusy = true
defer { isBusy = false }
log("→ bind(\(userId))") log("→ bind(\(userId))")
do { viewModel.sdk.bind(userId: userId) { [self] result in
let result = try await sdk.bind(userId: userId) DispatchQueue.main.async {
log("bind ✓ sn=\(result.sn) contents=\(result.contents.count)", level: .success) self.isBusy = false
deviceFiles = result.contents switch result {
} catch { case .success(let resp):
log("bind \(error.localizedDescription)", level: .error) self.log("bind ✓ sn=\(resp.sn) contents=\(resp.contents.count)", level: .success)
self.deviceFiles = resp.contents
case .failure(let error):
self.log("bind ✗ \(error.localizedDescription)", level: .error)
}
}
} }
} }
/// sdk.unbind(userId:) UnbindResponse private func runUnbind() {
private func runUnbind() async {
isBusy = true isBusy = true
defer { isBusy = false }
log("→ unbind(\(userId))") log("→ unbind(\(userId))")
do { viewModel.sdk.unbind(userId: userId) { [self] result in
_ = try await sdk.unbind(userId: userId) DispatchQueue.main.async {
deviceFiles = [] self.isBusy = false
log("unbind ✓", level: .success) switch result {
} catch { case .success:
log("unbind ✗ \(error.localizedDescription)", level: .error) self.deviceFiles = []
self.log("unbind ✓", level: .success)
case .failure(let error):
self.log("unbind ✗ \(error.localizedDescription)", level: .error)
}
}
} }
} }
/// sdk.transferMedia(fileUrl:) ANI + prepare + transfer private func runTransfer() {
private func runTransfer() async {
isBusy = true isBusy = true
defer { isBusy = false }
log("→ transferMedia(\(fileUrl))") log("→ transferMedia(\(fileUrl))")
do { viewModel.sdk.transferMedia(fileUrl: fileUrl) { [self] result in
try await sdk.transferMedia(fileUrl: fileUrl) DispatchQueue.main.async {
log("transfer ✓", level: .success) self.isBusy = false
} catch { switch result {
log("transfer ✗ \(error.localizedDescription)", level: .error) case .success:
self.log("transfer ✓", level: .success)
case .failure(let error):
self.log("transfer ✗ \(error.localizedDescription)", level: .error)
}
}
} }
} }
/// sdk.deleteFile(key:) DeleteFileResponse private func runDeleteFile(_ rawKey: String) {
private func runDeleteFile(_ rawKey: String) async {
isBusy = true isBusy = true
defer { isBusy = false }
log("→ deleteFile(\(rawKey))") log("→ deleteFile(\(rawKey))")
do { viewModel.sdk.deleteFile(key: rawKey) { [self] result in
_ = try await sdk.deleteFile(key: rawKey) DispatchQueue.main.async {
log("deleteFile ✓", level: .success) self.isBusy = false
deviceFiles.removeAll { $0 == rawKey } switch result {
} catch { case .success:
log("deleteFile \(error.localizedDescription)", level: .error) self.log("deleteFile ", level: .success)
self.deviceFiles.removeAll { $0 == rawKey }
case .failure(let error):
self.log("deleteFile ✗ \(error.localizedDescription)", level: .error)
}
}
} }
} }
/// sdk.fetchLatestFirmware(identifier:status:) FirmwareInfo? private func runFetchFirmwareInfo() {
private func runFetchFirmwareInfo() async {
let brand = firmwareBrand.trimmingCharacters(in: .whitespacesAndNewlines) let brand = firmwareBrand.trimmingCharacters(in: .whitespacesAndNewlines)
guard !brand.isEmpty else { guard !brand.isEmpty else {
firmwareError = "No identifier specified" firmwareError = "No identifier specified"
@@ -424,34 +437,41 @@ struct WrapperTestView: View {
} }
firmwareLoading = true firmwareLoading = true
firmwareError = nil firmwareError = nil
defer { firmwareLoading = false }
log("→ fetchLatestFirmware(\(brand), \(firmwareStatus))") log("→ fetchLatestFirmware(\(brand), \(firmwareStatus))")
do { viewModel.sdk.fetchLatestFirmware(identifier: brand, status: firmwareStatus) { [self] result in
let info = try await sdk.fetchLatestFirmware(identifier: brand, status: firmwareStatus) DispatchQueue.main.async {
firmwareInfo = info self.firmwareLoading = false
if let info { switch result {
let hasUpdate = sdk.hasNewerFirmware(deviceVersion: sdk.version, serverVersion: info.version) case .success(let info):
log("firmware latest: \(info.version), hasUpdate=\(hasUpdate)", level: .success) self.firmwareInfo = info
} else { if let info = info {
log("no firmware available", level: .info) let hasUpdate = self.viewModel.sdk.hasNewerFirmware(deviceVersion: self.viewModel.version, serverVersion: info.version)
self.log("firmware latest: \(info.version), hasUpdate=\(hasUpdate)", level: .success)
} else {
self.log("no firmware available", level: .info)
}
case .failure(let error):
self.firmwareError = error.localizedDescription
self.log("firmware info ✗ \(error.localizedDescription)", level: .error)
}
} }
} catch {
firmwareError = error.localizedDescription
log("firmware info ✗ \(error.localizedDescription)", level: .error)
} }
} }
/// sdk.upgradeFirmware(fileUrl:) private func runUpgradeFirmware() {
private func runUpgradeFirmware() async {
guard let info = firmwareInfo, !info.fileUrl.isEmpty else { return } guard let info = firmwareInfo, !info.fileUrl.isEmpty else { return }
isBusy = true isBusy = true
defer { isBusy = false }
log("→ upgradeFirmware \(info.version)") log("→ upgradeFirmware \(info.version)")
do { viewModel.sdk.upgradeFirmware(fileUrl: info.fileUrl) { [self] result in
try await sdk.upgradeFirmware(fileUrl: info.fileUrl) DispatchQueue.main.async {
log("OTA ✓", level: .success) self.isBusy = false
} catch { switch result {
log("OTA ✗ \(error.localizedDescription)", level: .error) case .success:
self.log("OTA ✓", level: .success)
case .failure(let error):
self.log("OTA ✗ \(error.localizedDescription)", level: .error)
}
}
} }
} }
} }

File diff suppressed because it is too large Load Diff