demo app + SDK updates:\n- Add direct ANI transfer (prepare -> transfer)\n- Add firmware update flow (fetch latest w/ x-api-key, OTA transfer)\n- Extract x-api-key to NetworkConstants\n- Rename example app to demo; move folder and entry to DemoApp\n- Switch from SPM package dependency to local framework target\n- Enable automatic Info.plist generation for framework target\n- Add placeholder test to satisfy SwiftPM test target\n- Regenerate XcodeGen project (demo.xcodeproj)

This commit is contained in:
km2023
2026-04-09 18:41:56 +08:00
parent c33e7d3fa1
commit 7d9ff3081d
30 changed files with 2594 additions and 516 deletions

1
.gitignore vendored
View File

@@ -9,6 +9,7 @@
# User-specific Xcode data # User-specific Xcode data
xcuserdata/ xcuserdata/
*.xcuserstate *.xcuserstate
.claude
# Build # Build
build/ build/

View File

@@ -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"
}
}

View File

@@ -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)
}
}
}

View File

@@ -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<AnyCancellable>()
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() }
}
}

27
Package.swift Normal file
View File

@@ -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"
)
]
)

261
README.md
View File

@@ -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 配置 调用方 (宿主 App)
├── DuooomiTest/
── Info.plist ── DuooomiBleSDK (ObservableObject) ← 公开 API + 状态观察
│ └── Sources/
├── DuooomiTestApp.swift # App 入口 ├── BleClient ← CoreBluetooth 封装
├── ContentView.swift # 测试界面 │ CBCentralManager / CBPeripheral
└── SDKManager.swift # SDK 封装层
├── 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
<key>NSBluetoothAlwaysUsageDescription</key>
<string>This app needs Bluetooth to communicate with your device</string>
```
---
## 技术规格
- **平台**: iOS 16.0+
- **语言**: Swift 5.9+
- **依赖**: 零 (仅 CoreBluetooth 系统框架)
- **分发**: SPM / XCFramework
- **线程模型**: 公开 API 为 @MainActorBLE 操作在专用串行队列
## 与旧 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 (系统框架) |

View File

@@ -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<CBPeripheral, Error>?
private var disconnectContinuation: CheckedContinuation<Void, Error>?
// MARK: - Streams
private var scanContinuation: AsyncStream<DiscoveredDevice>.Continuation?
private var notificationContinuation: AsyncStream<Data>.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<DiscoveredDevice> {
stopScan()
discoveredPeripherals.removeAll()
return AsyncStream { continuation in
self.scanContinuation = continuation
continuation.onTermination = { [weak self] _ in
self?.queue.async {
self?.centralManager.stopScan()
self?.scanContinuation = nil
}
}
self.queue.async {
guard self.centralManager.state == .poweredOn else { return }
BleLog.i("CBCentral startScan", "BLE")
self.centralManager.scanForPeripherals(
withServices: serviceUUIDs,
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
)
}
}
}
///
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<Void, Error>) in
self.disconnectContinuation = continuation
self.queue.async {
BleLog.i("Cancel connection: \(peripheral.identifier.uuidString)", "BLE")
self.centralManager.cancelPeripheralConnection(peripheral)
}
// Timeout for disconnect
self.queue.asyncAfter(deadline: .now() + 5) { [weak self] in
if let cont = self?.disconnectContinuation {
self?.disconnectContinuation = nil
BleLog.w("Force-finish disconnect after timeout", "BLE")
self?.cleanup()
cont.resume()
}
}
}
}
/// GATT Write Without Response
/// - 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<Data> {
AsyncStream { continuation in
self.notificationContinuation = continuation
continuation.onTermination = { [weak self] _ in
self?.notificationContinuation = nil
}
BleLog.i("Notifications stream attached", "BLE")
}
}
// MARK: - Private
private func cleanup() {
if let readChar = readCharacteristic, let peripheral = connectedPeripheral {
peripheral.setNotifyValue(false, for: readChar)
}
connectedPeripheral = nil
writeCharacteristic = nil
readCharacteristic = nil
notificationContinuation?.finish()
notificationContinuation = nil
BleLog.i("Client state cleaned up", "BLE")
}
}
// MARK: - CBCentralManagerDelegate
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)
}
}

View File

@@ -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"
}
}
}

View File

@@ -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<Data, Error>] = [:]
private var messageListenerTask: Task<Void, Never>?
// MARK: - Scan Batching
private var scanTask: Task<Void, Never>?
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<T: Decodable>(_ 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) ?? "<non-utf8, \(cleaned.count) bytes>"
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")
}
}

View File

@@ -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]
}

View File

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

View File

@@ -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<CodingKeys>,
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
}
}

View File

@@ -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
}

View File

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

View File

@@ -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 = ""
}
}
}

View File

@@ -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
}

View File

@@ -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 = APPDevice, 0xB0 = DeviceAPP)
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
}

View File

@@ -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..<totalPages {
let start = i * maxDataSize
let end = min(start + maxDataSize, data.count)
let chunk = data[start..<end]
var buffer = Data(capacity: FrameConstants.headerSize + chunk.count + FrameConstants.footerSize)
buffer.append(head.rawValue)
buffer.append(type)
// subpageTotal (big-endian UInt16)
buffer.append(UInt8((totalPages >> 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..<dataEnd]
let checksum = data[dataEnd]
// Verify checksum on everything before checksum byte
let dataToCheck = data[data.startIndex..<dataEnd]
guard verifyChecksum(dataToCheck, expected: checksum) else {
return nil
}
return ProtocolFrame(
head: head,
type: type,
subpageTotal: subpageTotal,
curPage: curPage,
dataLen: dataLen,
data: Data(frameData),
checksum: checksum
)
}
}

View File

@@ -0,0 +1,158 @@
import Foundation
///
final class BleProtocolService: @unchecked Sendable {
private let client: BleClient
///
private struct FragmentSession {
let total: Int
var frames: [Int: Data] // curPage -> data
}
private var fragments: [String: FragmentSession] = [:]
private var listenerTask: Task<Void, Never>?
private var messageContinuation: AsyncStream<(commandType: UInt8, data: Data)>.Continuation?
private(set) var incomingMessages: AsyncStream<(commandType: UInt8, data: Data)>!
init(client: BleClient) {
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<T: Encodable>(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))
}
}

View File

@@ -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
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Duooomi Test</string> <string>demo</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -18,8 +18,45 @@
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0.0</string> <string>1.0.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>duooomi</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1</string> <string>1</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>api.mixvideo.bowong.cc</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSExceptionMinimumTLSVersion</key>
<string>TLSv1.0</string>
<key>NSExceptionRequiresForwardSecrecy</key>
<false/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
<key>cdn.bowong.cc</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSExceptionMinimumTLSVersion</key>
<string>TLSv1.0</string>
<key>NSExceptionRequiresForwardSecrecy</key>
<false/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
<key>NSBluetoothAlwaysUsageDescription</key> <key>NSBluetoothAlwaysUsageDescription</key>
<string>This app needs Bluetooth to communicate with your device</string> <string>This app needs Bluetooth to communicate with your device</string>
<key>UILaunchScreen</key> <key>UILaunchScreen</key>

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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"
}

View File

@@ -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)
}
}

View File

@@ -1,4 +1,4 @@
name: DuooomiTest name: demo
options: options:
bundleIdPrefix: com.duooomi bundleIdPrefix: com.duooomi
deploymentTarget: deploymentTarget:
@@ -8,31 +8,56 @@ options:
settings: settings:
base: base:
SWIFT_VERSION: "6.0" SWIFT_VERSION: "5.0"
CODE_SIGN_STYLE: Automatic CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: "" DEVELOPMENT_TEAM: ""
packages: {}
targets: 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 type: application
platform: iOS platform: iOS
sources: sources:
- path: DuooomiTest/Sources - path: demo/Sources
info: info:
path: DuooomiTest/Info.plist path: demo/Info.plist
properties: properties:
CFBundleDisplayName: Duooomi Test CFBundleDisplayName: demo
CFBundleShortVersionString: "1.0.0" CFBundleShortVersionString: "1.0.0"
CFBundleVersion: "1" CFBundleVersion: "1"
NSBluetoothAlwaysUsageDescription: "This app needs Bluetooth to communicate with your device" 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: {} UILaunchScreen: {}
CFBundleURLTypes:
- CFBundleURLSchemes:
- duooomi
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: com.duooomi.test PRODUCT_BUNDLE_IDENTIFIER: com.duooomi.demo
PRODUCT_NAME: DuooomiTest PRODUCT_NAME: demo
LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks" LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks"
dependencies: dependencies:
- framework: ../duooomi-ble-sdk/artifacts/DuooomiBleSDK.xcframework - target: DuooomiBleSDK
embed: true
- framework: ../duooomi-ble-sdk/artifacts/hermesvm.xcframework
embed: true