diff --git a/.gitignore b/.gitignore index cad9091..11e08c0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ xcuserdata/ *.xcuserstate .claude - +.omc/ # Build build/ DerivedData/ diff --git a/README.md b/README.md index 27ed519..98996a6 100644 --- a/README.md +++ b/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 // Package.swift dependencies: [ .package(path: "../duooomi-ios-sdk") ] - -// 或远程 -dependencies: [ - .package(url: "https://github.com/xxx/duooomi-ios-sdk.git", from: "1.0.0") -] ``` -### 使用示例 +## 初始化 ```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 -struct MyApp: App { - @StateObject private var sdk = DuooomiBleSDK() +## API - 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() - print("Device: \(info.brand)") - } - } - } - } - } +### 设备命令 + +| 方法 | 返回 | 说明 | +|------|------|------| +| `getDeviceInfo()` | `DeviceInfo` | 品牌、容量、电量、型号 | +| `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 NSBluetoothAlwaysUsageDescription This app needs Bluetooth to communicate with your device ``` ---- - ## 技术规格 -- **平台**: iOS 16.0+ -- **语言**: Swift 5.9+ -- **依赖**: 零 (仅 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 (系统框架) | +- iOS 16.0+ / Swift 5.9+ / 零依赖 +- 公开 API @MainActor,BLE 操作专用串行队列 diff --git a/Sources/DuooomiBleSDK/DuooomiBleConfig.swift b/Sources/DuooomiBleSDK/DuooomiBleConfig.swift new file mode 100644 index 0000000..babd9ea --- /dev/null +++ b/Sources/DuooomiBleSDK/DuooomiBleConfig.swift @@ -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 + } +} diff --git a/Sources/DuooomiBleSDK/DuooomiBleSDK.swift b/Sources/DuooomiBleSDK/DuooomiBleSDK.swift index 172dc5b..fb85909 100644 --- a/Sources/DuooomiBleSDK/DuooomiBleSDK.swift +++ b/Sources/DuooomiBleSDK/DuooomiBleSDK.swift @@ -20,12 +20,18 @@ public final class DuooomiBleSDK: ObservableObject { @Published public private(set) var error: String? = nil @Published public private(set) var discoveredDevices: [DiscoveredDevice] = [] + // MARK: - Configuration + + public let config: DuooomiBleConfig + // MARK: - Internal Services private let bleClient: BleClient private let protocolService: BleProtocolService private let deviceInfoService: DeviceInfoService private let fileTransferService: FileTransferService + private let aniConverter: AniConverter + private let firmwareService: FirmwareService // MARK: - Request-Response @@ -41,13 +47,16 @@ public final class DuooomiBleSDK: ObservableObject { // MARK: - Init - public init() { + public init(config: DuooomiBleConfig) { + self.config = config bleClient = BleClient() protocolService = BleProtocolService(client: bleClient) deviceInfoService = DeviceInfoService(protocolService: protocolService) fileTransferService = FileTransferService(protocolService: protocolService) + aniConverter = AniConverter(config: config) + firmwareService = FirmwareService(config: config) - BleLog.i("SDK initialized", "SDK") + BleLog.i("SDK initialized (apiHost=\(config.apiHost), firmware=\(config.firmwareIdentifier)/\(config.firmwareStatus))", "SDK") setupDisconnectHandler() } @@ -327,6 +336,97 @@ public final class DuooomiBleSDK: ObservableObject { 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 private func sendAndWait( diff --git a/demo/Sources/AniConverter.swift b/Sources/DuooomiBleSDK/Services/AniConverter.swift similarity index 51% rename from demo/Sources/AniConverter.swift rename to Sources/DuooomiBleSDK/Services/AniConverter.swift index f07a3d7..542a246 100644 --- a/demo/Sources/AniConverter.swift +++ b/Sources/DuooomiBleSDK/Services/AniConverter.swift @@ -1,54 +1,48 @@ import Foundation -/// ANI 转换接口错误。 struct AniConverterError: LocalizedError { let message: String var errorDescription: String? { message } } -/// 将源视频转换为设备可用的 ANI 文件。 -/// -/// 接口定义详见 duooomi-ble-sdk/CLAUDE.md 的"外部依赖 > ANI 转换 API"。 -/// 除 `videoUrl` 外,`width / height / fps` 全部固定写死,不得暴露为可调参数。 -enum AniConverter { - private static let endpoint = URL(string: "https://api.mixvideo.bowong.cc/api/auth/loomart/file/convert-to-ani")! +/// 将源视频/图片转换为设备可用的 ANI 文件。 +final class AniConverter { + private let config: DuooomiBleConfig - /// 专用 session:延长超时,避免 ANI 转换耗时长被空闲连接关闭(-1005)。 - private static let session: URLSession = { - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 120 - config.timeoutIntervalForResource = 180 - config.waitsForConnectivity = true - return URLSession(configuration: config) + private var endpoint: URL { + config.apiHost.appendingPathComponent("api/auth/loomart/file/convert-to-ani") + } + + private let session: URLSession = { + let cfg = URLSessionConfiguration.default + cfg.timeoutIntervalForRequest = 120 + cfg.timeoutIntervalForResource = 180 + cfg.waitsForConnectivity = true + return URLSession(configuration: cfg) }() + init(config: DuooomiBleConfig) { + self.config = config + } + /// 调用 ANI 转换接口,返回 ani 文件的 CDN URL。 - /// - Parameter videoUrl: 源视频的公网 URL。 - /// - Returns: 转换后 ani 文件的 CDN URL。 - /// - Throws: 网络异常、服务返回失败或响应解析失败等错误。 - /// - Note: 遇到 -1005(连接丢失)时自动重试一次。 - static func convert(videoUrl: String) async throws -> String { + func convert(fileUrl: String) async throws -> String { do { - return try await performConvert(videoUrl: videoUrl) + return try await performConvert(fileUrl: fileUrl) } catch let error as NSError where error.code == NSURLErrorNetworkConnectionLost { - // -1005 重试一次 - return try await performConvert(videoUrl: videoUrl) + return try await performConvert(fileUrl: fileUrl) } } - /// 实际执行转换请求。 - /// - Parameter videoUrl: 源视频 URL。 - /// - Returns: ani 文件 URL。 - /// - Throws: 各类网络或业务错误。 - private static func performConvert(videoUrl: String) async throws -> String { + private func performConvert(fileUrl: String) async throws -> String { var request = URLRequest(url: endpoint) request.httpMethod = "POST" 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 let body: [String: Any] = [ - "videoUrl": videoUrl, + "videoUrl": fileUrl, "width": 360, "height": 360, "fps": "24", diff --git a/Sources/DuooomiBleSDK/Services/FirmwareService.swift b/Sources/DuooomiBleSDK/Services/FirmwareService.swift new file mode 100644 index 0000000..131bba6 --- /dev/null +++ b/Sources/DuooomiBleSDK/Services/FirmwareService.swift @@ -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 + } +} diff --git a/demo/Sources/DemoApp.swift b/demo/Sources/DemoApp.swift index cad7cc5..a6cde0e 100644 --- a/demo/Sources/DemoApp.swift +++ b/demo/Sources/DemoApp.swift @@ -1,9 +1,13 @@ import SwiftUI import DuooomiBleSDK +/// Demo 入口:初始化 SDK 并注入到视图树。 +/// 只需传 apiKey,其余配置(apiHost/cdnHost/firmwareIdentifier/firmwareStatus)使用默认值。 @main struct DemoApp: App { - @StateObject private var sdk = DuooomiBleSDK() + @StateObject private var sdk = DuooomiBleSDK(config: .init( + apiKey: "" + )) var body: some Scene { WindowGroup { diff --git a/demo/Sources/FirmwareUpdateService.swift b/demo/Sources/FirmwareUpdateService.swift deleted file mode 100644 index cdc49b2..0000000 --- a/demo/Sources/FirmwareUpdateService.swift +++ /dev/null @@ -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) - } - } -} diff --git a/demo/Sources/NetworkConstants.swift b/demo/Sources/NetworkConstants.swift deleted file mode 100644 index cb3010d..0000000 --- a/demo/Sources/NetworkConstants.swift +++ /dev/null @@ -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" -} - diff --git a/demo/Sources/WrapperTestView.swift b/demo/Sources/WrapperTestView.swift index e17bac5..f61e6cb 100644 --- a/demo/Sources/WrapperTestView.swift +++ b/demo/Sources/WrapperTestView.swift @@ -4,14 +4,14 @@ import DuooomiBleSDK // MARK: - CDN Helper private enum CDNHelper { - static let host = "https://cdn.bowong.cc/" + static var cdnHost = "https://cdn.bowong.cc/" static func ensureFullUrl(_ keyOrUrl: String) -> String { if keyOrUrl.hasPrefix("http://") || keyOrUrl.hasPrefix("https://") { return keyOrUrl } let key = keyOrUrl.hasPrefix("/") ? String(keyOrUrl.dropFirst()) : keyOrUrl - return host + key + return cdnHost + key } static func isImage(_ url: String) -> Bool { @@ -21,17 +21,28 @@ private enum CDNHelper { } // 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 { @EnvironmentObject var sdk: DuooomiBleSDK @State private var userId = "test-user-001" - @State private var videoUrl = "https://cdn.bowong.cc/material/569f48a8e29f47859b3a9808be37f94c.mp4" - @State private var aniUrlDirect = "https://cdn.bowong.cc/material/c59affcbe0654bf8b69aaadc1900ac3f.ani" - // Firmware update + @State private var fileUrl = "https://cdn.bowong.cc/material/569f48a8e29f47859b3a9808be37f94c.mp4" + // Firmware + @State private var firmwareBrand = "duomi" + @State private var firmwareStatus = "DRAFT" @State private var firmwareInfo: FirmwareInfo? = nil @State private var firmwareLoading = false @State private var firmwareError: String? = nil + // State @State private var isBusy = false @State private var connectingDeviceId: String? @State private var deviceFiles: [String] = [] @@ -39,7 +50,6 @@ struct WrapperTestView: View { struct LogLine: Identifiable { let id = UUID() - let time = Date() let level: Level let message: String enum Level { case info, success, error } @@ -50,19 +60,17 @@ struct WrapperTestView: View { statusSection scanSection deviceSection - fileSection - directAniSection + transferSection firmwareSection deviceFilesSection logSection } - .navigationTitle("Native SDK Test") + .navigationTitle("SDK Demo") .scrollDismissesKeyboard(.interactively) - .onTapGesture { - UIApplication.shared.sendAction( - #selector(UIResponder.resignFirstResponder), - to: nil, from: nil, for: nil - ) + .onAppear { + CDNHelper.cdnHost = sdk.config.cdnHost + firmwareBrand = sdk.config.firmwareIdentifier + firmwareStatus = sdk.config.firmwareStatus } } @@ -70,9 +78,14 @@ struct WrapperTestView: View { private var statusSection: some View { Section("Status") { - LabeledContent("btState", value: sdk.btState.rawValue) - LabeledContent("isActivated", value: sdk.isActivated ? "YES" : "NO") - LabeledContent("busy", value: isBusy ? "YES" : "no") + LabeledContent("BLE", value: sdk.btState.rawValue) + LabeledContent("Device", value: sdk.connectedDevice?.name ?? "—") + 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 { Text(err).foregroundStyle(.red).font(.caption) } @@ -80,7 +93,7 @@ struct WrapperTestView: View { } private var scanSection: some View { - Section("Scan") { + Section("Scan & Connect") { HStack { Button("Scan") { sdk.scan() @@ -102,9 +115,7 @@ struct WrapperTestView: View { Text(device.id).font(.caption2).foregroundStyle(.secondary) } Spacer() - Text("\(device.rssi) dBm") - .font(.caption) - .foregroundStyle(.secondary) + Text("\(device.rssi) dBm").font(.caption).foregroundStyle(.secondary) if sdk.connectedDevice?.id == device.id { Button("Disconnect") { @@ -130,19 +141,10 @@ struct WrapperTestView: View { private var deviceSection: some View { Section("Device") { - LabeledContent("Name", value: sdk.connectedDevice?.name ?? "—") - LabeledContent("ID", value: sdk.connectedDevice?.id ?? "—") LabeledContent("Brand", value: sdk.deviceInfo?.brand ?? "—") LabeledContent("Size", value: sdk.deviceInfo?.size ?? "—") - LabeledContent( - "Power", - value: sdk.deviceInfo.map { "\($0.powerlevel)%" } ?? "—" - ) - LabeledContent( - "Storage", - value: sdk.deviceInfo.map { "\($0.freespace)/\($0.allspace)" } ?? "—" - ) - LabeledContent("Version", value: sdk.version.isEmpty ? "—" : sdk.version) + LabeledContent("Power", value: sdk.deviceInfo.map { "\($0.powerlevel)%" } ?? "—") + LabeledContent("Storage", value: sdk.deviceInfo.map { "\($0.freespace)/\($0.allspace)" } ?? "—") HStack { Button("getDeviceInfo") { Task { await runGetDeviceInfo() } } @@ -161,54 +163,54 @@ struct WrapperTestView: View { } } - private var fileSection: some View { + private var transferSection: some View { Section("Transfer") { - if sdk.transferProgress > 0 { - ProgressView(value: Double(sdk.transferProgress), total: 100) { - Text("Transfer: \(sdk.transferProgress)%").font(.caption) - } - } - - TextField("videoUrl (source)", text: $videoUrl) + TextField("File URL (mp4/jpg/png/ani)", text: $fileUrl) .textFieldStyle(.roundedBorder) .autocapitalization(.none) .disableAutocorrection(true) - Button("Convert → PrepareTransfer → Transfer") { - Task { await runConvertAndTransfer() } + Button("Transfer") { + Task { await runTransfer() } } .buttonStyle(.borderedProminent) - .disabled(videoUrl.isEmpty || sdk.connectedDevice == nil) + .disabled(fileUrl.isEmpty || sdk.connectedDevice == nil || isBusy) } } - private var directAniSection: some View { - 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 static let statusOptions = ["DRAFT", "PUBLISHED"] private var firmwareSection: some View { Section("Firmware Update") { 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 { VStack(alignment: .leading, spacing: 6) { - Text("Latest: \(info.version)") - Text(info.fileUrl).font(.caption2).foregroundStyle(.secondary) - if let notes = info.notes, !notes.isEmpty { - Text(notes).font(.caption) + LabeledContent("Latest", value: info.version) + if let size = info.fileSize { + let bytes = Int(size) ?? 0 + 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) } + if firmwareLoading { + HStack { + ProgressView().controlSize(.small) + Text("Fetching...").font(.caption).foregroundStyle(.secondary) + } + } + HStack { - Button(firmwareLoading ? "Fetching..." : "Get Update Info") { + Button("Get Update Info") { Task { await runFetchFirmwareInfo() } } .buttonStyle(.bordered) - .disabled(firmwareLoading || sdk.connectedDevice == nil || sdk.deviceInfo?.brand == nil) + .disabled(firmwareLoading || firmwareBrand.isEmpty) - Button("Upgrade Firmware") { + Button("Upgrade") { Task { await runUpgradeFirmware() } } .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) } - Text(URL(string: fileUrl)?.lastPathComponent ?? fileUrl) - .font(.caption) - .lineLimit(1) + Text(rawKey) + .font(.caption2) + .lineLimit(2) Spacer() @@ -284,19 +293,11 @@ struct WrapperTestView: View { ForEach(logs.prefix(80)) { line in Text(line.message) .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 private func log(_ message: String, level: LogLine.Level = .info) { @@ -304,8 +305,10 @@ struct WrapperTestView: View { if logs.count > 200 { logs.removeLast() } } - // MARK: - Async runners + // MARK: - Actions + // 每个 run* 方法对应一个 SDK API 调用,展示输入/输出和错误处理 + /// sdk.connect(deviceId:) → DiscoveredDevice private func runConnect(deviceId: String) async { connectingDeviceId = deviceId defer { connectingDeviceId = nil } @@ -318,6 +321,7 @@ struct WrapperTestView: View { } } + /// sdk.disconnect() private func runDisconnect() async { log("→ disconnect") do { @@ -329,21 +333,20 @@ struct WrapperTestView: View { } } + /// sdk.getDeviceInfo() → DeviceInfo private func runGetDeviceInfo() async { isBusy = true defer { isBusy = false } log("→ getDeviceInfo") do { let info = try await sdk.getDeviceInfo() - log( - "getDeviceInfo ✓ brand=\(info.brand) size=\(info.size) battery=\(info.powerlevel)", - level: .success - ) + log("getDeviceInfo ✓ brand=\(info.brand) power=\(info.powerlevel)%", level: .success) } catch { log("getDeviceInfo ✗ \(error.localizedDescription)", level: .error) } } + /// sdk.getVersion() → VersionInfo private func runGetVersion() async { isBusy = true defer { isBusy = false } @@ -356,6 +359,7 @@ struct WrapperTestView: View { } } + /// sdk.bind(userId:) → BindingResponse (sn + contents)。绑定后才能查看设备文件 private func runBind() async { isBusy = true defer { isBusy = false } @@ -369,6 +373,7 @@ struct WrapperTestView: View { } } + /// sdk.unbind(userId:) → UnbindResponse。解绑后该账号的设备文件将被删除 private func runUnbind() async { isBusy = true 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 defer { isBusy = false } + log("→ transferMedia(\(fileUrl))") do { - // 1. Convert - log("→ AniConverter.convert") - 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) + try await sdk.transferMedia(fileUrl: fileUrl) + log("transfer ✓", level: .success) } catch { - log("transfer failed: \(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) + log("transfer ✗ \(error.localizedDescription)", level: .error) } } + /// sdk.deleteFile(key:) → DeleteFileResponse private func runDeleteFile(_ rawKey: String) async { isBusy = true defer { isBusy = false } @@ -479,48 +414,42 @@ struct WrapperTestView: View { } } + /// sdk.fetchLatestFirmware(identifier:status:) → FirmwareInfo? private func runFetchFirmwareInfo() async { - guard let brand = sdk.deviceInfo?.brand, !brand.isEmpty else { - firmwareError = "No device brand" + let brand = firmwareBrand.trimmingCharacters(in: .whitespacesAndNewlines) + guard !brand.isEmpty else { + firmwareError = "No identifier specified" return } firmwareLoading = true firmwareError = nil defer { firmwareLoading = false } - log("→ fetch firmware info for \(brand)") + log("→ fetchLatestFirmware(\(brand), \(firmwareStatus))") do { - let info = try await FirmwareUpdateService.fetchLatest(brand: brand) + let info = try await sdk.fetchLatestFirmware(identifier: brand, status: firmwareStatus) 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 { firmwareError = error.localizedDescription log("firmware info ✗ \(error.localizedDescription)", level: .error) } } + /// 固件升级第二步:sdk.upgradeFirmware(fileUrl:) private func runUpgradeFirmware() async { - guard let url = firmwareInfo?.fileUrl, !url.isEmpty else { return } + guard let info = firmwareInfo, !info.fileUrl.isEmpty else { return } isBusy = true defer { isBusy = false } - log("→ OTA transfer") + log("→ upgradeFirmware \(info.version)") do { - try await sdk.transferFile(fileUri: url, commandType: .otaPackage) + try await sdk.upgradeFirmware(fileUrl: info.fileUrl) log("OTA ✓", level: .success) } catch { 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) - } }