feat: Sdk 封装
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,7 +10,7 @@
|
|||||||
xcuserdata/
|
xcuserdata/
|
||||||
*.xcuserstate
|
*.xcuserstate
|
||||||
.claude
|
.claude
|
||||||
|
.omc/
|
||||||
# Build
|
# Build
|
||||||
build/
|
build/
|
||||||
DerivedData/
|
DerivedData/
|
||||||
|
|||||||
295
README.md
295
README.md
@@ -1,223 +1,130 @@
|
|||||||
# Duooomi BLE SDK (Native iOS)
|
# DuooomiBleSDK (iOS)
|
||||||
|
|
||||||
纯原生 Swift 蓝牙 SDK,使用 CoreBluetooth 直接操作,零第三方依赖。
|
纯原生 Swift BLE SDK,零第三方依赖。
|
||||||
|
|
||||||
## 架构设计
|
## 集成
|
||||||
|
|
||||||
```
|
|
||||||
调用方 (宿主 App)
|
|
||||||
│
|
|
||||||
└── DuooomiBleSDK (ObservableObject) ← 公开 API + 状态观察
|
|
||||||
│
|
|
||||||
├── BleClient ← CoreBluetooth 封装
|
|
||||||
│ CBCentralManager / CBPeripheral
|
|
||||||
│
|
|
||||||
├── BleProtocolService ← 协议帧收发 + 分包重组
|
|
||||||
│ ProtocolManager (编码/解码/校验)
|
|
||||||
│
|
|
||||||
├── DeviceInfoService ← 设备命令 (JSON 请求/响应)
|
|
||||||
│
|
|
||||||
└── FileTransferService ← 文件下载 + 二进制传输
|
|
||||||
```
|
|
||||||
|
|
||||||
### 目录结构
|
|
||||||
|
|
||||||
```
|
|
||||||
Sources/DuooomiBleSDK/
|
|
||||||
├── DuooomiBleSDK.swift # 公开 API facade (ObservableObject)
|
|
||||||
├── Core/
|
|
||||||
│ ├── BleClient.swift # CoreBluetooth 封装 (scan/connect/write/notify)
|
|
||||||
│ └── BleTypes.swift # ConnectionState, DiscoveredDevice, DuooomiBleError
|
|
||||||
├── Protocol/
|
|
||||||
│ ├── ProtocolConstants.swift # BLE UUID, 帧常量, CommandType 枚举
|
|
||||||
│ ├── ProtocolFrame.swift # 协议帧结构体
|
|
||||||
│ └── ProtocolManager.swift # 帧编解码、校验和、分片/重组
|
|
||||||
├── Services/
|
|
||||||
│ ├── BleProtocolService.swift # 监听通知、解析帧、分包重组、帧发送
|
|
||||||
│ ├── DeviceInfoService.swift # 设备命令发送 (JSON payload)
|
|
||||||
│ └── FileTransferService.swift # 文件下载 + 二进制传输
|
|
||||||
└── Models/
|
|
||||||
├── DeviceInfo.swift # 设备信息 (allspace/freespace/brand/...)
|
|
||||||
├── VersionInfo.swift # 固件版本
|
|
||||||
├── BindingResponse.swift # 绑定响应 (sn/success/contents)
|
|
||||||
├── UnbindResponse.swift # 解绑响应
|
|
||||||
├── DeleteFileResponse.swift # 删除文件响应
|
|
||||||
└── PrepareTransferResponse.swift # 预传输响应
|
|
||||||
```
|
|
||||||
|
|
||||||
### 协议帧格式
|
|
||||||
|
|
||||||
```
|
|
||||||
[head:1][type:1][subpageTotal:2][curPage:2][dataLen:2][data:N][checksum:1]
|
|
||||||
```
|
|
||||||
|
|
||||||
| 字段 | 大小 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| head | 1 byte | 方向标识: APP→Device=0xC7, Device→APP=0xB0 |
|
|
||||||
| type | 1 byte | 命令类型 (CommandType 枚举) |
|
|
||||||
| subpageTotal | 2 bytes | 总分包数 (0=单帧) |
|
|
||||||
| curPage | 2 bytes | 当前包序号 (从 total-1 倒数到 0) |
|
|
||||||
| dataLen | 2 bytes | 数据段长度 |
|
|
||||||
| data | N bytes | 数据内容 (JSON 或二进制) |
|
|
||||||
| checksum | 1 byte | 校验: (~字节和 + 1) & 0xFF |
|
|
||||||
|
|
||||||
### 请求-响应模式
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// 发送命令 → 注册 CheckedContinuation → 等待设备回包 → 自动解码
|
|
||||||
let info = try await sdk.getDeviceInfo() // 10s 超时
|
|
||||||
```
|
|
||||||
|
|
||||||
内部流程:
|
|
||||||
1. 注册 continuation (keyed by commandType)
|
|
||||||
2. 编码 JSON → 创建协议帧 → 写入 write characteristic
|
|
||||||
3. 设备回包 → notify → 解析帧 → 分包重组 → 匹配 continuation → resume
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 功能清单
|
|
||||||
|
|
||||||
### 扫描
|
|
||||||
|
|
||||||
| 方法 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `scan()` | 开始扫描,结果通过 `discoveredDevices` 属性观察 (500ms 批量刷新) |
|
|
||||||
| `stopScan()` | 停止扫描 |
|
|
||||||
|
|
||||||
### 连接
|
|
||||||
|
|
||||||
| 方法 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `connect(deviceId:) async throws -> DiscoveredDevice` | 连接设备 (含服务发现 + 特征发现 + notify 启用) |
|
|
||||||
| `disconnect() async throws` | 断开连接,清理所有状态 |
|
|
||||||
|
|
||||||
### 设备命令 (请求-响应,10s 超时)
|
|
||||||
|
|
||||||
| 方法 | 发送 payload | 响应类型 |
|
|
||||||
|------|-------------|---------|
|
|
||||||
| `getDeviceInfo()` | `{"type":13}` | `DeviceInfo` |
|
|
||||||
| `getVersion()` | `{"type":7}` | `VersionInfo` |
|
|
||||||
| `bind(userId:)` | `{"type":15,"userId":"..."}` | `BindingResponse` |
|
|
||||||
| `unbind(userId:)` | `{"type":18,"userId":"..."}` | `UnbindResponse` |
|
|
||||||
| `deleteFile(key:)` | `{"type":19,"key":"..."}` | `DeleteFileResponse` |
|
|
||||||
| `prepareTransfer(key:size:)` | `{"type":20,"key":"...","size":N}` | `PrepareTransferResponse` |
|
|
||||||
|
|
||||||
### 文件传输
|
|
||||||
|
|
||||||
| 方法 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `transferFile(fileUri:commandType:) async throws` | 下载文件 + 分帧传输,进度通过 `transferProgress` 观察 |
|
|
||||||
|
|
||||||
### 可观察状态
|
|
||||||
|
|
||||||
| 属性 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `btState` | `ConnectionState` | idle/scanning/connecting/connected/disconnecting/disconnected |
|
|
||||||
| `discoveredDevices` | `[DiscoveredDevice]` | 扫描发现的设备列表 |
|
|
||||||
| `connectedDevice` | `DiscoveredDevice?` | 当前连接的设备 |
|
|
||||||
| `deviceInfo` | `DeviceInfo?` | 设备信息 (空间/品牌/电量等) |
|
|
||||||
| `version` | `String` | 固件版本 |
|
|
||||||
| `isActivated` | `Bool` | 是否已绑定 |
|
|
||||||
| `transferProgress` | `Int` | 传输进度 0-100 |
|
|
||||||
| `error` | `String?` | 最近的错误信息 |
|
|
||||||
|
|
||||||
### CommandType 枚举
|
|
||||||
|
|
||||||
| Case | 值 | 用途 |
|
|
||||||
|------|-----|------|
|
|
||||||
| `.otaPackage` | 0x02 | OTA 升级包 |
|
|
||||||
| `.transferBootAnimation` | 0x03 | 开机动画 |
|
|
||||||
| `.transferAniVideo` | 0x05 | ANI 视频 (默认) |
|
|
||||||
| `.transferJpegImage` | 0x06 | JPEG 图片 |
|
|
||||||
| `.getDeviceVersion` | 0x07 | 获取固件版本 |
|
|
||||||
| `.getDeviceInfo` | 0x0D | 获取设备信息 |
|
|
||||||
| `.bindDevice` | 0x0F | 绑定设备 |
|
|
||||||
| `.unbindDevice` | 0x12 | 解绑设备 |
|
|
||||||
| `.deleteFile` | 0x13 | 删除文件 |
|
|
||||||
| `.prepareTransfer` | 0x14 | 预传输检查 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 集成方式
|
|
||||||
|
|
||||||
### Swift Package Manager (推荐)
|
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
// Package.swift
|
// Package.swift
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(path: "../duooomi-ios-sdk")
|
.package(path: "../duooomi-ios-sdk")
|
||||||
]
|
]
|
||||||
|
|
||||||
// 或远程
|
|
||||||
dependencies: [
|
|
||||||
.package(url: "https://github.com/xxx/duooomi-ios-sdk.git", from: "1.0.0")
|
|
||||||
]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 使用示例
|
## 初始化
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
import DuooomiBleSDK
|
let sdk = DuooomiBleSDK(config: .init(
|
||||||
|
apiKey: "your-api-key" // 必填
|
||||||
|
// apiHost: URL(...)!, // 默认 https://api.mixvideo.bowong.cc
|
||||||
|
// cdnHost: "https://cdn.bowong.cc/", // 默认
|
||||||
|
// firmwareIdentifier: "duomi", // 默认
|
||||||
|
// firmwareStatus: "DRAFT" // 默认
|
||||||
|
))
|
||||||
|
```
|
||||||
|
|
||||||
@main
|
## API
|
||||||
struct MyApp: App {
|
|
||||||
@StateObject private var sdk = DuooomiBleSDK()
|
|
||||||
|
|
||||||
var body: some Scene {
|
### 扫描
|
||||||
WindowGroup {
|
|
||||||
ContentView()
|
|
||||||
.environmentObject(sdk)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ContentView: View {
|
| 方法 | 返回 | 说明 |
|
||||||
@EnvironmentObject var sdk: DuooomiBleSDK
|
|------|------|------|
|
||||||
|
| `scan()` | `Void` | 开始扫描,结果通过 `discoveredDevices` 观察 |
|
||||||
|
| `stopScan()` | `Void` | 停止扫描 |
|
||||||
|
|
||||||
var body: some View {
|
### 连接
|
||||||
VStack {
|
|
||||||
Text("State: \(sdk.btState.rawValue)")
|
|
||||||
|
|
||||||
Button("Scan") { sdk.scan() }
|
| 方法 | 返回 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `connect(deviceId: String)` | `DiscoveredDevice` | 连接设备 |
|
||||||
|
| `disconnect()` | `Void` | 断开连接 |
|
||||||
|
| `getConnectedDevices()` | `[DiscoveredDevice]` | 获取系统已连接设备 |
|
||||||
|
|
||||||
ForEach(sdk.discoveredDevices) { device in
|
### 设备命令
|
||||||
Button(device.name ?? device.id) {
|
|
||||||
Task {
|
| 方法 | 返回 | 说明 |
|
||||||
try await sdk.connect(deviceId: device.id)
|
|------|------|------|
|
||||||
let info = try await sdk.getDeviceInfo()
|
| `getDeviceInfo()` | `DeviceInfo` | 品牌、容量、电量、型号 |
|
||||||
print("Device: \(info.brand)")
|
| `getVersion()` | `VersionInfo` | 固件版本 |
|
||||||
}
|
| `bind(userId: String)` | `BindingResponse` | 绑定设备,返回 sn + 文件列表。**绑定后才能查看设备文件** |
|
||||||
}
|
| `unbind(userId: String)` | `UnbindResponse` | 解绑设备。**解绑后该账号的设备文件将被删除** |
|
||||||
}
|
| `deleteFile(key: String)` | `DeleteFileResponse` | 删除设备上的单个文件 |
|
||||||
}
|
|
||||||
}
|
### 文件传输
|
||||||
|
|
||||||
|
| 方法 | 返回 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `transferMedia(fileUrl: String)` | `Void` | 输入文件 URL(mp4/jpg/png),内部自动完成 ANI 转换 → prepare → 传输。进度通过 `transferProgress` 观察 |
|
||||||
|
|
||||||
|
### 固件升级
|
||||||
|
|
||||||
|
| 方法 | 返回 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `fetchLatestFirmware(identifier:status:)` | `FirmwareInfo?` | 查询最新固件,nil 表示无可用版本 |
|
||||||
|
| `upgradeFirmware(fileUrl: String)` | `Void` | OTA 传输固件包到设备 |
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// 1. 查询最新固件
|
||||||
|
let info = try await sdk.fetchLatestFirmware()
|
||||||
|
// 2. OTA 升级
|
||||||
|
if let info {
|
||||||
|
try await sdk.upgradeFirmware(fileUrl: info.fileUrl)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Info.plist 权限
|
### 可观察状态
|
||||||
|
|
||||||
|
| 属性 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `btState` | `ConnectionState` | idle / scanning / connecting / connected / disconnecting / disconnected |
|
||||||
|
| `discoveredDevices` | `[DiscoveredDevice]` | 扫描到的设备列表 |
|
||||||
|
| `connectedDevice` | `DiscoveredDevice?` | 当前连接设备 |
|
||||||
|
| `deviceInfo` | `DeviceInfo?` | 设备信息 |
|
||||||
|
| `version` | `String` | 固件版本 |
|
||||||
|
| `isActivated` | `Bool` | 是否已绑定 |
|
||||||
|
| `transferProgress` | `Int` | 传输进度 0-100 |
|
||||||
|
| `error` | `String?` | 最近错误 |
|
||||||
|
|
||||||
|
### 数据类型
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// FirmwareInfo — fetchLatestFirmware 返回
|
||||||
|
public struct FirmwareInfo {
|
||||||
|
let version: String // 版本号
|
||||||
|
let fileUrl: String // 固件下载地址
|
||||||
|
let description: String? // 描述
|
||||||
|
let fileSize: String? // 文件大小(字节字符串)
|
||||||
|
let fileMd5: String? // MD5 校验
|
||||||
|
let identifier: String? // 设备标识
|
||||||
|
let status: String? // DRAFT / PUBLISHED
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceInfo — getDeviceInfo 返回
|
||||||
|
public struct DeviceInfo {
|
||||||
|
let name: String // 设备名
|
||||||
|
let brand: String // 品牌
|
||||||
|
let size: String // 屏幕尺寸
|
||||||
|
let powerlevel: Int // 电量百分比
|
||||||
|
let allspace: Int // 总存储(字节)
|
||||||
|
let freespace: Int // 可用存储(字节)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindingResponse — bind 返回
|
||||||
|
public struct BindingResponse {
|
||||||
|
let success: Int // 1=成功
|
||||||
|
let sn: String // 设备序列号
|
||||||
|
let contents: [String] // 设备文件 key 列表
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Info.plist
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
<string>This app needs Bluetooth to communicate with your device</string>
|
<string>This app needs Bluetooth to communicate with your device</string>
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 技术规格
|
## 技术规格
|
||||||
|
|
||||||
- **平台**: iOS 16.0+
|
- iOS 16.0+ / Swift 5.9+ / 零依赖
|
||||||
- **语言**: Swift 5.9+
|
- 公开 API @MainActor,BLE 操作专用串行队列
|
||||||
- **依赖**: 零 (仅 CoreBluetooth 系统框架)
|
|
||||||
- **分发**: SPM / XCFramework
|
|
||||||
- **线程模型**: 公开 API 为 @MainActor,BLE 操作在专用串行队列
|
|
||||||
|
|
||||||
## 与旧 Brownfield SDK 对比
|
|
||||||
|
|
||||||
| 维度 | Brownfield | 原生 SDK |
|
|
||||||
|------|-----------|---------|
|
|
||||||
| 体积 | ~75MB (含 Hermes + RN) | ~几百KB |
|
|
||||||
| 构建 | 7 个 patch + prebuild + pod | `swift build` |
|
|
||||||
| 类型安全 | `[String: Any]` 字典 | 强类型 struct/enum |
|
|
||||||
| API | 回调 + 消息传递 | `async throws` |
|
|
||||||
| 调试 | JS + Native 两层 | 纯 Xcode 断点 |
|
|
||||||
| 依赖 | React Native, Hermes, Expo | CoreBluetooth (系统框架) |
|
|
||||||
|
|||||||
29
Sources/DuooomiBleSDK/DuooomiBleConfig.swift
Normal file
29
Sources/DuooomiBleSDK/DuooomiBleConfig.swift
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// SDK 全局配置,在初始化时一次性传入。
|
||||||
|
public struct DuooomiBleConfig: Sendable {
|
||||||
|
/// API 服务地址
|
||||||
|
public let apiHost: URL
|
||||||
|
/// API 鉴权 key
|
||||||
|
public let apiKey: String
|
||||||
|
/// CDN 资源地址(末尾带 `/`)
|
||||||
|
public let cdnHost: String
|
||||||
|
/// 固件查询标识,如 "duomi"
|
||||||
|
public let firmwareIdentifier: String
|
||||||
|
/// 固件查询状态,如 "DRAFT" / "PUBLISHED"
|
||||||
|
public let firmwareStatus: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
apiHost: URL = URL(string: "https://api.mixvideo.bowong.cc")!,
|
||||||
|
apiKey: String,
|
||||||
|
cdnHost: String = "https://cdn.bowong.cc/",
|
||||||
|
firmwareIdentifier: String = "duomi",
|
||||||
|
firmwareStatus: String = "DRAFT"
|
||||||
|
) {
|
||||||
|
self.apiHost = apiHost
|
||||||
|
self.apiKey = apiKey
|
||||||
|
self.cdnHost = cdnHost.hasSuffix("/") ? cdnHost : cdnHost + "/"
|
||||||
|
self.firmwareIdentifier = firmwareIdentifier
|
||||||
|
self.firmwareStatus = firmwareStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,12 +20,18 @@ public final class DuooomiBleSDK: ObservableObject {
|
|||||||
@Published public private(set) var error: String? = nil
|
@Published public private(set) var error: String? = nil
|
||||||
@Published public private(set) var discoveredDevices: [DiscoveredDevice] = []
|
@Published public private(set) var discoveredDevices: [DiscoveredDevice] = []
|
||||||
|
|
||||||
|
// MARK: - Configuration
|
||||||
|
|
||||||
|
public let config: DuooomiBleConfig
|
||||||
|
|
||||||
// MARK: - Internal Services
|
// MARK: - Internal Services
|
||||||
|
|
||||||
private let bleClient: BleClient
|
private let bleClient: BleClient
|
||||||
private let protocolService: BleProtocolService
|
private let protocolService: BleProtocolService
|
||||||
private let deviceInfoService: DeviceInfoService
|
private let deviceInfoService: DeviceInfoService
|
||||||
private let fileTransferService: FileTransferService
|
private let fileTransferService: FileTransferService
|
||||||
|
private let aniConverter: AniConverter
|
||||||
|
private let firmwareService: FirmwareService
|
||||||
|
|
||||||
// MARK: - Request-Response
|
// MARK: - Request-Response
|
||||||
|
|
||||||
@@ -41,13 +47,16 @@ public final class DuooomiBleSDK: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Init
|
// MARK: - Init
|
||||||
|
|
||||||
public init() {
|
public init(config: DuooomiBleConfig) {
|
||||||
|
self.config = config
|
||||||
bleClient = BleClient()
|
bleClient = BleClient()
|
||||||
protocolService = BleProtocolService(client: bleClient)
|
protocolService = BleProtocolService(client: bleClient)
|
||||||
deviceInfoService = DeviceInfoService(protocolService: protocolService)
|
deviceInfoService = DeviceInfoService(protocolService: protocolService)
|
||||||
fileTransferService = FileTransferService(protocolService: protocolService)
|
fileTransferService = FileTransferService(protocolService: protocolService)
|
||||||
|
aniConverter = AniConverter(config: config)
|
||||||
|
firmwareService = FirmwareService(config: config)
|
||||||
|
|
||||||
BleLog.i("SDK initialized", "SDK")
|
BleLog.i("SDK initialized (apiHost=\(config.apiHost), firmware=\(config.firmwareIdentifier)/\(config.firmwareStatus))", "SDK")
|
||||||
setupDisconnectHandler()
|
setupDisconnectHandler()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,6 +336,97 @@ public final class DuooomiBleSDK: ObservableObject {
|
|||||||
BleLog.i("Transfer completed", "Transfer")
|
BleLog.i("Transfer completed", "Transfer")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - High-Level APIs
|
||||||
|
|
||||||
|
/// 传输媒体文件到设备(一步完成)。
|
||||||
|
///
|
||||||
|
/// 内部自动处理:视频/图片 → ANI 转换 → 获取大小 → prepareTransfer → transferFile。
|
||||||
|
/// - Parameter fileUrl: 文件的公网 URL(支持 mp4/jpg/png 等,会自动转 ANI;.ani 文件直接传输)。
|
||||||
|
/// - Throws: 转换失败、设备空间不足、传输失败等。
|
||||||
|
public func transferMedia(fileUrl: String) async throws {
|
||||||
|
try ensureConnected()
|
||||||
|
let url = fileUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !url.isEmpty else { throw DuooomiBleError.transferFailed("Empty file URL") }
|
||||||
|
|
||||||
|
let isAni = url.lowercased().hasSuffix(".ani")
|
||||||
|
let aniUrl: String
|
||||||
|
|
||||||
|
if isAni {
|
||||||
|
aniUrl = url
|
||||||
|
BleLog.i("Direct ANI transfer: \(url)", "Transfer")
|
||||||
|
} else {
|
||||||
|
BleLog.i("Converting to ANI: \(url)", "Transfer")
|
||||||
|
do {
|
||||||
|
aniUrl = try await aniConverter.convert(fileUrl: url)
|
||||||
|
} catch {
|
||||||
|
throw DuooomiBleError.transferFailed("ANI conversion failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
BleLog.i("ANI ready: \(aniUrl)", "Transfer")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取文件大小
|
||||||
|
let aniSize = try await getRemoteFileSize(url: aniUrl)
|
||||||
|
BleLog.d("ANI size: \(aniSize) bytes", "Transfer")
|
||||||
|
|
||||||
|
// prepareTransfer(key 用原始文件地址)
|
||||||
|
let key = url
|
||||||
|
_ = try await prepareTransfer(key: key, size: aniSize)
|
||||||
|
|
||||||
|
// 传输
|
||||||
|
try await transferFile(fileUri: aniUrl, commandType: .transferAniVideo)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取最新固件信息。
|
||||||
|
/// - Parameters:
|
||||||
|
/// - identifier: 设备标识,默认使用 config 中的 firmwareIdentifier。
|
||||||
|
/// - status: 固件状态,默认使用 config 中的 firmwareStatus。
|
||||||
|
/// - Returns: 固件信息,nil 表示无可用固件。
|
||||||
|
public func fetchLatestFirmware(identifier: String? = nil, status: String? = nil) async throws -> FirmwareInfo? {
|
||||||
|
return try await firmwareService.fetchLatest(identifier: identifier, status: status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 传输固件包到设备(OTA 升级)。
|
||||||
|
/// - Parameter fileUrl: 固件文件的下载地址(从 `fetchLatestFirmware()` 获取)。
|
||||||
|
/// - Throws: 未连接、下载失败、传输失败等。
|
||||||
|
public func upgradeFirmware(fileUrl: String) async throws {
|
||||||
|
try ensureConnected()
|
||||||
|
BleLog.i("OTA upgrade start: \(fileUrl)", "Firmware")
|
||||||
|
try await transferFile(fileUri: fileUrl, commandType: .otaPackage)
|
||||||
|
BleLog.i("OTA upgrade completed", "Firmware")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internal Helpers
|
||||||
|
|
||||||
|
private func getRemoteFileSize(url: String) async throws -> Int {
|
||||||
|
guard let fileUrl = URL(string: url) else {
|
||||||
|
throw DuooomiBleError.transferFailed("Invalid URL: \(url)")
|
||||||
|
}
|
||||||
|
var request = URLRequest(url: fileUrl)
|
||||||
|
request.httpMethod = "HEAD"
|
||||||
|
let (_, response) = try await URLSession.shared.data(for: request)
|
||||||
|
if let http = response as? HTTPURLResponse, http.expectedContentLength > 0 {
|
||||||
|
return Int(http.expectedContentLength)
|
||||||
|
}
|
||||||
|
// HEAD 无法获取大小,用 Content-Length 头部字段兜底
|
||||||
|
if let http = response as? HTTPURLResponse,
|
||||||
|
let lengthStr = http.value(forHTTPHeaderField: "Content-Length"),
|
||||||
|
let length = Int(lengthStr), length > 0 {
|
||||||
|
return length
|
||||||
|
}
|
||||||
|
// 最终回退:Range 请求获取大小,避免全量下载
|
||||||
|
var rangeReq = URLRequest(url: fileUrl)
|
||||||
|
rangeReq.httpMethod = "GET"
|
||||||
|
rangeReq.setValue("bytes=0-0", forHTTPHeaderField: "Range")
|
||||||
|
let (_, rangeResp) = try await URLSession.shared.data(for: rangeReq)
|
||||||
|
if let http = rangeResp as? HTTPURLResponse,
|
||||||
|
let rangeHeader = http.value(forHTTPHeaderField: "Content-Range"),
|
||||||
|
let totalStr = rangeHeader.split(separator: "/").last,
|
||||||
|
let total = Int(totalStr), total > 0 {
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
throw DuooomiBleError.transferFailed("Cannot determine file size: \(url)")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Request-Response Pattern
|
// MARK: - Request-Response Pattern
|
||||||
|
|
||||||
private func sendAndWait(
|
private func sendAndWait(
|
||||||
|
|||||||
@@ -1,54 +1,48 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// ANI 转换接口错误。
|
|
||||||
struct AniConverterError: LocalizedError {
|
struct AniConverterError: LocalizedError {
|
||||||
let message: String
|
let message: String
|
||||||
var errorDescription: String? { message }
|
var errorDescription: String? { message }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 将源视频转换为设备可用的 ANI 文件。
|
/// 将源视频/图片转换为设备可用的 ANI 文件。
|
||||||
///
|
final class AniConverter {
|
||||||
/// 接口定义详见 duooomi-ble-sdk/CLAUDE.md 的"外部依赖 > ANI 转换 API"。
|
private let config: DuooomiBleConfig
|
||||||
/// 除 `videoUrl` 外,`width / height / fps` 全部固定写死,不得暴露为可调参数。
|
|
||||||
enum AniConverter {
|
|
||||||
private static let endpoint = URL(string: "https://api.mixvideo.bowong.cc/api/auth/loomart/file/convert-to-ani")!
|
|
||||||
|
|
||||||
/// 专用 session:延长超时,避免 ANI 转换耗时长被空闲连接关闭(-1005)。
|
private var endpoint: URL {
|
||||||
private static let session: URLSession = {
|
config.apiHost.appendingPathComponent("api/auth/loomart/file/convert-to-ani")
|
||||||
let config = URLSessionConfiguration.default
|
}
|
||||||
config.timeoutIntervalForRequest = 120
|
|
||||||
config.timeoutIntervalForResource = 180
|
private let session: URLSession = {
|
||||||
config.waitsForConnectivity = true
|
let cfg = URLSessionConfiguration.default
|
||||||
return URLSession(configuration: config)
|
cfg.timeoutIntervalForRequest = 120
|
||||||
|
cfg.timeoutIntervalForResource = 180
|
||||||
|
cfg.waitsForConnectivity = true
|
||||||
|
return URLSession(configuration: cfg)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
init(config: DuooomiBleConfig) {
|
||||||
|
self.config = config
|
||||||
|
}
|
||||||
|
|
||||||
/// 调用 ANI 转换接口,返回 ani 文件的 CDN URL。
|
/// 调用 ANI 转换接口,返回 ani 文件的 CDN URL。
|
||||||
/// - Parameter videoUrl: 源视频的公网 URL。
|
func convert(fileUrl: String) async throws -> String {
|
||||||
/// - Returns: 转换后 ani 文件的 CDN URL。
|
|
||||||
/// - Throws: 网络异常、服务返回失败或响应解析失败等错误。
|
|
||||||
/// - Note: 遇到 -1005(连接丢失)时自动重试一次。
|
|
||||||
static func convert(videoUrl: String) async throws -> String {
|
|
||||||
do {
|
do {
|
||||||
return try await performConvert(videoUrl: videoUrl)
|
return try await performConvert(fileUrl: fileUrl)
|
||||||
} catch let error as NSError where error.code == NSURLErrorNetworkConnectionLost {
|
} catch let error as NSError where error.code == NSURLErrorNetworkConnectionLost {
|
||||||
// -1005 重试一次
|
return try await performConvert(fileUrl: fileUrl)
|
||||||
return try await performConvert(videoUrl: videoUrl)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 实际执行转换请求。
|
private func performConvert(fileUrl: String) async throws -> String {
|
||||||
/// - Parameter videoUrl: 源视频 URL。
|
|
||||||
/// - Returns: ani 文件 URL。
|
|
||||||
/// - Throws: 各类网络或业务错误。
|
|
||||||
private static func performConvert(videoUrl: String) async throws -> String {
|
|
||||||
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")
|
||||||
request.setValue(NetworkConfig.apiKey, forHTTPHeaderField: "x-api-key")
|
request.setValue(config.apiKey, forHTTPHeaderField: "x-api-key")
|
||||||
request.timeoutInterval = 120
|
request.timeoutInterval = 120
|
||||||
|
|
||||||
let body: [String: Any] = [
|
let body: [String: Any] = [
|
||||||
"videoUrl": videoUrl,
|
"videoUrl": fileUrl,
|
||||||
"width": 360,
|
"width": 360,
|
||||||
"height": 360,
|
"height": 360,
|
||||||
"fps": "24",
|
"fps": "24",
|
||||||
60
Sources/DuooomiBleSDK/Services/FirmwareService.swift
Normal file
60
Sources/DuooomiBleSDK/Services/FirmwareService.swift
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 固件信息
|
||||||
|
public struct FirmwareInfo: Codable, Equatable, Sendable {
|
||||||
|
public let version: String
|
||||||
|
public let fileUrl: String
|
||||||
|
public let description: String?
|
||||||
|
public let fileSize: String?
|
||||||
|
public let fileMd5: String?
|
||||||
|
public let identifier: String?
|
||||||
|
public let status: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FirmwareResponse: Codable {
|
||||||
|
let success: Bool
|
||||||
|
let data: FirmwareInfo?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 固件服务内部实现
|
||||||
|
final class FirmwareService {
|
||||||
|
private let config: DuooomiBleConfig
|
||||||
|
private let latestPath = "api/auth/loomart/firmware/latest-published"
|
||||||
|
|
||||||
|
init(config: DuooomiBleConfig) {
|
||||||
|
self.config = config
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchLatest(identifier: String? = nil, status: String? = nil) async throws -> FirmwareInfo? {
|
||||||
|
let id = (identifier ?? config.firmwareIdentifier).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let st = status ?? config.firmwareStatus
|
||||||
|
guard !id.isEmpty else {
|
||||||
|
throw DuooomiBleError.transferFailed("Invalid firmware identifier")
|
||||||
|
}
|
||||||
|
|
||||||
|
var comps = URLComponents(url: config.apiHost.appendingPathComponent(latestPath), resolvingAgainstBaseURL: false)!
|
||||||
|
comps.queryItems = [
|
||||||
|
URLQueryItem(name: "identifier", value: id),
|
||||||
|
URLQueryItem(name: "status", value: st)
|
||||||
|
]
|
||||||
|
guard let url = comps.url else {
|
||||||
|
throw DuooomiBleError.transferFailed("Invalid firmware URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = "GET"
|
||||||
|
req.setValue(config.apiKey, forHTTPHeaderField: "x-api-key")
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "accept")
|
||||||
|
|
||||||
|
let (data, resp) = try await URLSession.shared.data(for: req)
|
||||||
|
guard let http = resp as? HTTPURLResponse else {
|
||||||
|
throw DuooomiBleError.transferFailed("Invalid response")
|
||||||
|
}
|
||||||
|
guard (200...299).contains(http.statusCode) else {
|
||||||
|
throw DuooomiBleError.transferFailed("HTTP \(http.statusCode)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded = try JSONDecoder().decode(FirmwareResponse.self, from: data)
|
||||||
|
return decoded.data
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import DuooomiBleSDK
|
import DuooomiBleSDK
|
||||||
|
|
||||||
|
/// Demo 入口:初始化 SDK 并注入到视图树。
|
||||||
|
/// 只需传 apiKey,其余配置(apiHost/cdnHost/firmwareIdentifier/firmwareStatus)使用默认值。
|
||||||
@main
|
@main
|
||||||
struct DemoApp: App {
|
struct DemoApp: App {
|
||||||
@StateObject private var sdk = DuooomiBleSDK()
|
@StateObject private var sdk = DuooomiBleSDK(config: .init(
|
||||||
|
apiKey: ""
|
||||||
|
))
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
struct FirmwareInfo: Codable, Equatable {
|
|
||||||
let version: String
|
|
||||||
let fileUrl: String
|
|
||||||
let notes: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
enum FirmwareServiceError: LocalizedError {
|
|
||||||
case invalidBrand
|
|
||||||
case invalidResponse
|
|
||||||
case http(Int)
|
|
||||||
case network(String)
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .invalidBrand: return "Invalid brand"
|
|
||||||
case .invalidResponse: return "Invalid firmware info response"
|
|
||||||
case .http(let code): return "HTTP error: \(code)"
|
|
||||||
case .network(let msg): return msg
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum FirmwareUpdateService {
|
|
||||||
// 可根据后端实际接口调整
|
|
||||||
private static let base = NetworkConfig.apiHost
|
|
||||||
private static let latestPath = "/api/auth/firmware/latest"
|
|
||||||
|
|
||||||
/// 获取指定品牌的最新固件信息。
|
|
||||||
/// - Parameters:
|
|
||||||
/// - brand: 设备品牌,如 Bowong。
|
|
||||||
/// - status: 固件状态筛选,默认 `PUBLISHED`。
|
|
||||||
/// - Returns: 最新固件信息(版本、下载地址、备注)。
|
|
||||||
/// - Throws: 参数不合法、网络/HTTP 错误或响应解析失败。
|
|
||||||
static func fetchLatest(brand: String, status: String = "PUBLISHED") async throws -> FirmwareInfo {
|
|
||||||
guard !brand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
||||||
throw FirmwareServiceError.invalidBrand
|
|
||||||
}
|
|
||||||
var comps = URLComponents(url: base.appendingPathComponent(latestPath), resolvingAgainstBaseURL: false)!
|
|
||||||
comps.queryItems = [
|
|
||||||
URLQueryItem(name: "brand", value: brand),
|
|
||||||
URLQueryItem(name: "status", value: status)
|
|
||||||
]
|
|
||||||
guard let url = comps.url else { throw FirmwareServiceError.invalidResponse }
|
|
||||||
|
|
||||||
var req = URLRequest(url: url)
|
|
||||||
req.httpMethod = "GET"
|
|
||||||
req.setValue(NetworkConfig.apiKey, forHTTPHeaderField: "x-api-key")
|
|
||||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
|
||||||
|
|
||||||
do {
|
|
||||||
let (data, resp) = try await URLSession.shared.data(for: req)
|
|
||||||
guard let http = resp as? HTTPURLResponse else { throw FirmwareServiceError.invalidResponse }
|
|
||||||
guard (200...299).contains(http.statusCode) else {
|
|
||||||
throw FirmwareServiceError.http(http.statusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 期望返回 { version: String, fileUrl: String, notes?: String }
|
|
||||||
let info = try JSONDecoder().decode(FirmwareInfo.self, from: data)
|
|
||||||
return info
|
|
||||||
} catch let e as FirmwareServiceError {
|
|
||||||
throw e
|
|
||||||
} catch {
|
|
||||||
throw FirmwareServiceError.network(error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum NetworkConfig {
|
|
||||||
/// Shared API host for mixvideo endpoints
|
|
||||||
static let apiHost = URL(string: "https://api.mixvideo.bowong.cc")!
|
|
||||||
|
|
||||||
/// Shared API key used across ANI conversion and firmware APIs
|
|
||||||
static let apiKey = "gsUBjxphRjvpERpLiMSUFXERSsuqXvPTkzNSnoHgLqzlByabSemmMpTemyvRkRuj"
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -4,14 +4,14 @@ import DuooomiBleSDK
|
|||||||
// MARK: - CDN Helper
|
// MARK: - CDN Helper
|
||||||
|
|
||||||
private enum CDNHelper {
|
private enum CDNHelper {
|
||||||
static let host = "https://cdn.bowong.cc/"
|
static var cdnHost = "https://cdn.bowong.cc/"
|
||||||
|
|
||||||
static func ensureFullUrl(_ keyOrUrl: String) -> String {
|
static func ensureFullUrl(_ keyOrUrl: String) -> String {
|
||||||
if keyOrUrl.hasPrefix("http://") || keyOrUrl.hasPrefix("https://") {
|
if keyOrUrl.hasPrefix("http://") || keyOrUrl.hasPrefix("https://") {
|
||||||
return keyOrUrl
|
return keyOrUrl
|
||||||
}
|
}
|
||||||
let key = keyOrUrl.hasPrefix("/") ? String(keyOrUrl.dropFirst()) : keyOrUrl
|
let key = keyOrUrl.hasPrefix("/") ? String(keyOrUrl.dropFirst()) : keyOrUrl
|
||||||
return host + key
|
return cdnHost + key
|
||||||
}
|
}
|
||||||
|
|
||||||
static func isImage(_ url: String) -> Bool {
|
static func isImage(_ url: String) -> Bool {
|
||||||
@@ -21,17 +21,28 @@ 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
|
@EnvironmentObject var sdk: DuooomiBleSDK
|
||||||
|
|
||||||
@State private var userId = "test-user-001"
|
@State private var userId = "test-user-001"
|
||||||
@State private var videoUrl = "https://cdn.bowong.cc/material/569f48a8e29f47859b3a9808be37f94c.mp4"
|
@State private var fileUrl = "https://cdn.bowong.cc/material/569f48a8e29f47859b3a9808be37f94c.mp4"
|
||||||
@State private var aniUrlDirect = "https://cdn.bowong.cc/material/c59affcbe0654bf8b69aaadc1900ac3f.ani"
|
// Firmware
|
||||||
// Firmware update
|
@State private var firmwareBrand = "duomi"
|
||||||
|
@State private var firmwareStatus = "DRAFT"
|
||||||
@State private var firmwareInfo: FirmwareInfo? = nil
|
@State private var firmwareInfo: FirmwareInfo? = nil
|
||||||
@State private var firmwareLoading = false
|
@State private var firmwareLoading = false
|
||||||
@State private var firmwareError: String? = nil
|
@State private var firmwareError: String? = nil
|
||||||
|
// State
|
||||||
@State private var isBusy = false
|
@State private var isBusy = false
|
||||||
@State private var connectingDeviceId: String?
|
@State private var connectingDeviceId: String?
|
||||||
@State private var deviceFiles: [String] = []
|
@State private var deviceFiles: [String] = []
|
||||||
@@ -39,7 +50,6 @@ struct WrapperTestView: View {
|
|||||||
|
|
||||||
struct LogLine: Identifiable {
|
struct LogLine: Identifiable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
let time = Date()
|
|
||||||
let level: Level
|
let level: Level
|
||||||
let message: String
|
let message: String
|
||||||
enum Level { case info, success, error }
|
enum Level { case info, success, error }
|
||||||
@@ -50,19 +60,17 @@ struct WrapperTestView: View {
|
|||||||
statusSection
|
statusSection
|
||||||
scanSection
|
scanSection
|
||||||
deviceSection
|
deviceSection
|
||||||
fileSection
|
transferSection
|
||||||
directAniSection
|
|
||||||
firmwareSection
|
firmwareSection
|
||||||
deviceFilesSection
|
deviceFilesSection
|
||||||
logSection
|
logSection
|
||||||
}
|
}
|
||||||
.navigationTitle("Native SDK Test")
|
.navigationTitle("SDK Demo")
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.onTapGesture {
|
.onAppear {
|
||||||
UIApplication.shared.sendAction(
|
CDNHelper.cdnHost = sdk.config.cdnHost
|
||||||
#selector(UIResponder.resignFirstResponder),
|
firmwareBrand = sdk.config.firmwareIdentifier
|
||||||
to: nil, from: nil, for: nil
|
firmwareStatus = sdk.config.firmwareStatus
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,9 +78,14 @@ struct WrapperTestView: View {
|
|||||||
|
|
||||||
private var statusSection: some View {
|
private var statusSection: some View {
|
||||||
Section("Status") {
|
Section("Status") {
|
||||||
LabeledContent("btState", value: sdk.btState.rawValue)
|
LabeledContent("BLE", value: sdk.btState.rawValue)
|
||||||
LabeledContent("isActivated", value: sdk.isActivated ? "YES" : "NO")
|
LabeledContent("Device", value: sdk.connectedDevice?.name ?? "—")
|
||||||
LabeledContent("busy", value: isBusy ? "YES" : "no")
|
LabeledContent("Version", value: sdk.version.isEmpty ? "—" : sdk.version)
|
||||||
|
if sdk.transferProgress > 0 {
|
||||||
|
ProgressView(value: Double(sdk.transferProgress), total: 100) {
|
||||||
|
Text("\(sdk.transferProgress)%").font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
if let err = sdk.error {
|
if let err = sdk.error {
|
||||||
Text(err).foregroundStyle(.red).font(.caption)
|
Text(err).foregroundStyle(.red).font(.caption)
|
||||||
}
|
}
|
||||||
@@ -80,7 +93,7 @@ struct WrapperTestView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var scanSection: some View {
|
private var scanSection: some View {
|
||||||
Section("Scan") {
|
Section("Scan & Connect") {
|
||||||
HStack {
|
HStack {
|
||||||
Button("Scan") {
|
Button("Scan") {
|
||||||
sdk.scan()
|
sdk.scan()
|
||||||
@@ -102,9 +115,7 @@ struct WrapperTestView: View {
|
|||||||
Text(device.id).font(.caption2).foregroundStyle(.secondary)
|
Text(device.id).font(.caption2).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(device.rssi) dBm")
|
Text("\(device.rssi) dBm").font(.caption).foregroundStyle(.secondary)
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
if sdk.connectedDevice?.id == device.id {
|
if sdk.connectedDevice?.id == device.id {
|
||||||
Button("Disconnect") {
|
Button("Disconnect") {
|
||||||
@@ -130,19 +141,10 @@ struct WrapperTestView: View {
|
|||||||
|
|
||||||
private var deviceSection: some View {
|
private var deviceSection: some View {
|
||||||
Section("Device") {
|
Section("Device") {
|
||||||
LabeledContent("Name", value: sdk.connectedDevice?.name ?? "—")
|
|
||||||
LabeledContent("ID", value: sdk.connectedDevice?.id ?? "—")
|
|
||||||
LabeledContent("Brand", value: sdk.deviceInfo?.brand ?? "—")
|
LabeledContent("Brand", value: sdk.deviceInfo?.brand ?? "—")
|
||||||
LabeledContent("Size", value: sdk.deviceInfo?.size ?? "—")
|
LabeledContent("Size", value: sdk.deviceInfo?.size ?? "—")
|
||||||
LabeledContent(
|
LabeledContent("Power", value: sdk.deviceInfo.map { "\($0.powerlevel)%" } ?? "—")
|
||||||
"Power",
|
LabeledContent("Storage", value: sdk.deviceInfo.map { "\($0.freespace)/\($0.allspace)" } ?? "—")
|
||||||
value: sdk.deviceInfo.map { "\($0.powerlevel)%" } ?? "—"
|
|
||||||
)
|
|
||||||
LabeledContent(
|
|
||||||
"Storage",
|
|
||||||
value: sdk.deviceInfo.map { "\($0.freespace)/\($0.allspace)" } ?? "—"
|
|
||||||
)
|
|
||||||
LabeledContent("Version", value: sdk.version.isEmpty ? "—" : sdk.version)
|
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Button("getDeviceInfo") { Task { await runGetDeviceInfo() } }
|
Button("getDeviceInfo") { Task { await runGetDeviceInfo() } }
|
||||||
@@ -161,54 +163,54 @@ struct WrapperTestView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var fileSection: some View {
|
private var transferSection: some View {
|
||||||
Section("Transfer") {
|
Section("Transfer") {
|
||||||
if sdk.transferProgress > 0 {
|
TextField("File URL (mp4/jpg/png/ani)", text: $fileUrl)
|
||||||
ProgressView(value: Double(sdk.transferProgress), total: 100) {
|
|
||||||
Text("Transfer: \(sdk.transferProgress)%").font(.caption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TextField("videoUrl (source)", text: $videoUrl)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
.disableAutocorrection(true)
|
.disableAutocorrection(true)
|
||||||
|
|
||||||
Button("Convert → PrepareTransfer → Transfer") {
|
Button("Transfer") {
|
||||||
Task { await runConvertAndTransfer() }
|
Task { await runTransfer() }
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.disabled(videoUrl.isEmpty || sdk.connectedDevice == nil)
|
.disabled(fileUrl.isEmpty || sdk.connectedDevice == nil || isBusy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var directAniSection: some View {
|
private static let statusOptions = ["DRAFT", "PUBLISHED"]
|
||||||
Section("Direct ANI Transfer") {
|
|
||||||
TextField("aniUrl (https://... .ani)", text: $aniUrlDirect)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.autocapitalization(.none)
|
|
||||||
.disableAutocorrection(true)
|
|
||||||
|
|
||||||
Button("Prepare → Transfer ANI") {
|
|
||||||
Task { await runTransferAniDirect() }
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.disabled(aniUrlDirect.isEmpty || sdk.connectedDevice == nil || isBusy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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: sdk.version.isEmpty ? "—" : sdk.version)
|
||||||
LabeledContent("Brand", value: sdk.deviceInfo?.brand ?? "—")
|
|
||||||
|
HStack {
|
||||||
|
Text("Identifier")
|
||||||
|
TextField("duomi", text: $firmwareBrand)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.disableAutocorrection(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker("Status", selection: $firmwareStatus) {
|
||||||
|
ForEach(Self.statusOptions, id: \.self) { Text($0) }
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
|
||||||
if let info = firmwareInfo {
|
if let info = firmwareInfo {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Latest: \(info.version)")
|
LabeledContent("Latest", value: info.version)
|
||||||
Text(info.fileUrl).font(.caption2).foregroundStyle(.secondary)
|
if let size = info.fileSize {
|
||||||
if let notes = info.notes, !notes.isEmpty {
|
let bytes = Int(size) ?? 0
|
||||||
Text(notes).font(.caption)
|
LabeledContent("Size", value: ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file))
|
||||||
}
|
}
|
||||||
|
if let md5 = info.fileMd5, !md5.isEmpty {
|
||||||
|
LabeledContent("MD5", value: md5).font(.caption2)
|
||||||
|
}
|
||||||
|
if let desc = info.description, !desc.isEmpty {
|
||||||
|
Text(desc).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text(info.fileUrl).font(.caption2).foregroundStyle(.secondary).lineLimit(2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,18 +218,25 @@ struct WrapperTestView: View {
|
|||||||
Text(err).font(.caption).foregroundStyle(.red)
|
Text(err).font(.caption).foregroundStyle(.red)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if firmwareLoading {
|
||||||
|
HStack {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
Text("Fetching...").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Button(firmwareLoading ? "Fetching..." : "Get Update Info") {
|
Button("Get Update Info") {
|
||||||
Task { await runFetchFirmwareInfo() }
|
Task { await runFetchFirmwareInfo() }
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.disabled(firmwareLoading || sdk.connectedDevice == nil || sdk.deviceInfo?.brand == nil)
|
.disabled(firmwareLoading || firmwareBrand.isEmpty)
|
||||||
|
|
||||||
Button("Upgrade Firmware") {
|
Button("Upgrade") {
|
||||||
Task { await runUpgradeFirmware() }
|
Task { await runUpgradeFirmware() }
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.disabled(firmwareInfo?.fileUrl.isEmpty != false || sdk.connectedDevice == nil || isBusy)
|
.disabled(firmwareInfo?.fileUrl.isEmpty != false || sdk.connectedDevice == nil || isBusy || firmwareLoading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,9 +270,9 @@ struct WrapperTestView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(URL(string: fileUrl)?.lastPathComponent ?? fileUrl)
|
Text(rawKey)
|
||||||
.font(.caption)
|
.font(.caption2)
|
||||||
.lineLimit(1)
|
.lineLimit(2)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
@@ -284,19 +293,11 @@ struct WrapperTestView: View {
|
|||||||
ForEach(logs.prefix(80)) { line in
|
ForEach(logs.prefix(80)) { line in
|
||||||
Text(line.message)
|
Text(line.message)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(color(for: line.level))
|
.foregroundStyle(line.level == .error ? .red : line.level == .success ? .green : .primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func color(for level: LogLine.Level) -> Color {
|
|
||||||
switch level {
|
|
||||||
case .info: return .primary
|
|
||||||
case .success: return .green
|
|
||||||
case .error: return .red
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Logging
|
// MARK: - Logging
|
||||||
|
|
||||||
private func log(_ message: String, level: LogLine.Level = .info) {
|
private func log(_ message: String, level: LogLine.Level = .info) {
|
||||||
@@ -304,8 +305,10 @@ struct WrapperTestView: View {
|
|||||||
if logs.count > 200 { logs.removeLast() }
|
if logs.count > 200 { logs.removeLast() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Async runners
|
// MARK: - Actions
|
||||||
|
// 每个 run* 方法对应一个 SDK API 调用,展示输入/输出和错误处理
|
||||||
|
|
||||||
|
/// sdk.connect(deviceId:) → DiscoveredDevice
|
||||||
private func runConnect(deviceId: String) async {
|
private func runConnect(deviceId: String) async {
|
||||||
connectingDeviceId = deviceId
|
connectingDeviceId = deviceId
|
||||||
defer { connectingDeviceId = nil }
|
defer { connectingDeviceId = nil }
|
||||||
@@ -318,6 +321,7 @@ struct WrapperTestView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// sdk.disconnect()
|
||||||
private func runDisconnect() async {
|
private func runDisconnect() async {
|
||||||
log("→ disconnect")
|
log("→ disconnect")
|
||||||
do {
|
do {
|
||||||
@@ -329,21 +333,20 @@ struct WrapperTestView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// sdk.getDeviceInfo() → DeviceInfo
|
||||||
private func runGetDeviceInfo() async {
|
private func runGetDeviceInfo() async {
|
||||||
isBusy = true
|
isBusy = true
|
||||||
defer { isBusy = false }
|
defer { isBusy = false }
|
||||||
log("→ getDeviceInfo")
|
log("→ getDeviceInfo")
|
||||||
do {
|
do {
|
||||||
let info = try await sdk.getDeviceInfo()
|
let info = try await sdk.getDeviceInfo()
|
||||||
log(
|
log("getDeviceInfo ✓ brand=\(info.brand) power=\(info.powerlevel)%", level: .success)
|
||||||
"getDeviceInfo ✓ brand=\(info.brand) size=\(info.size) battery=\(info.powerlevel)",
|
|
||||||
level: .success
|
|
||||||
)
|
|
||||||
} catch {
|
} catch {
|
||||||
log("getDeviceInfo ✗ \(error.localizedDescription)", level: .error)
|
log("getDeviceInfo ✗ \(error.localizedDescription)", level: .error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// sdk.getVersion() → VersionInfo
|
||||||
private func runGetVersion() async {
|
private func runGetVersion() async {
|
||||||
isBusy = true
|
isBusy = true
|
||||||
defer { isBusy = false }
|
defer { isBusy = false }
|
||||||
@@ -356,6 +359,7 @@ struct WrapperTestView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// sdk.bind(userId:) → BindingResponse (sn + contents)。绑定后才能查看设备文件
|
||||||
private func runBind() async {
|
private func runBind() async {
|
||||||
isBusy = true
|
isBusy = true
|
||||||
defer { isBusy = false }
|
defer { isBusy = false }
|
||||||
@@ -369,6 +373,7 @@ struct WrapperTestView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// sdk.unbind(userId:) → UnbindResponse。解绑后该账号的设备文件将被删除
|
||||||
private func runUnbind() async {
|
private func runUnbind() async {
|
||||||
isBusy = true
|
isBusy = true
|
||||||
defer { isBusy = false }
|
defer { isBusy = false }
|
||||||
@@ -382,90 +387,20 @@ struct WrapperTestView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func runConvertAndTransfer() async {
|
/// sdk.transferMedia(fileUrl:) — 一步完成:自动转换 ANI + prepare + transfer
|
||||||
|
private func runTransfer() async {
|
||||||
isBusy = true
|
isBusy = true
|
||||||
defer { isBusy = false }
|
defer { isBusy = false }
|
||||||
|
log("→ transferMedia(\(fileUrl))")
|
||||||
do {
|
do {
|
||||||
// 1. Convert
|
try await sdk.transferMedia(fileUrl: fileUrl)
|
||||||
log("→ AniConverter.convert")
|
log("transfer ✓", level: .success)
|
||||||
let aniUrl = try await AniConverter.convert(videoUrl: videoUrl)
|
|
||||||
log("ani url: \(aniUrl)", level: .success)
|
|
||||||
|
|
||||||
// 2. HEAD request to get ani file size
|
|
||||||
log("→ HEAD \(aniUrl)")
|
|
||||||
let aniSize = try await getFileSize(url: aniUrl)
|
|
||||||
log("ani size: \(aniSize) bytes")
|
|
||||||
|
|
||||||
// 3. key = source video full URL
|
|
||||||
let key = videoUrl
|
|
||||||
log("key: \(key)")
|
|
||||||
|
|
||||||
// 4. prepareTransfer
|
|
||||||
log("→ prepareTransfer")
|
|
||||||
let prepareResult = try await sdk.prepareTransfer(key: key, size: aniSize)
|
|
||||||
log("prepareTransfer → status=\(prepareResult.status)", level: .success)
|
|
||||||
|
|
||||||
guard prepareResult.status == "ready" else {
|
|
||||||
log("device not ready: \(prepareResult.status)", level: .error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. transferFile
|
|
||||||
log("→ transferFile")
|
|
||||||
try await sdk.transferFile(fileUri: aniUrl)
|
|
||||||
log("transferFile ✓", level: .success)
|
|
||||||
} catch {
|
} catch {
|
||||||
log("transfer failed: \(error.localizedDescription)", level: .error)
|
log("transfer ✗ \(error.localizedDescription)", level: .error)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func runTransferAniDirect() async {
|
|
||||||
isBusy = true
|
|
||||||
defer { isBusy = false }
|
|
||||||
do {
|
|
||||||
let aniUrl = aniUrlDirect.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !aniUrl.isEmpty else { return }
|
|
||||||
|
|
||||||
// 1) 获取 ani 文件大小(优先 HEAD,失败则直读数据长度)
|
|
||||||
log("→ HEAD \(aniUrl)")
|
|
||||||
var aniSize: Int = 0
|
|
||||||
do {
|
|
||||||
aniSize = try await getFileSize(url: aniUrl)
|
|
||||||
} catch {
|
|
||||||
aniSize = 0
|
|
||||||
}
|
|
||||||
if aniSize <= 0, let url = URL(string: aniUrl) {
|
|
||||||
log("HEAD 无法获取大小,回退为直接下载计算")
|
|
||||||
let (data, response) = try await URLSession.shared.data(from: url)
|
|
||||||
if let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) {
|
|
||||||
aniSize = data.count
|
|
||||||
} else {
|
|
||||||
throw AniConverterError(message: "下载 ani 失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log("ani size: \(aniSize) bytes")
|
|
||||||
|
|
||||||
// 2) 使用 aniUrl 作为 key 进行 prepare
|
|
||||||
let key = aniUrl
|
|
||||||
log("key: \(key)")
|
|
||||||
log("→ prepareTransfer")
|
|
||||||
let prepareResult = try await sdk.prepareTransfer(key: key, size: aniSize)
|
|
||||||
log("prepareTransfer → status=\(prepareResult.status)", level: .success)
|
|
||||||
|
|
||||||
guard prepareResult.status == "ready" else {
|
|
||||||
log("device not ready: \(prepareResult.status)", level: .error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) 传输 ANI 文件
|
|
||||||
log("→ transferFile (ANI)")
|
|
||||||
try await sdk.transferFile(fileUri: aniUrl, commandType: .transferAniVideo)
|
|
||||||
log("transferFile ✓", level: .success)
|
|
||||||
} catch {
|
|
||||||
log("transfer failed: \(error.localizedDescription)", level: .error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// sdk.deleteFile(key:) → DeleteFileResponse
|
||||||
private func runDeleteFile(_ rawKey: String) async {
|
private func runDeleteFile(_ rawKey: String) async {
|
||||||
isBusy = true
|
isBusy = true
|
||||||
defer { isBusy = false }
|
defer { isBusy = false }
|
||||||
@@ -479,48 +414,42 @@ struct WrapperTestView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// sdk.fetchLatestFirmware(identifier:status:) → FirmwareInfo?
|
||||||
private func runFetchFirmwareInfo() async {
|
private func runFetchFirmwareInfo() async {
|
||||||
guard let brand = sdk.deviceInfo?.brand, !brand.isEmpty else {
|
let brand = firmwareBrand.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
firmwareError = "No device brand"
|
guard !brand.isEmpty else {
|
||||||
|
firmwareError = "No identifier specified"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
firmwareLoading = true
|
firmwareLoading = true
|
||||||
firmwareError = nil
|
firmwareError = nil
|
||||||
defer { firmwareLoading = false }
|
defer { firmwareLoading = false }
|
||||||
log("→ fetch firmware info for \(brand)")
|
log("→ fetchLatestFirmware(\(brand), \(firmwareStatus))")
|
||||||
do {
|
do {
|
||||||
let info = try await FirmwareUpdateService.fetchLatest(brand: brand)
|
let info = try await sdk.fetchLatestFirmware(identifier: brand, status: firmwareStatus)
|
||||||
firmwareInfo = info
|
firmwareInfo = info
|
||||||
log("firmware latest: \(info.version)", level: .success)
|
if let info {
|
||||||
|
log("firmware latest: \(info.version)", level: .success)
|
||||||
|
} else {
|
||||||
|
log("no firmware available", level: .info)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
firmwareError = error.localizedDescription
|
firmwareError = error.localizedDescription
|
||||||
log("firmware info ✗ \(error.localizedDescription)", level: .error)
|
log("firmware info ✗ \(error.localizedDescription)", level: .error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 固件升级第二步:sdk.upgradeFirmware(fileUrl:)
|
||||||
private func runUpgradeFirmware() async {
|
private func runUpgradeFirmware() async {
|
||||||
guard let url = firmwareInfo?.fileUrl, !url.isEmpty else { return }
|
guard let info = firmwareInfo, !info.fileUrl.isEmpty else { return }
|
||||||
isBusy = true
|
isBusy = true
|
||||||
defer { isBusy = false }
|
defer { isBusy = false }
|
||||||
log("→ OTA transfer")
|
log("→ upgradeFirmware \(info.version)")
|
||||||
do {
|
do {
|
||||||
try await sdk.transferFile(fileUri: url, commandType: .otaPackage)
|
try await sdk.upgradeFirmware(fileUrl: info.fileUrl)
|
||||||
log("OTA ✓", level: .success)
|
log("OTA ✓", level: .success)
|
||||||
} catch {
|
} catch {
|
||||||
log("OTA ✗ \(error.localizedDescription)", level: .error)
|
log("OTA ✗ \(error.localizedDescription)", level: .error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getFileSize(url: String) async throws -> Int {
|
|
||||||
guard let fileUrl = URL(string: url) else {
|
|
||||||
throw AniConverterError(message: "Invalid URL: \(url)")
|
|
||||||
}
|
|
||||||
var request = URLRequest(url: fileUrl)
|
|
||||||
request.httpMethod = "HEAD"
|
|
||||||
let (_, response) = try await URLSession.shared.data(for: request)
|
|
||||||
guard let http = response as? HTTPURLResponse else {
|
|
||||||
throw AniConverterError(message: "Invalid response")
|
|
||||||
}
|
|
||||||
return Int(http.expectedContentLength)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user