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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,6 +9,7 @@
|
||||
# User-specific Xcode data
|
||||
xcuserdata/
|
||||
*.xcuserstate
|
||||
.claude
|
||||
|
||||
# Build
|
||||
build/
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
27
Package.swift
Normal 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
261
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
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>This app needs Bluetooth to communicate with your device</string>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术规格
|
||||
|
||||
- **平台**: 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 (系统框架) |
|
||||
|
||||
341
Sources/DuooomiBleSDK/Core/BleClient.swift
Normal file
341
Sources/DuooomiBleSDK/Core/BleClient.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
69
Sources/DuooomiBleSDK/Core/BleTypes.swift
Normal file
69
Sources/DuooomiBleSDK/Core/BleTypes.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
484
Sources/DuooomiBleSDK/DuooomiBleSDK.swift
Normal file
484
Sources/DuooomiBleSDK/DuooomiBleSDK.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
11
Sources/DuooomiBleSDK/Models/BindingResponse.swift
Normal file
11
Sources/DuooomiBleSDK/Models/BindingResponse.swift
Normal 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]
|
||||
}
|
||||
7
Sources/DuooomiBleSDK/Models/DeleteFileResponse.swift
Normal file
7
Sources/DuooomiBleSDK/Models/DeleteFileResponse.swift
Normal file
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
public struct DeleteFileResponse: Codable, Sendable, Equatable {
|
||||
public let type: Int
|
||||
/// 0 = 成功, 1 = 失败, 2 = 文件不存在
|
||||
public let success: Int
|
||||
}
|
||||
56
Sources/DuooomiBleSDK/Models/DeviceInfo.swift
Normal file
56
Sources/DuooomiBleSDK/Models/DeviceInfo.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
6
Sources/DuooomiBleSDK/Models/UnbindResponse.swift
Normal file
6
Sources/DuooomiBleSDK/Models/UnbindResponse.swift
Normal file
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
public struct UnbindResponse: Codable, Sendable, Equatable {
|
||||
/// 1 = 成功, 0 = 失败
|
||||
public let success: Int
|
||||
}
|
||||
27
Sources/DuooomiBleSDK/Models/VersionInfo.swift
Normal file
27
Sources/DuooomiBleSDK/Models/VersionInfo.swift
Normal 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 = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
35
Sources/DuooomiBleSDK/Protocol/ProtocolConstants.swift
Normal file
35
Sources/DuooomiBleSDK/Protocol/ProtocolConstants.swift
Normal 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
|
||||
}
|
||||
20
Sources/DuooomiBleSDK/Protocol/ProtocolFrame.swift
Normal file
20
Sources/DuooomiBleSDK/Protocol/ProtocolFrame.swift
Normal 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 = 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
|
||||
}
|
||||
165
Sources/DuooomiBleSDK/Protocol/ProtocolManager.swift
Normal file
165
Sources/DuooomiBleSDK/Protocol/ProtocolManager.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
158
Sources/DuooomiBleSDK/Services/BleProtocolService.swift
Normal file
158
Sources/DuooomiBleSDK/Services/BleProtocolService.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
98
Sources/DuooomiBleSDK/Services/DeviceInfoService.swift
Normal file
98
Sources/DuooomiBleSDK/Services/DeviceInfoService.swift
Normal 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
|
||||
}
|
||||
48
Sources/DuooomiBleSDK/Services/FileTransferService.swift
Normal file
48
Sources/DuooomiBleSDK/Services/FileTransferService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
28
Sources/DuooomiBleSDK/Utils/BleLogger.swift
Normal file
28
Sources/DuooomiBleSDK/Utils/BleLogger.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
10
Tests/DuooomiBleSDKTests/PlaceholderTests.swift
Normal file
10
Tests/DuooomiBleSDKTests/PlaceholderTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Duooomi Test</string>
|
||||
<string>demo</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -18,8 +18,45 @@
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>duooomi</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<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>
|
||||
<string>This app needs Bluetooth to communicate with your device</string>
|
||||
<key>UILaunchScreen</key>
|
||||
84
demo/Sources/AniConverter.swift
Normal file
84
demo/Sources/AniConverter.swift
Normal 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
|
||||
}
|
||||
}
|
||||
16
demo/Sources/DemoApp.swift
Normal file
16
demo/Sources/DemoApp.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
68
demo/Sources/FirmwareUpdateService.swift
Normal file
68
demo/Sources/FirmwareUpdateService.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
10
demo/Sources/NetworkConstants.swift
Normal file
10
demo/Sources/NetworkConstants.swift
Normal 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"
|
||||
}
|
||||
|
||||
526
demo/Sources/WrapperTestView.swift
Normal file
526
demo/Sources/WrapperTestView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
49
project.yml
49
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
|
||||
|
||||
Reference in New Issue
Block a user