diff --git a/.gitignore b/.gitignore index 2897dcc..cad9091 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ # User-specific Xcode data xcuserdata/ *.xcuserstate +.claude # Build build/ diff --git a/DuooomiTest/Sources/ContentView.swift b/DuooomiTest/Sources/ContentView.swift deleted file mode 100644 index 258422d..0000000 --- a/DuooomiTest/Sources/ContentView.swift +++ /dev/null @@ -1,205 +0,0 @@ -import SwiftUI - -struct ContentView: View { - @EnvironmentObject var sdk: SDKManager - @State private var userId = "test-user-001" - @State private var fileKey = "" - - var body: some View { - NavigationView { - List { - statusSection - scanSection - deviceSection - fileSection - logSection - } - .navigationTitle("BLE SDK Test") - } - } - - // MARK: - Status - - private var statusSection: some View { - Section("Status") { - HStack { - Text("Connection") - Spacer() - Text(sdk.btState) - .foregroundStyle(sdk.btState == "connected" ? .green : .secondary) - .fontWeight(.medium) - } - - if let error = sdk.error { - HStack { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.red) - Text(error) - .foregroundStyle(.red) - .font(.caption) - } - } - - HStack { - Text("SDK Ready") - Spacer() - Image(systemName: sdk.isSDKReady ? "checkmark.circle.fill" : "xmark.circle") - .foregroundStyle(sdk.isSDKReady ? .green : .red) - } - } - } - - // MARK: - Scan - - private var scanSection: some View { - Section("Scan") { - HStack { - Button("Scan") { sdk.scan() } - .buttonStyle(.borderedProminent) - .disabled(sdk.btState == "scanning") - - Button("Stop") { sdk.stopScan() } - .buttonStyle(.bordered) - .disabled(sdk.btState != "scanning") - } - - if sdk.discoveredDevices.isEmpty && sdk.btState == "scanning" { - HStack { - ProgressView() - Text("Searching...") - .foregroundStyle(.secondary) - } - } - - ForEach(sdk.discoveredDevices) { device in - Button { - sdk.stopScan() - sdk.connect(deviceId: device.id) - } label: { - HStack { - VStack(alignment: .leading) { - Text(device.name) - .font(.body) - Text(device.id) - .font(.caption2) - .foregroundStyle(.secondary) - } - Spacer() - Text("\(device.rssi) dBm") - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - } - - // MARK: - Device - - private var deviceSection: some View { - Section("Device") { - if sdk.btState == "connected" { - if let device = sdk.connectedDevice { - LabeledContent("Name", value: device["name"] as? String ?? "—") - LabeledContent("ID", value: device["id"] as? String ?? "—") - } - - LabeledContent("Version", value: sdk.version.isEmpty ? "—" : sdk.version) - - if let info = sdk.deviceInfo { - LabeledContent("Brand", value: info.brand) - LabeledContent("Screen", value: info.screenSize) - LabeledContent("Battery", value: "\(info.batteryLevel)%") - LabeledContent("Storage", value: formatStorage(free: info.freeSpace, total: info.totalSpace)) - } - - HStack(spacing: 12) { - Button("Get Info") { sdk.getDeviceInfo() } - .buttonStyle(.bordered) - Button("Get Version") { sdk.getVersion() } - .buttonStyle(.bordered) - } - - HStack { - TextField("User ID", text: $userId) - .textFieldStyle(.roundedBorder) - Button("Bind") { sdk.bind(userId: userId) } - .buttonStyle(.bordered) - } - - HStack(spacing: 12) { - Button("Unbind") { sdk.unbind(userId: userId) } - .buttonStyle(.bordered) - Button("Disconnect") { sdk.disconnect() } - .buttonStyle(.bordered) - .tint(.red) - } - } else if sdk.btState == "connecting" { - HStack { - ProgressView() - Text("Connecting...") - } - } else { - Text("No device connected") - .foregroundStyle(.secondary) - } - } - } - - // MARK: - File - - private var fileSection: some View { - Section("File Transfer") { - if sdk.btState == "connected" { - if sdk.transferProgress > 0 { - VStack(alignment: .leading) { - Text("Transfer: \(sdk.transferProgress)%") - .font(.caption) - ProgressView(value: Double(sdk.transferProgress), total: 100) - } - } - - HStack { - TextField("File key to delete", text: $fileKey) - .textFieldStyle(.roundedBorder) - Button("Delete") { sdk.deleteFile(key: fileKey) } - .buttonStyle(.bordered) - .tint(.red) - .disabled(fileKey.isEmpty) - } - } else { - Text("Connect a device first") - .foregroundStyle(.secondary) - } - } - } - - // MARK: - Log - - private var logSection: some View { - Section("Event Log (\(sdk.logs.count))") { - ForEach(sdk.logs.prefix(50)) { entry in - VStack(alignment: .leading, spacing: 2) { - Text(entry.message) - .font(.caption) - Text(entry.time, style: .time) - .font(.caption2) - .foregroundStyle(.tertiary) - } - } - - if sdk.logs.isEmpty { - Text("No events yet") - .foregroundStyle(.secondary) - } - } - } - - // MARK: - Helpers - - private func formatStorage(free: Int, total: Int) -> String { - let freeMB = free / 1_048_576 - let totalMB = total / 1_048_576 - return "\(freeMB)MB / \(totalMB)MB" - } -} diff --git a/DuooomiTest/Sources/DuooomiTestApp.swift b/DuooomiTest/Sources/DuooomiTestApp.swift deleted file mode 100644 index fc242c9..0000000 --- a/DuooomiTest/Sources/DuooomiTestApp.swift +++ /dev/null @@ -1,24 +0,0 @@ -import SwiftUI -import DuooomiBleSDK - -class AppDelegate: ExpoBrownfieldAppDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil - ) -> Bool { - SDKManager.shared.initialize() - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } -} - -@main -struct DuooomiTestApp: App { - @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - - var body: some Scene { - WindowGroup { - ContentView() - .environmentObject(SDKManager.shared) - } - } -} diff --git a/DuooomiTest/Sources/SDKManager.swift b/DuooomiTest/Sources/SDKManager.swift deleted file mode 100644 index ee23790..0000000 --- a/DuooomiTest/Sources/SDKManager.swift +++ /dev/null @@ -1,229 +0,0 @@ -import UIKit -import Combine -import DuooomiBleSDK - -struct DiscoveredDevice: Identifiable { - let id: String - let name: String - let rssi: Int -} - -struct DeviceInfo { - let brand: String - let screenSize: String - let freeSpace: Int - let totalSpace: Int - let batteryLevel: Int -} - -struct LogEntry: Identifiable { - let id = UUID() - let time: Date - let message: String -} - -@MainActor -class SDKManager: ObservableObject { - static let shared = SDKManager() - - // State - @Published var btState = "idle" - @Published var connectedDevice: [String: Any]? = nil - @Published var deviceInfo: DeviceInfo? = nil - @Published var version = "" - @Published var isActivated = false - @Published var transferProgress: Int = 0 - @Published var error: String? = nil - @Published var discoveredDevices: [DiscoveredDevice] = [] - @Published var logs: [LogEntry] = [] - @Published var isSDKReady = false - - private var cancellables = Set() - private var listenerIds: [String] = [] - - private init() {} - - func initialize() { - ReactNativeHostManager.shared.initialize() - - // 加载 RN 视图启动 JS 运行时 - do { - let view = try ReactNativeHostManager.shared.loadView( - moduleName: "main", initialProps: nil, launchOptions: nil) - // 保持引用防止释放 - _rnView = view - log("SDK initialized, RN runtime started") - isSDKReady = true - } catch { - log("SDK init failed: \(error.localizedDescription)") - } - - setupSubscriptions() - setupMessageListener() - } - - private var _rnView: UIView? - - // MARK: - State Subscriptions - - private func setupSubscriptions() { - BrownfieldState.subscribe("btState") { [weak self] value in - DispatchQueue.main.async { - let state = value as? String ?? "idle" - self?.btState = state - self?.log("btState → \(state)") - } - }.store(in: &cancellables) - - BrownfieldState.subscribe("connectedDevice") { [weak self] value in - DispatchQueue.main.async { - self?.connectedDevice = value as? [String: Any] - if let device = value as? [String: Any] { - self?.log("connectedDevice → \(device["name"] ?? "unknown")") - } else { - self?.log("connectedDevice → nil") - } - } - }.store(in: &cancellables) - - BrownfieldState.subscribe("deviceInfo") { [weak self] value in - DispatchQueue.main.async { - if let dict = value as? [String: Any] { - self?.deviceInfo = DeviceInfo( - brand: dict["brand"] as? String ?? "", - screenSize: dict["screenSize"] as? String ?? "", - freeSpace: dict["freeSpace"] as? Int ?? 0, - totalSpace: dict["totalSpace"] as? Int ?? 0, - batteryLevel: dict["batteryLevel"] as? Int ?? 0 - ) - self?.log("deviceInfo updated") - } else { - self?.deviceInfo = nil - } - } - }.store(in: &cancellables) - - BrownfieldState.subscribe("version") { [weak self] value in - DispatchQueue.main.async { - self?.version = value as? String ?? "" - if let v = value as? String { - self?.log("version → \(v)") - } - } - }.store(in: &cancellables) - - BrownfieldState.subscribe("isActivated") { [weak self] value in - DispatchQueue.main.async { - self?.isActivated = value as? Bool ?? false - } - }.store(in: &cancellables) - - BrownfieldState.subscribe("transferProgress") { [weak self] value in - DispatchQueue.main.async { - self?.transferProgress = value as? Int ?? 0 - } - }.store(in: &cancellables) - - BrownfieldState.subscribe("error") { [weak self] value in - DispatchQueue.main.async { - self?.error = value as? String - if let e = value as? String { - self?.log("ERROR: \(e)") - } - } - }.store(in: &cancellables) - - BrownfieldState.subscribe("discoveredDevices") { [weak self] value in - DispatchQueue.main.async { - if let arr = value as? [[String: Any]] { - self?.discoveredDevices = arr.compactMap { dict in - guard let id = dict["id"] as? String else { return nil } - return DiscoveredDevice( - id: id, - name: dict["name"] as? String ?? "Unknown", - rssi: dict["rssi"] as? Int ?? 0 - ) - } - } else { - self?.discoveredDevices = [] - } - } - }.store(in: &cancellables) - } - - // MARK: - Message Listener - - private func setupMessageListener() { - let id = BrownfieldMessaging.addListener { [weak self] message in - DispatchQueue.main.async { - let event = message["event"] as? String ?? "unknown" - self?.log("Event: \(event) — \(message)") - } - } - listenerIds.append(id) - } - - // MARK: - Actions - - func scan() { - log("→ scan") - BrownfieldMessaging.sendMessage(["action": "scan"]) - } - - func stopScan() { - log("→ stopScan") - BrownfieldMessaging.sendMessage(["action": "stopScan"]) - } - - func connect(deviceId: String) { - log("→ connect \(deviceId)") - BrownfieldMessaging.sendMessage(["action": "connect", "deviceId": deviceId]) - } - - func disconnect() { - log("→ disconnect") - BrownfieldMessaging.sendMessage(["action": "disconnect"]) - } - - func bind(userId: String) { - log("→ bind \(userId)") - BrownfieldMessaging.sendMessage(["action": "bind", "userId": userId]) - } - - func unbind(userId: String) { - log("→ unbind \(userId)") - BrownfieldMessaging.sendMessage(["action": "unbind", "userId": userId]) - } - - func getDeviceInfo() { - log("→ getDeviceInfo") - BrownfieldMessaging.sendMessage(["action": "getDeviceInfo"]) - } - - func getVersion() { - log("→ getVersion") - BrownfieldMessaging.sendMessage(["action": "getVersion"]) - } - - func deleteFile(key: String) { - log("→ deleteFile \(key)") - BrownfieldMessaging.sendMessage(["action": "deleteFile", "key": key]) - } - - func transferFile(fileUri: String, commandType: Int) { - log("→ transferFile \(fileUri)") - BrownfieldMessaging.sendMessage([ - "action": "transferFile", - "fileUri": fileUri, - "commandType": commandType - ]) - } - - // MARK: - Logging - - private func log(_ message: String) { - let entry = LogEntry(time: Date(), message: message) - logs.insert(entry, at: 0) - if logs.count > 200 { logs.removeLast() } - } -} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..e26e64e --- /dev/null +++ b/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "DuooomiBleSDK", + platforms: [ + .iOS(.v16) + ], + products: [ + .library( + name: "DuooomiBleSDK", + targets: ["DuooomiBleSDK"] + ), + ], + targets: [ + .target( + name: "DuooomiBleSDK", + path: "Sources/DuooomiBleSDK" + ), + .testTarget( + name: "DuooomiBleSDKTests", + dependencies: ["DuooomiBleSDK"], + path: "Tests/DuooomiBleSDKTests" + ) + ] +) diff --git a/README.md b/README.md index eebb2ab..27ed519 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,223 @@ -# DuooomiTest +# Duooomi BLE SDK (Native iOS) -DuooomiBleSDK 的 iOS 测试应用,用于验证蓝牙设备的扫描、连接、绑定、文件传输等功能。 +纯原生 Swift 蓝牙 SDK,使用 CoreBluetooth 直接操作,零第三方依赖。 -## 环境要求 - -- Xcode 26+ -- iOS 16.0+ -- Swift 6.0 -- [XcodeGen](https://github.com/yonaskolb/XcodeGen) - -## 项目依赖 - -- `DuooomiBleSDK.xcframework` — 蓝牙通信 SDK -- `hermesvm.xcframework` — JS 运行时 - -依赖文件位于 `../duooomi-ble-sdk/artifacts/`,请确保该路径下存在对应的 xcframework。 - -## 快速开始 - -```bash -# 1. 安装 XcodeGen(如果还没有) -brew install xcodegen - -# 2. 生成 Xcode 项目 -xcodegen generate - -# 3. 打开项目 -open DuooomiTest.xcodeproj -``` - -在 Xcode 中设置你的 `DEVELOPMENT_TEAM`,然后运行到真机即可(蓝牙功能需要真机调试)。 - -## 项目结构 +## 架构设计 ``` -├── project.yml # XcodeGen 配置 -├── DuooomiTest/ -│ ├── Info.plist -│ └── Sources/ -│ ├── DuooomiTestApp.swift # App 入口 -│ ├── ContentView.swift # 测试界面 -│ └── SDKManager.swift # 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 + +@main +struct MyApp: App { + @StateObject private var sdk = DuooomiBleSDK() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(sdk) + } + } +} + +struct ContentView: View { + @EnvironmentObject var sdk: DuooomiBleSDK + + var body: some View { + VStack { + Text("State: \(sdk.btState.rawValue)") + + Button("Scan") { sdk.scan() } + + 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)") + } + } + } + } + } +} +``` + +### 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 (系统框架) | diff --git a/Sources/DuooomiBleSDK/Core/BleClient.swift b/Sources/DuooomiBleSDK/Core/BleClient.swift new file mode 100644 index 0000000..9d38df9 --- /dev/null +++ b/Sources/DuooomiBleSDK/Core/BleClient.swift @@ -0,0 +1,341 @@ +import CoreBluetooth +import Foundation + +/// CoreBluetooth 封装,提供 async API +final class BleClient: NSObject, @unchecked Sendable { + private var centralManager: CBCentralManager! + private let queue = DispatchQueue(label: "com.duooomi.ble.client") + + private(set) var connectedPeripheral: CBPeripheral? + private(set) var writeCharacteristic: CBCharacteristic? + private(set) var readCharacteristic: CBCharacteristic? + + /// 已发现但尚未连接的外设缓存 (scan 时填充) + private var discoveredPeripherals: [UUID: CBPeripheral] = [:] + + // MARK: - Continuations + + private var connectContinuation: CheckedContinuation? + private var disconnectContinuation: CheckedContinuation? + + // MARK: - Streams + + private var scanContinuation: AsyncStream.Continuation? + private var notificationContinuation: AsyncStream.Continuation? + + // MARK: - Callbacks + + var onConnectionStateChange: ((ConnectionState) -> Void)? + var onDisconnected: (() -> Void)? + + // MARK: - Init + + override init() { + super.init() + centralManager = CBCentralManager(delegate: self, queue: queue) + BleLog.i("BleClient initialized", "BLE") + } + + // MARK: - Public API + + /// 当前蓝牙状态。 + var bluetoothState: CBManagerState { + centralManager.state + } + + /// 当前连接在 `.withoutResponse` 模式下允许写入的最大长度。 + var maximumWriteLength: Int { + connectedPeripheral?.maximumWriteValueLength(for: .withoutResponse) ?? 20 + } + + /// 扫描包含目标 Service 的外设。 + /// - Parameter serviceUUIDs: 需要匹配的 Service UUID 列表,默认使用 SDK 预设服务。 + /// - Returns: 异步设备流;终止时自动停止底层扫描。 + func scan(serviceUUIDs: [CBUUID] = [BleUUIDs.service]) -> AsyncStream { + stopScan() + discoveredPeripherals.removeAll() + + return AsyncStream { continuation in + self.scanContinuation = continuation + continuation.onTermination = { [weak self] _ in + self?.queue.async { + self?.centralManager.stopScan() + self?.scanContinuation = nil + } + } + self.queue.async { + guard self.centralManager.state == .poweredOn else { return } + BleLog.i("CBCentral startScan", "BLE") + self.centralManager.scanForPeripherals( + withServices: serviceUUIDs, + options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] + ) + } + } + } + + /// 停止扫描。 + func stopScan() { + queue.async { [weak self] in + self?.centralManager.stopScan() + BleLog.i("CBCentral stopScan", "BLE") + } + scanContinuation?.finish() + scanContinuation = nil + } + + /// 连接已发现的外设。 + /// - Parameters: + /// - deviceId: 目标外设的 UUID 字符串(来源于扫描阶段)。 + /// - timeout: 连接超时,默认 30 秒。 + /// - Returns: 成功连接的 `CBPeripheral`。 + /// - Throws: 蓝牙未开启、找不到设备、超时或连接失败等错误。 + func connect(deviceId: String, timeout: TimeInterval = 30) async throws -> CBPeripheral { + guard centralManager.state == .poweredOn else { + throw DuooomiBleError.bluetoothNotPoweredOn(String(describing: centralManager.state)) + } + + guard let uuid = UUID(uuidString: deviceId), + let peripheral = discoveredPeripherals[uuid] else { + throw DuooomiBleError.connectionFailed("Device not found: \(deviceId)") + } + + return try await withCheckedThrowingContinuation { continuation in + self.connectContinuation = continuation + self.queue.async { + BleLog.i("Connecting to peripheral: \(peripheral.identifier.uuidString)", "BLE") + self.centralManager.connect(peripheral, options: nil) + } + + // Timeout + self.queue.asyncAfter(deadline: .now() + timeout) { [weak self] in + if let cont = self?.connectContinuation { + self?.connectContinuation = nil + self?.centralManager.cancelPeripheralConnection(peripheral) + BleLog.w("Connection timeout: \(peripheral.identifier.uuidString)", "BLE") + cont.resume(throwing: DuooomiBleError.connectionFailed("Connection timeout after \(Int(timeout))s")) + } + } + } + } + + /// 断开当前连接。 + /// - Throws: 如在超时后强制清理,仍会完成回调,不抛错;异常流程可能抛底层错误。 + func disconnect() async throws { + guard let peripheral = connectedPeripheral else { return } + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + self.disconnectContinuation = continuation + self.queue.async { + BleLog.i("Cancel connection: \(peripheral.identifier.uuidString)", "BLE") + self.centralManager.cancelPeripheralConnection(peripheral) + } + + // Timeout for disconnect + self.queue.asyncAfter(deadline: .now() + 5) { [weak self] in + if let cont = self?.disconnectContinuation { + self?.disconnectContinuation = nil + BleLog.w("Force-finish disconnect after timeout", "BLE") + self?.cleanup() + cont.resume() + } + } + } + } + + /// 无响应写入(GATT Write Without Response)。 + /// - Parameter data: 要写入的二进制数据。 + /// - Throws: 未连接等错误。 + func writeWithoutResponse(_ data: Data) throws { + guard let peripheral = connectedPeripheral, + let characteristic = writeCharacteristic else { + throw DuooomiBleError.notConnected + } + BleLog.d("Write w/o response, len=\(data.count)", "BLE") + peripheral.writeValue(data, for: characteristic, type: .withoutResponse) + } + + /// 查询系统层面已连接、且具备目标 Service 的外设。 + /// - Returns: 已连接外设的精简信息列表。 + func retrieveConnectedDevices() -> [DiscoveredDevice] { + let peripherals = centralManager.retrieveConnectedPeripherals(withServices: [BleUUIDs.service]) + return peripherals.map { peripheral in + DiscoveredDevice( + id: peripheral.identifier.uuidString, + name: peripheral.name, + rssi: 0 + ) + } + } + + /// 订阅并接收通知特征的原始二进制数据流。 + /// - Returns: 通知数据的异步流。 + func notifications() -> AsyncStream { + AsyncStream { continuation in + self.notificationContinuation = continuation + continuation.onTermination = { [weak self] _ in + self?.notificationContinuation = nil + } + BleLog.i("Notifications stream attached", "BLE") + } + } + + // MARK: - Private + + private func cleanup() { + if let readChar = readCharacteristic, let peripheral = connectedPeripheral { + peripheral.setNotifyValue(false, for: readChar) + } + connectedPeripheral = nil + writeCharacteristic = nil + readCharacteristic = nil + notificationContinuation?.finish() + notificationContinuation = nil + BleLog.i("Client state cleaned up", "BLE") + } +} + +// MARK: - CBCentralManagerDelegate + +extension BleClient: CBCentralManagerDelegate { + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + // If scanning was requested before poweredOn, start now + if central.state == .poweredOn, scanContinuation != nil { + BleLog.d("State poweredOn; resume scan", "BLE") + central.scanForPeripherals( + withServices: [BleUUIDs.service], + options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] + ) + } + BleLog.d("Central state=\(central.state.rawValue)", "BLE") + } + + func centralManager( + _ central: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi RSSI: NSNumber + ) { + discoveredPeripherals[peripheral.identifier] = peripheral + + let device = DiscoveredDevice( + id: peripheral.identifier.uuidString, + name: peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String, + rssi: RSSI.intValue + ) + BleLog.d("Did discover: id=\(device.id), rssi=\(device.rssi)", "BLE") + scanContinuation?.yield(device) + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + connectedPeripheral = peripheral + peripheral.delegate = self + BleLog.i("Did connect: \(peripheral.identifier.uuidString)", "BLE") + peripheral.discoverServices([BleUUIDs.service]) + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + let cont = connectContinuation + connectContinuation = nil + BleLog.e("Fail to connect: \(peripheral.identifier.uuidString), error=\(error?.localizedDescription ?? "-")", "BLE") + cont?.resume(throwing: DuooomiBleError.connectionFailed(error?.localizedDescription ?? "Unknown error")) + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + // If disconnect happens mid-setup (after didConnect but before discoverCharacteristics + // completes), the connect continuation must be resolved or the caller hangs forever. + if let connectCont = connectContinuation { + connectContinuation = nil + connectCont.resume(throwing: DuooomiBleError.connectionFailed( + error?.localizedDescription ?? "Disconnected during setup" + )) + } + + cleanup() + + if let cont = disconnectContinuation { + disconnectContinuation = nil + cont.resume() + } + + BleLog.w("Did disconnect: \(peripheral.identifier.uuidString), error=\(error?.localizedDescription ?? "-")", "BLE") + onDisconnected?() + } +} + +// MARK: - CBPeripheralDelegate + +extension BleClient: CBPeripheralDelegate { + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + if let error = error { + let cont = connectContinuation + connectContinuation = nil + BleLog.e("Discover services error: \(error.localizedDescription)", "BLE") + cont?.resume(throwing: DuooomiBleError.connectionFailed(error.localizedDescription)) + return + } + + guard let service = peripheral.services?.first(where: { $0.uuid == BleUUIDs.service }) else { + let cont = connectContinuation + connectContinuation = nil + BleLog.e("Service not found", "BLE") + cont?.resume(throwing: DuooomiBleError.serviceNotFound) + return + } + + BleLog.d("Service found: \(service.uuid.uuidString)", "BLE") + peripheral.discoverCharacteristics( + [BleUUIDs.writeCharacteristic, BleUUIDs.readCharacteristic], + for: service + ) + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + if let error = error { + let cont = connectContinuation + connectContinuation = nil + BleLog.e("Discover characteristics error: \(error.localizedDescription)", "BLE") + cont?.resume(throwing: DuooomiBleError.connectionFailed(error.localizedDescription)) + return + } + + guard let characteristics = service.characteristics else { + let cont = connectContinuation + connectContinuation = nil + cont?.resume(throwing: DuooomiBleError.characteristicNotFound) + return + } + + for char in characteristics { + if char.uuid == BleUUIDs.writeCharacteristic { + writeCharacteristic = char + } else if char.uuid == BleUUIDs.readCharacteristic { + readCharacteristic = char + peripheral.setNotifyValue(true, for: char) + } + } + + guard writeCharacteristic != nil, readCharacteristic != nil else { + let cont = connectContinuation + connectContinuation = nil + BleLog.e("Required characteristics missing", "BLE") + cont?.resume(throwing: DuooomiBleError.characteristicNotFound) + return + } + + // Connection fully ready + let cont = connectContinuation + connectContinuation = nil + BleLog.i("Characteristics ready; connection established", "BLE") + cont?.resume(returning: peripheral) + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + guard error == nil, characteristic.uuid == BleUUIDs.readCharacteristic, + let value = characteristic.value else { return } + BleLog.d("Notification received, len=\(value.count)", "BLE") + notificationContinuation?.yield(value) + } +} diff --git a/Sources/DuooomiBleSDK/Core/BleTypes.swift b/Sources/DuooomiBleSDK/Core/BleTypes.swift new file mode 100644 index 0000000..08c2bc3 --- /dev/null +++ b/Sources/DuooomiBleSDK/Core/BleTypes.swift @@ -0,0 +1,69 @@ +import Foundation + +public enum ConnectionState: String, Sendable { + case idle + case scanning + case connecting + case connected + case disconnecting + case disconnected +} + +public struct DiscoveredDevice: Identifiable, Sendable, Equatable { + public let id: String + public let name: String? + public let rssi: Int + + public init(id: String, name: String?, rssi: Int) { + self.id = id + self.name = name + self.rssi = rssi + } +} + +public enum DuooomiBleError: LocalizedError, Sendable { + case bluetoothNotPoweredOn(String) + case notConnected + case timeout(command: CommandType) + case connectionFailed(String) + case serviceNotFound + case characteristicNotFound + case bindingFailed(String) + case unbindFailed + case deleteFileFailed(status: Int) + case prepareTransferFailed(status: String) + case transferFailed(String) + case invalidFrame + case permissionDenied + + public var errorDescription: String? { + switch self { + case .bluetoothNotPoweredOn(let state): + return "Bluetooth is not powered on. State: \(state)" + case .notConnected: + return "No device connected" + case .timeout(let command): + return "Operation timeout: \(command)" + case .connectionFailed(let reason): + return "Connection failed: \(reason)" + case .serviceNotFound: + return "BLE service not found" + case .characteristicNotFound: + return "BLE characteristic not found" + case .bindingFailed(let reason): + return "Binding failed: \(reason)" + case .unbindFailed: + return "Unbind failed" + case .deleteFileFailed(let status): + return "Delete file failed with status: \(status)" + case .prepareTransferFailed(let status): + return "Prepare transfer failed: \(status)" + case .transferFailed(let reason): + return "Transfer failed: \(reason)" + case .invalidFrame: + return "Invalid protocol frame" + case .permissionDenied: + return "Bluetooth permission denied" + } + } +} diff --git a/Sources/DuooomiBleSDK/DuooomiBleSDK.swift b/Sources/DuooomiBleSDK/DuooomiBleSDK.swift new file mode 100644 index 0000000..172dc5b --- /dev/null +++ b/Sources/DuooomiBleSDK/DuooomiBleSDK.swift @@ -0,0 +1,484 @@ +import Combine +import CoreBluetooth +import Foundation + +/// Duooomi BLE SDK - 纯原生 iOS 蓝牙 SDK +/// +/// 提供设备扫描、连接、命令交互、文件传输等原子蓝牙操作。 +/// 不做业务编排,调用方自行串联多步流程。 +@MainActor +public final class DuooomiBleSDK: ObservableObject { + + // MARK: - Observable State + + @Published public private(set) var btState: ConnectionState = .idle + @Published public private(set) var connectedDevice: DiscoveredDevice? = nil + @Published public private(set) var deviceInfo: DeviceInfo? = nil + @Published public private(set) var version: String = "" + @Published public private(set) var isActivated: Bool = false + @Published public private(set) var transferProgress: Int = 0 + @Published public private(set) var error: String? = nil + @Published public private(set) var discoveredDevices: [DiscoveredDevice] = [] + + // MARK: - Internal Services + + private let bleClient: BleClient + private let protocolService: BleProtocolService + private let deviceInfoService: DeviceInfoService + private let fileTransferService: FileTransferService + + // MARK: - Request-Response + + private var pendingContinuations: [String: CheckedContinuation] = [:] + private var messageListenerTask: Task? + + // MARK: - Scan Batching + + private var scanTask: Task? + private var pendingDevices: [DiscoveredDevice] = [] + private var allDiscoveredDevices: [DiscoveredDevice] = [] + private var flushWorkItem: DispatchWorkItem? + + // MARK: - Init + + public init() { + bleClient = BleClient() + protocolService = BleProtocolService(client: bleClient) + deviceInfoService = DeviceInfoService(protocolService: protocolService) + fileTransferService = FileTransferService(protocolService: protocolService) + + BleLog.i("SDK initialized", "SDK") + setupDisconnectHandler() + } + + private func setupDisconnectHandler() { + bleClient.onDisconnected = { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + BleLog.w("Peripheral disconnected; resetting state", "SDK") + self.connectedDevice = nil + self.deviceInfo = nil + self.version = "" + self.isActivated = false + self.btState = .disconnected + self.protocolService.stopListening() + self.cancelAllPending(error: DuooomiBleError.notConnected) + } + } + } + + // MARK: - Scanning + + /// 开始扫描设备。 + /// - Note: 扫描使用 500ms 批量刷新以减少 UI 抖动,调用 `stopScan()` 可停止。 + public func scan() { + stopScan() + btState = .scanning + discoveredDevices = [] + allDiscoveredDevices = [] + pendingDevices = [] + + BleLog.i("Start scanning...", "Scan") + let stream = bleClient.scan() + scanTask = Task { [weak self] in + for await device in stream { + guard let self, !Task.isCancelled else { break } + BleLog.d("Discovered: id=\(device.id), name=\(device.name ?? "-"), rssi=\(device.rssi)", "Scan") + await self.queueDevice(device) + } + } + } + + /// 停止扫描设备。 + /// - Note: 会立即刷新已缓存的发现列表;若保持已连接,`btState` 回到 `.connected`。 + public func stopScan() { + scanTask?.cancel() + scanTask = nil + bleClient.stopScan() + flushDevices() + + if connectedDevice != nil { + btState = .connected + } else if btState == .scanning { + btState = .idle + } + BleLog.i("Stop scanning (state=\(btState.rawValue))", "Scan") + } + + // MARK: - Connection + + /// 连接到指定设备。 + /// - Parameter deviceId: 通过扫描得到的 `DiscoveredDevice.id`(CBPeripheral UUID 字符串)。 + /// - Returns: 成功连接的设备信息。 + /// - Throws: `DuooomiBleError.connectionFailed` 等连接相关错误。 + public func connect(deviceId: String) async throws -> DiscoveredDevice { + stopScan() + btState = .connecting + BleLog.i("Connecting to \(deviceId)...", "Connect") + + do { + let peripheral = try await bleClient.connect(deviceId: deviceId) + + // Start protocol listener + protocolService.startListening() + startMessageListener() + + let device = DiscoveredDevice( + id: peripheral.identifier.uuidString, + name: peripheral.name, + rssi: 0 + ) + connectedDevice = device + btState = .connected + BleLog.i("Connected: \(device.id)", "Connect") + return device + } catch { + btState = .idle + self.error = error.localizedDescription + BleLog.e("Connect failed: \(error.localizedDescription)", "Connect") + throw error + } + } + + /// 断开当前连接。 + /// - Throws: 断开过程中发生的底层错误(若有)。 + public func disconnect() async throws { + btState = .disconnecting + BleLog.i("Disconnect requested", "Connect") + protocolService.stopListening() + messageListenerTask?.cancel() + messageListenerTask = nil + cancelAllPending(error: DuooomiBleError.notConnected) + + // Capture the underlying disconnect error so we can clean up state regardless, + // but still surface it to the caller. UI consumers may also see it via `error`. + var disconnectError: Error? + do { + try await bleClient.disconnect() + } catch { + disconnectError = error + self.error = error.localizedDescription + BleLog.e("Disconnect error: \(error.localizedDescription)", "Connect") + } + + connectedDevice = nil + deviceInfo = nil + version = "" + isActivated = false + btState = .disconnected + BleLog.i("Disconnected", "Connect") + + if let disconnectError { + throw disconnectError + } + } + + /// 返回系统已连接的、属于本 SDK service 的外设(不一定是被本 SDK 主动连接的) + /// 返回系统层面已连接、且包含本 SDK 所需 Service 的外设。 + /// - Returns: 符合条件的已连接外设列表。 + public func getConnectedDevices() -> [DiscoveredDevice] { + let list = bleClient.retrieveConnectedDevices() + BleLog.d("System-connected devices: \(list.count)", "Connect") + return list + } + + // MARK: - Device Commands + + /// 读取设备信息(容量、电量、品牌、型号等)。 + /// - Returns: 设备信息结构体。 + /// - Throws: 未连接或超时、解析失败等错误。 + public func getDeviceInfo() async throws -> DeviceInfo { + try ensureConnected() + BleLog.d("Sending getDeviceInfo", "Command") + let data = try await sendAndWait(commandType: .getDeviceInfo) { + try await self.deviceInfoService.getDeviceInfo() + } + let info = try decodeResponse(DeviceInfo.self, from: data) + BleLog.i("DeviceInfo received: name=\(info.name), brand=\(info.brand)", "Command") + self.deviceInfo = info + return info + } + + /// 读取设备固件版本。 + /// - Returns: 版本信息。 + /// - Throws: 未连接或超时、解析失败等错误。 + public func getVersion() async throws -> VersionInfo { + try ensureConnected() + BleLog.d("Sending getDeviceVersion", "Command") + let data = try await sendAndWait(commandType: .getDeviceVersion) { + try await self.deviceInfoService.getDeviceVersion() + } + let info = try decodeResponse(VersionInfo.self, from: data) + self.version = info.version + BleLog.i("Version received: \(info.version) (type=\(info.type))", "Command") + return info + } + + /// 绑定设备到用户。 + /// - Parameter userId: 业务侧用户唯一标识。 + /// - Returns: 绑定结果,包含 SN 与设备文件清单。 + /// - Throws: 已被他人绑定或其他错误时抛出。 + public func bind(userId: String) async throws -> BindingResponse { + try ensureConnected() + BleLog.d("Sending bind (userId=\(userId))", "Command") + let data = try await sendAndWait(commandType: .bindDevice) { + try await self.deviceInfoService.bindDevice(userId: userId) + } + let resp = try decodeResponse(BindingResponse.self, from: data) + isActivated = resp.success == 1 + if resp.success != 1 { + BleLog.w("Bind failed: device already bound", "Command") + throw DuooomiBleError.bindingFailed("Device already bound to another user") + } + BleLog.i("Bind success: sn=\(resp.sn)", "Command") + return resp + } + + /// 解除设备与用户的绑定关系。 + /// - Parameter userId: 业务侧用户唯一标识。 + /// - Returns: 解绑结果。 + /// - Throws: 未连接或设备拒绝等错误。 + public func unbind(userId: String) async throws -> UnbindResponse { + try ensureConnected() + BleLog.d("Sending unbind (userId=\(userId))", "Command") + let data = try await sendAndWait(commandType: .unbindDevice) { + try await self.deviceInfoService.unbindDevice(userId: userId) + } + let resp = try decodeResponse(UnbindResponse.self, from: data) + if resp.success == 1 { + isActivated = false + BleLog.i("Unbind success", "Command") + } else { + BleLog.w("Unbind failed", "Command") + throw DuooomiBleError.unbindFailed + } + return resp + } + + /// 从设备中删除指定 key 的文件。 + /// - Parameter key: 设备中文件对应的 key 或完整 URL。 + /// - Returns: 删除结果。 + /// - Throws: 文件不存在或删除失败时抛出。 + public func deleteFile(key: String) async throws -> DeleteFileResponse { + try ensureConnected() + BleLog.d("Sending deleteFile (key=\(key))", "Command") + let data = try await sendAndWait(commandType: .deleteFile) { + try await self.deviceInfoService.deleteFile(key: key) + } + let resp = try decodeResponse(DeleteFileResponse.self, from: data) + if resp.success != 0 { + BleLog.w("Delete failed with status=\(resp.success)", "Command") + throw DuooomiBleError.deleteFileFailed(status: resp.success) + } + BleLog.i("Delete success (key=\(key))", "Command") + return resp + } + + /// 进行传输前的准备校验(空间、重复等)。 + /// - Parameters: + /// - key: 用于标识该文件的唯一 key(可直接使用源文件 URL)。 + /// - size: 即将传输的字节大小。 + /// - Returns: 设备返回的准备状态,`status == "ready"` 表示可传。 + /// - Throws: 未连接或设备拒绝等错误。 + public func prepareTransfer(key: String, size: Int) async throws -> PrepareTransferResponse { + try ensureConnected() + let opId = "\(CommandType.prepareTransfer.rawValue)_\(key)" + BleLog.d("Sending prepareTransfer (key=\(key), size=\(size))", "Transfer") + let data = try await sendAndWait(opId: opId, command: .prepareTransfer) { + try await self.deviceInfoService.prepareTransfer(key: key, size: size) + } + let resp = try decodeResponse(PrepareTransferResponse.self, from: data) + if resp.status != "ready" { + BleLog.w("PrepareTransfer not ready: status=\(resp.status)", "Transfer") + throw DuooomiBleError.prepareTransferFailed(status: resp.status) + } + BleLog.i("PrepareTransfer ready (key=\(key))", "Transfer") + return resp + } + + // MARK: - File Transfer + + /// 传输文件到设备。 + /// - Parameters: + /// - fileUri: `file://` 本地路径或 `https://` 远程 URL。 + /// - commandType: 传输命令类型(如 `.transferAniVideo`、`.otaPackage`)。 + /// - Throws: 下载失败、蓝牙发送失败等错误。 + public func transferFile( + fileUri: String, + commandType: CommandType = .transferAniVideo + ) async throws { + try ensureConnected() + transferProgress = 0 + BleLog.i("Transfer start: uri=\(fileUri), cmd=\(commandType)", "Transfer") + + try await fileTransferService.transferFile( + fileUri: fileUri, + commandType: commandType + ) { [weak self] progress in + Task { @MainActor [weak self] in + self?.transferProgress = Int(progress * 100) + if let p = self?.transferProgress, p % 10 == 0 { + BleLog.d("Progress: \(p)%", "Transfer") + } + } + } + + transferProgress = 100 + BleLog.i("Transfer completed", "Transfer") + } + + // MARK: - Request-Response Pattern + + private func sendAndWait( + commandType: CommandType, + timeout: TimeInterval = 10, + send: @escaping () async throws -> Void + ) async throws -> Data { + try await sendAndWait( + opId: "\(commandType.rawValue)", + command: commandType, + timeout: timeout, + send: send + ) + } + + private func sendAndWait( + opId: String, + command: CommandType, + timeout: TimeInterval = 10, + send: @escaping () async throws -> Void + ) async throws -> Data { + BleLog.d("Register opId=\(opId), cmd=\(command)", "RPC") + // Register continuation BEFORE sending so a fast device response can't race past us. + // Use a defer block to guarantee opId removal regardless of timeout/send/cancel outcome. + do { + return try await withThrowingTaskGroup(of: Data.self) { group in + group.addTask { @MainActor [self] in + try await withCheckedThrowingContinuation { continuation in + self.pendingContinuations[opId] = continuation + BleLog.d("Continuation stored for opId=\(opId)", "RPC") + } + } + + group.addTask { + // Send first; if send fails, propagate immediately instead of waiting timeout. + try await send() + BleLog.d("Command sent for opId=\(opId)", "RPC") + try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + throw DuooomiBleError.timeout(command: command) + } + + defer { group.cancelAll() } + guard let result = try await group.next() else { + throw DuooomiBleError.timeout(command: command) + } + BleLog.d("Result received for opId=\(opId)", "RPC") + return result + } + } catch { + // Whatever the failure (timeout, cancellation, send error), make sure no stale + // continuation is left in the dictionary so a late device response cannot resume + // a continuation that has already been released. + await MainActor.run { + if let stale = self.pendingContinuations.removeValue(forKey: opId) { + stale.resume(throwing: error) + } + } + BleLog.e("RPC failed for opId=\(opId): \(error.localizedDescription)", "RPC") + throw error + } + } + + // MARK: - Message Listener + + private func startMessageListener() { + messageListenerTask?.cancel() + messageListenerTask = Task { [weak self] in + guard let self else { return } + BleLog.i("Message listener started", "RPC") + for await (commandType, data) in self.protocolService.incomingMessages { + guard !Task.isCancelled else { break } + BleLog.d("Incoming message: type=\(commandType), size=\(data.count)", "RPC") + await self.handleIncomingMessage(commandType: commandType, data: data) + } + } + } + + @MainActor + private func handleIncomingMessage(commandType: UInt8, data: Data) { + let opId = "\(commandType)" + + // Check for prepareTransfer with key-based opId + if commandType == CommandType.prepareTransfer.rawValue { + if let resp = try? decodeResponse(PrepareTransferResponse.self, from: data) { + let keyedOpId = "\(commandType)_\(resp.key)" + if let continuation = pendingContinuations.removeValue(forKey: keyedOpId) { + BleLog.d("Resuming keyed opId=\(keyedOpId)", "RPC") + continuation.resume(returning: data) + return + } + } + } + + if let continuation = pendingContinuations.removeValue(forKey: opId) { + BleLog.d("Resuming opId=\(opId)", "RPC") + continuation.resume(returning: data) + } else { + BleLog.w("No pending continuation for opId=\(opId)", "RPC") + } + } + + // MARK: - Helpers + + private func ensureConnected() throws { + guard connectedDevice != nil else { + BleLog.w("Operation requires connection", "SDK") + throw DuooomiBleError.notConnected + } + } + + private func decodeResponse(_ type: T.Type, from data: Data) throws -> T { + // Device responses may contain null bytes + let cleaned = data.filter { $0 != 0 } + do { + return try JSONDecoder().decode(type, from: cleaned) + } catch { + let raw = String(data: cleaned, encoding: .utf8) ?? "" + BleLog.e("JSON decode \(T.self) failed: \(error.localizedDescription)\nRaw: \(raw)", "Decode") + throw error + } + } + + private func cancelAllPending(error: Error) { + let continuations = pendingContinuations + pendingContinuations.removeAll() + for (_, continuation) in continuations { + continuation.resume(throwing: error) + } + } + + // MARK: - Scan Batching (500ms throttle) + + private func queueDevice(_ device: DiscoveredDevice) { + guard !allDiscoveredDevices.contains(where: { $0.id == device.id }) else { return } + + allDiscoveredDevices.append(device) + pendingDevices.append(device) + + flushWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + Task { @MainActor [weak self] in + self?.flushDevices() + } + } + flushWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: workItem) + } + + private func flushDevices() { + guard !pendingDevices.isEmpty else { return } + pendingDevices = [] + discoveredDevices = allDiscoveredDevices + BleLog.d("Discovered devices flushed: total=\(discoveredDevices.count)", "Scan") + } +} diff --git a/Sources/DuooomiBleSDK/Models/BindingResponse.swift b/Sources/DuooomiBleSDK/Models/BindingResponse.swift new file mode 100644 index 0000000..79a4baf --- /dev/null +++ b/Sources/DuooomiBleSDK/Models/BindingResponse.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct BindingResponse: Codable, Sendable, Equatable { + public let type: Int + /// 设备 SN 码 (14 位) + public let sn: String + /// 1 = 成功, 0 = 失败 + public let success: Int + /// 设备内已存在的文件 key 列表 + public let contents: [String] +} diff --git a/Sources/DuooomiBleSDK/Models/DeleteFileResponse.swift b/Sources/DuooomiBleSDK/Models/DeleteFileResponse.swift new file mode 100644 index 0000000..f1f9c47 --- /dev/null +++ b/Sources/DuooomiBleSDK/Models/DeleteFileResponse.swift @@ -0,0 +1,7 @@ +import Foundation + +public struct DeleteFileResponse: Codable, Sendable, Equatable { + public let type: Int + /// 0 = 成功, 1 = 失败, 2 = 文件不存在 + public let success: Int +} diff --git a/Sources/DuooomiBleSDK/Models/DeviceInfo.swift b/Sources/DuooomiBleSDK/Models/DeviceInfo.swift new file mode 100644 index 0000000..c4dcbc2 --- /dev/null +++ b/Sources/DuooomiBleSDK/Models/DeviceInfo.swift @@ -0,0 +1,56 @@ +import Foundation + +public struct DeviceInfo: Sendable, Equatable { + public let allspace: UInt64 + public let freespace: UInt64 + public let name: String + public let size: String + public let brand: String + public let powerlevel: Int +} + +extension DeviceInfo: Codable { + enum CodingKeys: String, CodingKey { + case allspace, freespace, name, size, brand, powerlevel + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // All string fields: fallback to "" if missing + name = (try? container.decode(String.self, forKey: .name)) ?? "" + size = (try? container.decode(String.self, forKey: .size)) ?? "" + brand = (try? container.decode(String.self, forKey: .brand)) ?? "" + + // powerlevel: Int, may come as Int or String + if let num = try? container.decode(Int.self, forKey: .powerlevel) { + powerlevel = num + } else if let str = try? container.decode(String.self, forKey: .powerlevel), + let num = Int(str) { + powerlevel = num + } else { + powerlevel = 0 + } + + // allspace/freespace: may come as number, string, or be missing + allspace = Self.decodeFlexibleUInt64(container: container, key: .allspace) + freespace = Self.decodeFlexibleUInt64(container: container, key: .freespace) + } + + private static func decodeFlexibleUInt64( + container: KeyedDecodingContainer, + key: CodingKeys + ) -> UInt64 { + if let value = try? container.decode(UInt64.self, forKey: key) { + return value + } + if let value = try? container.decode(Int.self, forKey: key) { + return UInt64(max(0, value)) + } + if let str = try? container.decode(String.self, forKey: key), + let value = UInt64(str) { + return value + } + return 0 + } +} diff --git a/Sources/DuooomiBleSDK/Models/PrepareTransferResponse.swift b/Sources/DuooomiBleSDK/Models/PrepareTransferResponse.swift new file mode 100644 index 0000000..14f3127 --- /dev/null +++ b/Sources/DuooomiBleSDK/Models/PrepareTransferResponse.swift @@ -0,0 +1,8 @@ +import Foundation + +public struct PrepareTransferResponse: Codable, Sendable, Equatable { + public let type: Int + public let key: String + /// "ready" | "no_space" | "duplicated" + public let status: String +} diff --git a/Sources/DuooomiBleSDK/Models/UnbindResponse.swift b/Sources/DuooomiBleSDK/Models/UnbindResponse.swift new file mode 100644 index 0000000..e0aa098 --- /dev/null +++ b/Sources/DuooomiBleSDK/Models/UnbindResponse.swift @@ -0,0 +1,6 @@ +import Foundation + +public struct UnbindResponse: Codable, Sendable, Equatable { + /// 1 = 成功, 0 = 失败 + public let success: Int +} diff --git a/Sources/DuooomiBleSDK/Models/VersionInfo.swift b/Sources/DuooomiBleSDK/Models/VersionInfo.swift new file mode 100644 index 0000000..bea080e --- /dev/null +++ b/Sources/DuooomiBleSDK/Models/VersionInfo.swift @@ -0,0 +1,27 @@ +import Foundation + +public struct VersionInfo: Sendable, Equatable { + public let version: String + /// 命令类型标识,设备可能返回 Int (如 7) 或 String (如 "0x07") + public let type: String +} + +extension VersionInfo: Codable { + enum CodingKeys: String, CodingKey { + case version, type + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + version = try container.decode(String.self, forKey: .version) + + // type may come as Int or String from different firmware versions + if let str = try? container.decode(String.self, forKey: .type) { + type = str + } else if let num = try? container.decode(Int.self, forKey: .type) { + type = "\(num)" + } else { + type = "" + } + } +} diff --git a/Sources/DuooomiBleSDK/Protocol/ProtocolConstants.swift b/Sources/DuooomiBleSDK/Protocol/ProtocolConstants.swift new file mode 100644 index 0000000..e4841a5 --- /dev/null +++ b/Sources/DuooomiBleSDK/Protocol/ProtocolConstants.swift @@ -0,0 +1,35 @@ +import CoreBluetooth + +public enum BleUUIDs { + public static let service = CBUUID(string: "000002c4-0000-1000-8000-00805f9b34fb") + public static let writeCharacteristic = CBUUID(string: "000002c5-0000-1000-8000-00805f9b34fb") + public static let readCharacteristic = CBUUID(string: "000002c6-0000-1000-8000-00805f9b34fb") + public static let broadcastCharacteristic = CBUUID(string: "000002c1-0000-1000-8000-00805f9b34fb") +} + +public enum FrameHead: UInt8 { + case appToDevice = 0xC7 + case deviceToApp = 0xB0 +} + +public enum FrameConstants { + public static let maxDataSize = 496 + public static let headerSize = 8 + public static let footerSize = 1 + /// Inter-frame delay in nanoseconds (35ms) + public static let frameIntervalNanos: UInt64 = 35_000_000 + public static let screenSize = 360 +} + +public enum CommandType: UInt8, Sendable { + case otaPackage = 0x02 + case transferBootAnimation = 0x03 + case transferAniVideo = 0x05 + case transferJpegImage = 0x06 + case getDeviceVersion = 0x07 + case getDeviceInfo = 0x0D + case bindDevice = 0x0F + case unbindDevice = 0x12 + case deleteFile = 0x13 + case prepareTransfer = 0x14 +} diff --git a/Sources/DuooomiBleSDK/Protocol/ProtocolFrame.swift b/Sources/DuooomiBleSDK/Protocol/ProtocolFrame.swift new file mode 100644 index 0000000..8fb38f8 --- /dev/null +++ b/Sources/DuooomiBleSDK/Protocol/ProtocolFrame.swift @@ -0,0 +1,20 @@ +import Foundation + +/// BLE 协议帧结构 +/// 格式: [head:1][type:1][subpageTotal:2][curPage:2][dataLen:2][data:N][checksum:1] +public struct ProtocolFrame: Sendable { + /// 传输方向标识 (0xC7 = APP→Device, 0xB0 = Device→APP) + public let head: UInt8 + /// 命令类型,对应 CommandType + public let type: UInt8 + /// 总分包数 (0 = 单帧不分包) + public let subpageTotal: UInt16 + /// 当前包序号 (从 total-1 倒数到 0) + public let curPage: UInt16 + /// 数据段字节长度 + public let dataLen: UInt16 + /// 数据段内容 + public let data: Data + /// 校验字节 + public let checksum: UInt8 +} diff --git a/Sources/DuooomiBleSDK/Protocol/ProtocolManager.swift b/Sources/DuooomiBleSDK/Protocol/ProtocolManager.swift new file mode 100644 index 0000000..3dee35c --- /dev/null +++ b/Sources/DuooomiBleSDK/Protocol/ProtocolManager.swift @@ -0,0 +1,165 @@ +import Foundation + +public enum ProtocolManager { + + // MARK: - Checksum + + /// 计算校验和:所有字节求和后取二进制补码的低 8 位 + /// checksum = (~sum + 1) & 0xFF + public static func calculateChecksum(_ data: Data) -> UInt8 { + var sum: UInt32 = 0 + for byte in data { + sum += UInt32(byte) + } + return UInt8((~sum &+ 1) & 0xFF) + } + + /// 校验给定数据的校验和是否匹配。 + /// - Parameters: + /// - data: 用于计算校验的字节序列(不含最后的校验位)。 + /// - expected: 期望的校验和。 + /// - Returns: 是否匹配。 + public static func verifyChecksum(_ data: Data, expected: UInt8) -> Bool { + calculateChecksum(data) == expected + } + + // MARK: - Frame Creation + + /// 根据数据大小自动选择单帧或分片 + /// - Throws: 当 payload 超过 UInt16 分页能力时(> 65535 个分片)抛出 invalidFrame + public static func createFrames( + type: UInt8, + data: Data, + head: FrameHead = .appToDevice, + maxDataSize: Int = FrameConstants.maxDataSize + ) -> [Data] { + // subpageTotal/curPage are encoded as UInt16, so the absolute ceiling is 65535 pages. + // Returning [] keeps the call non-throwing for backwards compatibility; callers (the + // protocol service / file transfer) detect the empty array as a payload-too-large signal. + let maxChunk = max(1, maxDataSize) + let totalPages = (data.count + maxChunk - 1) / maxChunk + if totalPages > Int(UInt16.max) { + return [] + } + + if data.count <= maxDataSize { + return [createSingleFrame(type: type, data: data, head: head)] + } else { + return createFragmentedFrames(type: type, data: data, head: head, maxDataSize: maxChunk) + } + } + + private static func createSingleFrame(type: UInt8, data: Data, head: FrameHead) -> Data { + let totalSize = FrameConstants.headerSize + data.count + FrameConstants.footerSize + var buffer = Data(capacity: totalSize) + + // Header: head(1) + type(1) + subpageTotal(2) + curPage(2) + dataLen(2) + buffer.append(head.rawValue) + buffer.append(type) + buffer.append(0) // subpageTotal high + buffer.append(0) // subpageTotal low + buffer.append(0) // curPage high + buffer.append(0) // curPage low + buffer.append(UInt8((data.count >> 8) & 0xFF)) // dataLen high + buffer.append(UInt8(data.count & 0xFF)) // dataLen low + + // Data + buffer.append(data) + + // Checksum (calculated on everything before checksum) + let checksum = calculateChecksum(buffer) + buffer.append(checksum) + + return buffer + } + + private static func createFragmentedFrames( + type: UInt8, + data: Data, + head: FrameHead, + maxDataSize: Int + ) -> [Data] { + let totalPages = (data.count + maxDataSize - 1) / maxDataSize + var frames: [Data] = [] + frames.reserveCapacity(totalPages) + + for i in 0..> 8) & 0xFF)) + buffer.append(UInt8(totalPages & 0xFF)) + + // curPage: 页号从 (total-1) 倒数到 0 + let curPageVal = totalPages - 1 - i + buffer.append(UInt8((curPageVal >> 8) & 0xFF)) + buffer.append(UInt8(curPageVal & 0xFF)) + + // dataLen + buffer.append(UInt8((chunk.count >> 8) & 0xFF)) + buffer.append(UInt8(chunk.count & 0xFF)) + + // data + buffer.append(contentsOf: chunk) + + // checksum + let checksum = calculateChecksum(buffer) + buffer.append(checksum) + + frames.append(buffer) + } + + return frames + } + + // MARK: - Frame Parsing + + /// 解析原始二进制为协议帧。 + /// - Parameter data: 含有完整帧内容的字节序列。 + /// - Returns: 若校验与长度正确,返回 `ProtocolFrame`,否则返回 `nil`。 + public static func parseFrame(_ data: Data) -> ProtocolFrame? { + let minSize = FrameConstants.headerSize + FrameConstants.footerSize + guard data.count >= minSize else { return nil } + + let head = data[data.startIndex] + guard head == FrameHead.deviceToApp.rawValue || head == FrameHead.appToDevice.rawValue else { + return nil + } + + let type = data[data.startIndex + 1] + let subpageTotal = UInt16(data[data.startIndex + 2]) << 8 | UInt16(data[data.startIndex + 3]) + let curPage = UInt16(data[data.startIndex + 4]) << 8 | UInt16(data[data.startIndex + 5]) + let dataLen = UInt16(data[data.startIndex + 6]) << 8 | UInt16(data[data.startIndex + 7]) + + let expectedSize = FrameConstants.headerSize + Int(dataLen) + FrameConstants.footerSize + guard data.count >= expectedSize else { return nil } + + let dataStart = data.startIndex + FrameConstants.headerSize + let dataEnd = dataStart + Int(dataLen) + let frameData = data[dataStart.. data + } + + private var fragments: [String: FragmentSession] = [:] + private var listenerTask: Task? + + private var messageContinuation: AsyncStream<(commandType: UInt8, data: Data)>.Continuation? + private(set) var incomingMessages: AsyncStream<(commandType: UInt8, data: Data)>! + + init(client: BleClient) { + self.client = client + incomingMessages = AsyncStream { continuation in + self.messageContinuation = continuation + } + BleLog.i("Protocol service created", "Protocol") + } + + // MARK: - Start/Stop Listening + + /// 开始监听底层通知并进行帧解析与重组。 + /// - Note: 重新创建消息流并清空历史分片缓存。 + func startListening() { + // Recreate the stream in case it was previously finished + incomingMessages = AsyncStream { continuation in + self.messageContinuation = continuation + } + + fragments.removeAll() + + listenerTask = Task { [weak self] in + guard let self else { return } + BleLog.i("Start listening for notifications", "Protocol") + for await data in self.client.notifications() { + self.handleRawData(data) + } + } + } + + /// 停止监听,结束消息流并清理缓存。 + func stopListening() { + listenerTask?.cancel() + listenerTask = nil + messageContinuation?.finish() + messageContinuation = nil + fragments.removeAll() + BleLog.i("Stop listening; fragments cleared", "Protocol") + } + + // MARK: - Send + + /// 发送二进制数据(自动拆分为协议帧)。 + /// - Parameters: + /// - type: 命令类型值。 + /// - data: 需要发送的原始负载。 + /// - onProgress: 发送进度回调(0.0~1.0)。 + /// - Throws: 写入失败或负载过大等错误。 + func send( + type: UInt8, + data: Data, + onProgress: ((Double) -> Void)? = nil + ) async throws { + let maxWriteLen = client.maximumWriteLength + let maxDataSize = min( + maxWriteLen - FrameConstants.headerSize - FrameConstants.footerSize, + FrameConstants.maxDataSize + ) + let safeMaxDataSize = max(1, maxDataSize) + + let frames = ProtocolManager.createFrames( + type: type, + data: data, + head: .appToDevice, + maxDataSize: safeMaxDataSize + ) + + guard !frames.isEmpty else { + BleLog.e("Payload too large; frames empty", "Protocol") + throw DuooomiBleError.transferFailed("Payload too large: exceeds 65535-page limit") + } + + let total = frames.count + BleLog.d("Sending frames: total=\(total), maxChunk=\(safeMaxDataSize)", "Protocol") + for (index, frame) in frames.enumerated() { + try client.writeWithoutResponse(frame) + try await Task.sleep(nanoseconds: FrameConstants.frameIntervalNanos) + onProgress?(Double(index + 1) / Double(total)) + } + BleLog.i("Frames sent: total=\(total)", "Protocol") + } + + /// 发送 JSON 命令,内部完成编码与帧拆分。 + /// - Parameters: + /// - type: 命令类型。 + /// - payload: 可编码的命令体。 + /// - Throws: 编码/发送失败时抛出。 + func sendJSON(type: CommandType, payload: T) async throws { + let jsonData = try JSONEncoder().encode(payload) + try await send(type: type.rawValue, data: jsonData) + } + + // MARK: - Receive & Reassemble + + private func handleRawData(_ data: Data) { + guard let frame = ProtocolManager.parseFrame(data) else { + BleLog.w("Drop invalid frame (len=\(data.count))", "Protocol") + return + } + + if frame.subpageTotal > 0 { + handleFragment(frame) + } else { + BleLog.d("Single frame received: type=\(frame.type), len=\(frame.data.count)", "Protocol") + messageContinuation?.yield((commandType: frame.type, data: frame.data)) + } + } + + private func handleFragment(_ frame: ProtocolFrame) { + // Match TS reference behavior: scope reassembly per peripheral so concurrent transfers + // (or stale fragments from a previous device after reconnect) cannot pollute each other. + let deviceKey = client.connectedPeripheral?.identifier.uuidString ?? "unknown" + let key = "\(deviceKey)_\(frame.type)" + + if fragments[key] == nil { + fragments[key] = FragmentSession(total: Int(frame.subpageTotal), frames: [:]) + BleLog.d("New fragment session: key=\(key), total=\(frame.subpageTotal)", "Protocol") + } + + guard var session = fragments[key] else { return } + guard frame.curPage < session.total else { return } + + session.frames[Int(frame.curPage)] = frame.data + fragments[key] = session + + // Check if all pages received + guard session.frames.count == session.total else { return } + + // Reassemble: from high page to low page + var combined = Data() + for i in stride(from: session.total - 1, through: 0, by: -1) { + if let part = session.frames[i] { + combined.append(part) + } + } + + fragments.removeValue(forKey: key) + BleLog.i("Fragment reassembled: key=\(key), size=\(combined.count)", "Protocol") + messageContinuation?.yield((commandType: frame.type, data: combined)) + } +} diff --git a/Sources/DuooomiBleSDK/Services/DeviceInfoService.swift b/Sources/DuooomiBleSDK/Services/DeviceInfoService.swift new file mode 100644 index 0000000..4ac7b04 --- /dev/null +++ b/Sources/DuooomiBleSDK/Services/DeviceInfoService.swift @@ -0,0 +1,98 @@ +import Foundation + +/// 设备命令服务:发送 JSON 命令,等待协议响应 +final class DeviceInfoService { + private let protocolService: BleProtocolService + + init(protocolService: BleProtocolService) { + self.protocolService = protocolService + } + + // MARK: - Commands + + /// 发送获取设备信息命令。 + /// - Throws: 发送失败时抛出。 + func getDeviceInfo() async throws { + try await protocolService.sendJSON( + type: .getDeviceInfo, + payload: CommandPayload(type: CommandType.getDeviceInfo.rawValue) + ) + } + + /// 发送获取设备版本命令。 + /// - Throws: 发送失败时抛出。 + func getDeviceVersion() async throws { + try await protocolService.sendJSON( + type: .getDeviceVersion, + payload: CommandPayload(type: CommandType.getDeviceVersion.rawValue) + ) + } + + /// 发送绑定命令。 + /// - Parameter userId: 用户唯一标识。 + /// - Throws: 发送失败时抛出。 + func bindDevice(userId: String) async throws { + try await protocolService.sendJSON( + type: .bindDevice, + payload: BindPayload(type: CommandType.bindDevice.rawValue, userId: userId) + ) + } + + /// 发送解绑命令。 + /// - Parameter userId: 用户唯一标识。 + /// - Throws: 发送失败时抛出。 + func unbindDevice(userId: String) async throws { + try await protocolService.sendJSON( + type: .unbindDevice, + payload: BindPayload(type: CommandType.unbindDevice.rawValue, userId: userId) + ) + } + + /// 发送删除文件命令。 + /// - Parameter key: 文件 key 或完整 URL。 + /// - Throws: 发送失败时抛出。 + func deleteFile(key: String) async throws { + try await protocolService.sendJSON( + type: .deleteFile, + payload: FileKeyPayload(type: CommandType.deleteFile.rawValue, key: key) + ) + } + + /// 发送传输准备命令。 + /// - Parameters: + /// - key: 文件标识 key。 + /// - size: 文件大小(字节)。 + /// - Throws: 发送失败时抛出。 + func prepareTransfer(key: String, size: Int) async throws { + try await protocolService.sendJSON( + type: .prepareTransfer, + payload: PrepareTransferPayload( + type: CommandType.prepareTransfer.rawValue, + key: key, + size: size + ) + ) + } +} + +// MARK: - Command Payloads + +private struct CommandPayload: Encodable { + let type: UInt8 +} + +private struct BindPayload: Encodable { + let type: UInt8 + let userId: String +} + +private struct FileKeyPayload: Encodable { + let type: UInt8 + let key: String +} + +private struct PrepareTransferPayload: Encodable { + let type: UInt8 + let key: String + let size: Int +} diff --git a/Sources/DuooomiBleSDK/Services/FileTransferService.swift b/Sources/DuooomiBleSDK/Services/FileTransferService.swift new file mode 100644 index 0000000..5bf6bce --- /dev/null +++ b/Sources/DuooomiBleSDK/Services/FileTransferService.swift @@ -0,0 +1,48 @@ +import Foundation + +/// 文件传输服务:下载文件并通过协议发送二进制数据 +final class FileTransferService { + private let protocolService: BleProtocolService + + init(protocolService: BleProtocolService) { + self.protocolService = protocolService + } + + /// 传输文件到设备 + /// - Parameters: + /// - fileUri: 本地 file:// URL 或远程 https:// URL + /// - commandType: 传输命令类型 + /// - onProgress: 进度回调 0.0 ~ 1.0 + func transferFile( + fileUri: String, + commandType: CommandType, + onProgress: ((Double) -> Void)? = nil + ) async throws { + BleLog.i("Load file: \(fileUri)", "Transfer") + let data = try await loadFileData(from: fileUri) + BleLog.d("File loaded: size=\(data.count) bytes", "Transfer") + + try await protocolService.send( + type: commandType.rawValue, + data: data, + onProgress: onProgress + ) + } + + private func loadFileData(from uri: String) async throws -> Data { + guard let url = URL(string: uri) else { + throw DuooomiBleError.transferFailed("Invalid URL: \(uri)") + } + + if url.isFileURL { + return try Data(contentsOf: url) + } else { + let (data, response) = try await URLSession.shared.data(from: url) + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + throw DuooomiBleError.transferFailed("Failed to download file: \(uri)") + } + return data + } + } +} diff --git a/Sources/DuooomiBleSDK/Utils/BleLogger.swift b/Sources/DuooomiBleSDK/Utils/BleLogger.swift new file mode 100644 index 0000000..037c12c --- /dev/null +++ b/Sources/DuooomiBleSDK/Utils/BleLogger.swift @@ -0,0 +1,28 @@ +import Foundation + +enum BleLog { + static func d(_ message: @autoclosure () -> String, _ category: String = "General") { + #if DEBUG + print("[DuooomiBleSDK][\(category)] \(message())") + #endif + } + + static func i(_ message: @autoclosure () -> String, _ category: String = "General") { + #if DEBUG + print("ℹ️ [DuooomiBleSDK][\(category)] \(message())") + #endif + } + + static func w(_ message: @autoclosure () -> String, _ category: String = "General") { + #if DEBUG + print("⚠️ [DuooomiBleSDK][\(category)] \(message())") + #endif + } + + static func e(_ message: @autoclosure () -> String, _ category: String = "General") { + #if DEBUG + print("❌ [DuooomiBleSDK][\(category)] \(message())") + #endif + } +} + diff --git a/Tests/DuooomiBleSDKTests/PlaceholderTests.swift b/Tests/DuooomiBleSDKTests/PlaceholderTests.swift new file mode 100644 index 0000000..ff6cb92 --- /dev/null +++ b/Tests/DuooomiBleSDKTests/PlaceholderTests.swift @@ -0,0 +1,10 @@ +import XCTest +@testable import DuooomiBleSDK + +final class PlaceholderTests: XCTestCase { + func testExample() throws { + // Placeholder test to satisfy SwiftPM test target path. + XCTAssertTrue(true) + } +} + diff --git a/DuooomiTest/Info.plist b/demo/Info.plist similarity index 50% rename from DuooomiTest/Info.plist rename to demo/Info.plist index cc0dd52..dccd364 100644 --- a/DuooomiTest/Info.plist +++ b/demo/Info.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Duooomi Test + demo CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -18,8 +18,45 @@ APPL CFBundleShortVersionString 1.0.0 + CFBundleURLTypes + + + CFBundleURLSchemes + + duooomi + + + CFBundleVersion 1 + NSAppTransportSecurity + + NSExceptionDomains + + api.mixvideo.bowong.cc + + NSExceptionAllowsInsecureHTTPLoads + + NSExceptionMinimumTLSVersion + TLSv1.0 + NSExceptionRequiresForwardSecrecy + + NSIncludesSubdomains + + + cdn.bowong.cc + + NSExceptionAllowsInsecureHTTPLoads + + NSExceptionMinimumTLSVersion + TLSv1.0 + NSExceptionRequiresForwardSecrecy + + NSIncludesSubdomains + + + + NSBluetoothAlwaysUsageDescription This app needs Bluetooth to communicate with your device UILaunchScreen diff --git a/demo/Sources/AniConverter.swift b/demo/Sources/AniConverter.swift new file mode 100644 index 0000000..f07a3d7 --- /dev/null +++ b/demo/Sources/AniConverter.swift @@ -0,0 +1,84 @@ +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")! + + /// 专用 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) + }() + + /// 调用 ANI 转换接口,返回 ani 文件的 CDN URL。 + /// - Parameter videoUrl: 源视频的公网 URL。 + /// - Returns: 转换后 ani 文件的 CDN URL。 + /// - Throws: 网络异常、服务返回失败或响应解析失败等错误。 + /// - Note: 遇到 -1005(连接丢失)时自动重试一次。 + static func convert(videoUrl: String) async throws -> String { + do { + return try await performConvert(videoUrl: videoUrl) + } catch let error as NSError where error.code == NSURLErrorNetworkConnectionLost { + // -1005 重试一次 + return try await performConvert(videoUrl: videoUrl) + } + } + + /// 实际执行转换请求。 + /// - Parameter videoUrl: 源视频 URL。 + /// - Returns: ani 文件 URL。 + /// - Throws: 各类网络或业务错误。 + private static func performConvert(videoUrl: 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.timeoutInterval = 120 + + let body: [String: Any] = [ + "videoUrl": videoUrl, + "width": 360, + "height": 360, + "fps": "24", + ] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await session.data(for: request) + + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + throw AniConverterError(message: "HTTP \(http.statusCode)") + } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw AniConverterError(message: "Invalid JSON response") + } + + guard (json["success"] as? Bool) == true, + let outer = json["data"] as? [String: Any] else { + throw AniConverterError(message: "ANI API reported failure") + } + + if (outer["status"] as? Bool) != true { + let msg = (outer["msg"] as? String) ?? "ANI conversion failed" + throw AniConverterError(message: msg) + } + + guard let aniUrl = outer["data"] as? String, !aniUrl.isEmpty else { + throw AniConverterError(message: "Missing ani url in response") + } + + return aniUrl + } +} diff --git a/demo/Sources/DemoApp.swift b/demo/Sources/DemoApp.swift new file mode 100644 index 0000000..cad7cc5 --- /dev/null +++ b/demo/Sources/DemoApp.swift @@ -0,0 +1,16 @@ +import SwiftUI +import DuooomiBleSDK + +@main +struct DemoApp: App { + @StateObject private var sdk = DuooomiBleSDK() + + var body: some Scene { + WindowGroup { + NavigationView { + WrapperTestView() + } + .environmentObject(sdk) + } + } +} diff --git a/demo/Sources/FirmwareUpdateService.swift b/demo/Sources/FirmwareUpdateService.swift new file mode 100644 index 0000000..cdc49b2 --- /dev/null +++ b/demo/Sources/FirmwareUpdateService.swift @@ -0,0 +1,68 @@ +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 new file mode 100644 index 0000000..cb3010d --- /dev/null +++ b/demo/Sources/NetworkConstants.swift @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..e17bac5 --- /dev/null +++ b/demo/Sources/WrapperTestView.swift @@ -0,0 +1,526 @@ +import SwiftUI +import DuooomiBleSDK + +// MARK: - CDN Helper + +private enum CDNHelper { + static let host = "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 + } + + static func isImage(_ url: String) -> Bool { + let ext = URL(string: url)?.pathExtension.lowercased() ?? "" + return ["jpg", "jpeg", "png", "webp", "gif"].contains(ext) + } +} + +// MARK: - View + +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 firmwareInfo: FirmwareInfo? = nil + @State private var firmwareLoading = false + @State private var firmwareError: String? = nil + @State private var isBusy = false + @State private var connectingDeviceId: String? + @State private var deviceFiles: [String] = [] + @State private var logs: [LogLine] = [] + + struct LogLine: Identifiable { + let id = UUID() + let time = Date() + let level: Level + let message: String + enum Level { case info, success, error } + } + + var body: some View { + List { + statusSection + scanSection + deviceSection + fileSection + directAniSection + firmwareSection + deviceFilesSection + logSection + } + .navigationTitle("Native SDK Test") + .scrollDismissesKeyboard(.interactively) + .onTapGesture { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, from: nil, for: nil + ) + } + } + + // MARK: - Sections + + 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") + if let err = sdk.error { + Text(err).foregroundStyle(.red).font(.caption) + } + } + } + + private var scanSection: some View { + Section("Scan") { + HStack { + Button("Scan") { + sdk.scan() + log("→ scan()") + } + .buttonStyle(.borderedProminent) + + Button("Stop") { + sdk.stopScan() + log("→ stopScan()") + } + .buttonStyle(.bordered) + } + + ForEach(sdk.discoveredDevices) { device in + HStack { + VStack(alignment: .leading) { + Text(device.name ?? "Unknown") + Text(device.id).font(.caption2).foregroundStyle(.secondary) + } + Spacer() + Text("\(device.rssi) dBm") + .font(.caption) + .foregroundStyle(.secondary) + + if sdk.connectedDevice?.id == device.id { + Button("Disconnect") { + Task { await runDisconnect() } + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(.red) + } else if connectingDeviceId == device.id { + ProgressView().controlSize(.small) + } else { + Button("Connect") { + Task { await runConnect(deviceId: device.id) } + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(connectingDeviceId != nil) + } + } + } + } + } + + 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) + + HStack { + Button("getDeviceInfo") { Task { await runGetDeviceInfo() } } + Button("getVersion") { Task { await runGetVersion() } } + } + .buttonStyle(.bordered) + + HStack { + TextField("userId", text: $userId) + .textFieldStyle(.roundedBorder) + Button("bind") { Task { await runBind() } } + .buttonStyle(.bordered) + Button("unbind") { Task { await runUnbind() } } + .buttonStyle(.bordered) + } + } + } + + private var fileSection: 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) + .textFieldStyle(.roundedBorder) + .autocapitalization(.none) + .disableAutocorrection(true) + + Button("Convert → PrepareTransfer → Transfer") { + Task { await runConvertAndTransfer() } + } + .buttonStyle(.borderedProminent) + .disabled(videoUrl.isEmpty || sdk.connectedDevice == nil) + } + } + + 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 var firmwareSection: some View { + Section("Firmware Update") { + LabeledContent("Current Version", value: sdk.version.isEmpty ? "—" : sdk.version) + LabeledContent("Brand", value: sdk.deviceInfo?.brand ?? "—") + + 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) + } + } + } + + if let err = firmwareError { + Text(err).font(.caption).foregroundStyle(.red) + } + + HStack { + Button(firmwareLoading ? "Fetching..." : "Get Update Info") { + Task { await runFetchFirmwareInfo() } + } + .buttonStyle(.bordered) + .disabled(firmwareLoading || sdk.connectedDevice == nil || sdk.deviceInfo?.brand == nil) + + Button("Upgrade Firmware") { + Task { await runUpgradeFirmware() } + } + .buttonStyle(.borderedProminent) + .disabled(firmwareInfo?.fileUrl.isEmpty != false || sdk.connectedDevice == nil || isBusy) + } + } + } + + private var deviceFilesSection: some View { + Section("Device Files (\(deviceFiles.count))") { + if deviceFiles.isEmpty { + Text("Bind 后显示设备文件") + .font(.caption) + .foregroundStyle(.secondary) + } + ForEach(deviceFiles, id: \.self) { rawKey in + let fileUrl = CDNHelper.ensureFullUrl(rawKey) + HStack { + if CDNHelper.isImage(fileUrl), let url = URL(string: fileUrl) { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image.resizable().aspectRatio(contentMode: .fill) + case .failure: + Image(systemName: "photo").foregroundStyle(.secondary) + default: + ProgressView() + } + } + .frame(width: 50, height: 50) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } else { + Image(systemName: "film") + .frame(width: 50, height: 50) + .foregroundStyle(.secondary) + } + + Text(URL(string: fileUrl)?.lastPathComponent ?? fileUrl) + .font(.caption) + .lineLimit(1) + + Spacer() + + Button(role: .destructive) { + Task { await runDeleteFile(rawKey) } + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .disabled(isBusy) + } + } + } + } + + private var logSection: some View { + Section("Log (\(logs.count))") { + ForEach(logs.prefix(80)) { line in + Text(line.message) + .font(.caption) + .foregroundStyle(color(for: line.level)) + } + } + } + + 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) { + logs.insert(LogLine(level: level, message: message), at: 0) + if logs.count > 200 { logs.removeLast() } + } + + // MARK: - Async runners + + private func runConnect(deviceId: String) async { + connectingDeviceId = deviceId + defer { connectingDeviceId = nil } + log("→ connect(\(deviceId))") + do { + let device = try await sdk.connect(deviceId: deviceId) + log("connect ✓ \(device.name ?? device.id)", level: .success) + } catch { + log("connect ✗ \(error.localizedDescription)", level: .error) + } + } + + private func runDisconnect() async { + log("→ disconnect") + do { + try await sdk.disconnect() + deviceFiles = [] + log("disconnect ✓", level: .success) + } catch { + log("disconnect ✗ \(error.localizedDescription)", level: .error) + } + } + + 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 + ) + } catch { + log("getDeviceInfo ✗ \(error.localizedDescription)", level: .error) + } + } + + private func runGetVersion() async { + isBusy = true + defer { isBusy = false } + log("→ getVersion") + do { + let info = try await sdk.getVersion() + log("getVersion ✓ \(info.version)", level: .success) + } catch { + log("getVersion ✗ \(error.localizedDescription)", level: .error) + } + } + + private func runBind() async { + isBusy = true + defer { isBusy = false } + log("→ bind(\(userId))") + do { + let result = try await sdk.bind(userId: userId) + log("bind ✓ sn=\(result.sn) contents=\(result.contents.count)", level: .success) + deviceFiles = result.contents + } catch { + log("bind ✗ \(error.localizedDescription)", level: .error) + } + } + + private func runUnbind() async { + isBusy = true + defer { isBusy = false } + log("→ unbind(\(userId))") + do { + _ = try await sdk.unbind(userId: userId) + deviceFiles = [] + log("unbind ✓", level: .success) + } catch { + log("unbind ✗ \(error.localizedDescription)", level: .error) + } + } + + private func runConvertAndTransfer() async { + isBusy = true + defer { isBusy = false } + 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) + } 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) + } + } + + private func runDeleteFile(_ rawKey: String) async { + isBusy = true + defer { isBusy = false } + log("→ deleteFile(\(rawKey))") + do { + _ = try await sdk.deleteFile(key: rawKey) + log("deleteFile ✓", level: .success) + deviceFiles.removeAll { $0 == rawKey } + } catch { + log("deleteFile ✗ \(error.localizedDescription)", level: .error) + } + } + + private func runFetchFirmwareInfo() async { + guard let brand = sdk.deviceInfo?.brand, !brand.isEmpty else { + firmwareError = "No device brand" + return + } + firmwareLoading = true + firmwareError = nil + defer { firmwareLoading = false } + log("→ fetch firmware info for \(brand)") + do { + let info = try await FirmwareUpdateService.fetchLatest(brand: brand) + firmwareInfo = info + log("firmware latest: \(info.version)", level: .success) + } catch { + firmwareError = error.localizedDescription + log("firmware info ✗ \(error.localizedDescription)", level: .error) + } + } + + private func runUpgradeFirmware() async { + guard let url = firmwareInfo?.fileUrl, !url.isEmpty else { return } + isBusy = true + defer { isBusy = false } + log("→ OTA transfer") + do { + try await sdk.transferFile(fileUri: url, commandType: .otaPackage) + 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) + } +} diff --git a/project.yml b/project.yml index b0fe577..624989f 100644 --- a/project.yml +++ b/project.yml @@ -1,4 +1,4 @@ -name: DuooomiTest +name: demo options: bundleIdPrefix: com.duooomi deploymentTarget: @@ -8,31 +8,56 @@ options: settings: base: - SWIFT_VERSION: "6.0" + SWIFT_VERSION: "5.0" CODE_SIGN_STYLE: Automatic DEVELOPMENT_TEAM: "" +packages: {} + targets: - DuooomiTest: + DuooomiBleSDK: + type: framework + platform: iOS + sources: + - path: Sources/DuooomiBleSDK + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.duooomi.sdk + PRODUCT_NAME: DuooomiBleSDK + LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks @loader_path/Frameworks" + GENERATE_INFOPLIST_FILE: YES + demo: type: application platform: iOS sources: - - path: DuooomiTest/Sources + - path: demo/Sources info: - path: DuooomiTest/Info.plist + path: demo/Info.plist properties: - CFBundleDisplayName: Duooomi Test + CFBundleDisplayName: demo CFBundleShortVersionString: "1.0.0" CFBundleVersion: "1" NSBluetoothAlwaysUsageDescription: "This app needs Bluetooth to communicate with your device" + NSAppTransportSecurity: + NSExceptionDomains: + api.mixvideo.bowong.cc: + NSIncludesSubdomains: true + NSExceptionAllowsInsecureHTTPLoads: true + NSExceptionMinimumTLSVersion: TLSv1.0 + NSExceptionRequiresForwardSecrecy: false + cdn.bowong.cc: + NSIncludesSubdomains: true + NSExceptionAllowsInsecureHTTPLoads: true + NSExceptionMinimumTLSVersion: TLSv1.0 + NSExceptionRequiresForwardSecrecy: false UILaunchScreen: {} + CFBundleURLTypes: + - CFBundleURLSchemes: + - duooomi settings: base: - PRODUCT_BUNDLE_IDENTIFIER: com.duooomi.test - PRODUCT_NAME: DuooomiTest + PRODUCT_BUNDLE_IDENTIFIER: com.duooomi.demo + PRODUCT_NAME: demo LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks" dependencies: - - framework: ../duooomi-ble-sdk/artifacts/DuooomiBleSDK.xcframework - embed: true - - framework: ../duooomi-ble-sdk/artifacts/hermesvm.xcframework - embed: true + - target: DuooomiBleSDK