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:
@@ -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
|
||||||
|
|||||||
@@ -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
6
Podfile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
platform :ios, '16.0'
|
||||||
|
|
||||||
|
target 'demo' do
|
||||||
|
use_frameworks!
|
||||||
|
pod 'DuooomiBleSDK', :path => './'
|
||||||
|
end
|
||||||
16
Podfile.lock
Normal file
16
Podfile.lock
Normal 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
130
README.md
@@ -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` | 输入文件 URL(mp4/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 @MainActor,BLE 操作专用串行队列
|
- Delegate 回调模式,所有状态变化在主线程通知
|
||||||
|
- BLE 操作专用串行队列
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
34
Sources/DuooomiBleSDK/DuooomiBleSDKDelegate.swift
Normal file
34
Sources/DuooomiBleSDK/DuooomiBleSDKDelegate.swift
Normal 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?) {}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = APP→Device, 0xB0 = Device→APP)
|
/// 传输方向标识 (0xC7 = APP→Device, 0xB0 = Device→APP)
|
||||||
public let head: UInt8
|
public let head: UInt8
|
||||||
/// 命令类型,对应 CommandType
|
/// 命令类型,对应 CommandType
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,69 @@ import SwiftUI
|
|||||||
import DuooomiBleSDK
|
import DuooomiBleSDK
|
||||||
|
|
||||||
/// Demo 入口:初始化 SDK 并注入到视图树。
|
/// Demo 入口:初始化 SDK 并注入到视图树。
|
||||||
/// 只需传 apiKey,其余配置(apiHost/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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1962
docs/superpowers/plans/2026-04-16-ios12-compat-rewrite.md
Normal file
1962
docs/superpowers/plans/2026-04-16-ios12-compat-rewrite.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user