BleLog 之前所有调用都无条件 print(Debug 包),集成方没法关。新增 public enum BleLogLevel(off/error/warn/info/debug),通过 DuooomiBleConfig.logLevel 传入,DuooomiBleSDK.init 第一行写入 BleLog.level。默认 .off 保证对外集成默认静默,排查时显式开 .debug。 保留 #if DEBUG 兜底,Release 任何 level 都不输出。 frameIntervalMs 越界 warn 从 DuooomiBleConfig.init 挪到 DuooomiBleSDK.init 内、level 设置之后,避免在 level 写入前丢日志。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
372 lines
15 KiB
Markdown
372 lines
15 KiB
Markdown
# DuooomiBleSDK (iOS)
|
||
|
||
纯原生 Swift BLE SDK,零第三方依赖。
|
||
|
||
> ⚠️ **请先跑通仓库内 demo 再开始对接 SDK。** demo 已串完整链路(扫描 / 连接 / 绑定 / OTA / 文件传输),是验证设备 + apiKey + brand 是否对的最快途径,避免在自家工程里盲调试。
|
||
|
||
## 集成
|
||
|
||
### CocoaPods
|
||
|
||
```ruby
|
||
pod 'DuooomiBleSDK', :git => 'https://gitea.bowong.cc/comi-logic/duooomi-ios-sdk.git', :branch => 'main'
|
||
```
|
||
|
||
然后执行 `pod install`。
|
||
|
||
### Swift Package Manager
|
||
|
||
```swift
|
||
// Package.swift
|
||
dependencies: [
|
||
.package(url: "https://gitea.bowong.cc/comi-logic/duooomi-ios-sdk.git", branch: "main")
|
||
]
|
||
```
|
||
|
||
## Demo 初始化(仓库内 demo 目录)
|
||
|
||
`apiKey` / `brand` / `scanNamePrefix` 不进 git,由本地 `demo/Sources/DemoSecrets.swift` 提供:
|
||
|
||
```bash
|
||
cp demo/Sources/DemoSecrets.template.swift demo/Sources/DemoSecrets.swift
|
||
# 然后编辑 DemoSecrets.swift 填真实 apiKey
|
||
pod install
|
||
open demo.xcworkspace
|
||
```
|
||
|
||
`DemoSecrets.swift` 已加入 `.gitignore`,本地修改不会被 commit。
|
||
|
||
## 初始化
|
||
|
||
```swift
|
||
let sdk = DuooomiBleSDK(config: .init(
|
||
apiKey: "your-api-key", // 必填,Loomart x-api-key
|
||
brand: "your-brand", // 必填,设备品牌名(所有 RPC 必带 x-owner header)
|
||
scanNamePrefix: "蓝牙广播名前缀" // 必填,大小写不敏感
|
||
// apiHost: URL(...)!, // 默认 https://api.duooomi.com
|
||
// cdnHost: "https://cdn.bowong.cc/", // 默认
|
||
// aniWidth: 360, aniHeight: 360, aniFps: "24", // ANI 转换默认参数
|
||
// frameIntervalMs: 35, // 协议帧间隔(ms),合法 [0,500],越界 warn 后 clamp
|
||
// logLevel: .off, // 日志等级,默认 .off(完全静默),排查时改 .debug
|
||
))
|
||
|
||
sdk.delegate = self
|
||
```
|
||
|
||
### 日志(BleLogLevel)
|
||
|
||
SDK 内部所有日志统一走 `BleLog` → `print`,分级 `off / error / warn / info / debug`。集成方通过 `config.logLevel` 控制输出:
|
||
|
||
```swift
|
||
// 对外发布:什么都不传,默认完全静默
|
||
DuooomiBleSDK(config: .init(apiKey: ..., brand: ..., scanNamePrefix: ...))
|
||
|
||
// 本地排查:开 debug 看到所有 BLE 帧、RPC opId、传输进度等
|
||
DuooomiBleSDK(config: .init(apiKey: ..., brand: ..., scanNamePrefix: ..., logLevel: .debug))
|
||
```
|
||
|
||
- 等级单调:`.warn` 会输出 warn + error;`.info` 输出 info + warn + error;以此类推。
|
||
- **Release 构建因 `#if DEBUG` 条件编译,任何 `logLevel` 都不会输出**(双保险)。
|
||
- 日志按 category 分桶:`SDK / Scan / Connect / BLE / RPC / Command / Transfer / Firmware / Protocol / Decode / Config`,Xcode console 可按 `[DuooomiBleSDK][<category>]` 过滤。
|
||
|
||
## SDK 能力范围
|
||
|
||
- **内置**:BLE 协议(扫描/连接/绑定/解绑/播放模式/文件传输/OTA 烧录)+ 固件版本检查
|
||
- **不内置**:UI、retry 策略;SDK 只提供原子方法和状态属性,由集成方组合
|
||
|
||
### 绑定与固件升级流程
|
||
|
||
1. **`bind(userId:)`** — 绑定设备,完成后 `sn` / `version` / `isActivated` 就绪。
|
||
2. **`fetchLatestFirmware(sn:currentVersion:userId:)`** — 拉服务端最新固件信息,写入 `firmwareUpgrade`。
|
||
3. **`upgradeFirmware(...)`** — 烧录固件包,进度通过 `didUpdateProgress` 回调。
|
||
|
||
解绑:`unbind(userId:)` 一步完成。
|
||
|
||
### API 调用风格
|
||
|
||
所有公开方法**显式传入参**,不依赖 SDK 内部状态自动取参。调用方从 SDK 只读属性(`sdk.sn` / `sdk.version` / `sdk.firmwareUpgrade?.targetFirmware?.*` 等)组装参数后传入。
|
||
|
||
## 代理(Delegate)
|
||
|
||
通过 `DuooomiBleSDKDelegate` 接收状态变化,所有方法在主线程调用,均有默认空实现:
|
||
|
||
```swift
|
||
extension ViewController: DuooomiBleSDKDelegate {
|
||
func sdk(_ sdk: DuooomiBleSDK, didChangeState state: ConnectionState) { }
|
||
func sdk(_ sdk: DuooomiBleSDK, didUpdateDevices devices: [DiscoveredDevice]) { }
|
||
func sdk(_ sdk: DuooomiBleSDK, didConnectDevice device: DiscoveredDevice?) { }
|
||
func sdk(_ sdk: DuooomiBleSDK, didUpdateDeviceInfo info: DeviceInfo?) { }
|
||
func sdk(_ sdk: DuooomiBleSDK, didUpdateVersion version: String) { }
|
||
func sdk(_ sdk: DuooomiBleSDK, didChangeActivation isActivated: Bool) { }
|
||
func sdk(_ sdk: DuooomiBleSDK, didUpdateProgress progress: Int) { }
|
||
func sdk(_ sdk: DuooomiBleSDK, didEncounterError error: String?) { }
|
||
}
|
||
```
|
||
|
||
## API
|
||
|
||
所有异步方法使用 `completion` 回调,返回 `Result<T, Error>`。
|
||
|
||
### 扫描
|
||
|
||
| 方法 | 说明 |
|
||
|------|------|
|
||
| `scan()` | 开始扫描,结果通过 delegate `didUpdateDevices` 回调 |
|
||
| `stopScan()` | 停止扫描 |
|
||
|
||
### 连接
|
||
|
||
```swift
|
||
sdk.connect(deviceId: deviceId) { result in
|
||
switch result {
|
||
case .success(let device): print("Connected: \(device.name ?? device.id)")
|
||
case .failure(let error): print("Failed: \(error)")
|
||
}
|
||
}
|
||
|
||
sdk.disconnect { result in
|
||
// ...
|
||
}
|
||
```
|
||
|
||
### 设备命令
|
||
|
||
```swift
|
||
sdk.getDeviceInfo { result in
|
||
if case .success(let info) = result {
|
||
print("Brand: \(info.brand), Power: \(info.powerlevel)%")
|
||
}
|
||
}
|
||
|
||
sdk.getVersion { result in
|
||
if case .success(let info) = result {
|
||
print("Version: \(info.version)")
|
||
}
|
||
}
|
||
|
||
// 绑定设备 + 同步拉版本
|
||
sdk.bind(userId: "user-id") { result in
|
||
if case .success(let resp) = result {
|
||
print("SN: \(resp.sn), Files: \(resp.contents.count)")
|
||
// 此时 sdk.sn / sdk.version / sdk.isActivated 已就绪
|
||
}
|
||
}
|
||
|
||
// 解绑
|
||
sdk.unbind(userId: "user-id") { result in /* ... */ }
|
||
sdk.deleteFile(key: "file-key") { result in /* ... */ }
|
||
```
|
||
|
||
#### 播放模式(Play Mode)
|
||
|
||
> ⚠️ **前置条件 — 必须先绑定。** `setPlayMode` 复用 `BIND_DEVICE` (0x0F) 命令通道,固件设计上要求设备先和该 `userId` 完成 `bind`(即 SDK `isActivated == true`)才能切播放模式。SDK 在客户端做了守卫:未绑定时直接 fail,不会发命令。调用方在 `isActivated == true` 之前不应调用此方法。
|
||
|
||
```swift
|
||
// 0 = 单播(播完一次停)/ 1 = 循环播放
|
||
sdk.setPlayMode(.loop, userId: "user-id") { result in
|
||
switch result {
|
||
case .success:
|
||
// 成功后 SDK 自动刷新 deviceInfo,
|
||
// 通过 didUpdateDeviceInfo 回调拿到最新 info.loop
|
||
break
|
||
case .failure(let error):
|
||
print("setPlayMode failed: \(error)")
|
||
}
|
||
}
|
||
```
|
||
|
||
**注意事项:**
|
||
- `bind` / `unbind` / `setPlayMode` 共享 `0x0F` / `0x12` 响应通道,**不要并发调用**
|
||
- 旧固件可能不支持 `loop` 字段 —— 此时 `DeviceInfo.loop` 为 `nil`,调用 `setPlayMode` 设备可能回 `success=0`
|
||
|
||
### 文件传输
|
||
|
||
```swift
|
||
// 一步完成:格式校验 → ANI 转换 → prepare → transfer
|
||
sdk.transferMedia(fileUrl: "https://example.com/video.mp4") { result in
|
||
switch result {
|
||
case .success: print("Transfer complete")
|
||
case .failure(let error): print("Transfer failed: \(error)")
|
||
}
|
||
}
|
||
// 进度通过 delegate didUpdateProgress 回调
|
||
```
|
||
|
||
**支持的媒体格式:** JPEG、PNG、GIF、MP4、WebM、WebP
|
||
|
||
格式校验规则:
|
||
- 优先从 URL path 提取扩展名(自动忽略查询参数)
|
||
- 无扩展名时通过 HEAD 请求检查 `Content-Type`
|
||
- 不支持的格式返回 `DuooomiBleError.unsupportedFormat`
|
||
|
||
### 固件升级
|
||
|
||
完整流程:**扫描 → 连接 → bind → fetchLatestFirmware → upgradeFirmware**。设备重启重连后再次走一遍即可。
|
||
|
||
```swift
|
||
// ─── 1. 扫描 + 连接 ───
|
||
sdk.scan()
|
||
// 在 didUpdateDevices 拿到列表后选一台 connect
|
||
sdk.connect(deviceId: device.id) { result in
|
||
guard case .success = result else { return }
|
||
|
||
// ─── 2. 绑定 ───
|
||
sdk.bind(userId: "user-123") { result in
|
||
guard case .success(let resp) = result else { return }
|
||
// 此时 sdk.sn = resp.sn / sdk.version 已就绪 / sdk.isActivated = true
|
||
|
||
// ─── 3. 拉最新固件信息 ───
|
||
sdk.fetchLatestFirmware(
|
||
sn: sdk.sn,
|
||
currentVersion: sdk.version,
|
||
userId: "user-123"
|
||
) { result in
|
||
switch result {
|
||
case .failure(let err):
|
||
print("拉取失败:\(err.localizedDescription)")
|
||
return
|
||
case .success(let info):
|
||
// info 同步写入 sdk.firmwareUpgrade
|
||
guard let target = info.targetFirmware,
|
||
let fileUrl = target.fileUrl else {
|
||
print("无可升级固件")
|
||
return
|
||
}
|
||
|
||
// ─── 4. 烧录 ───
|
||
sdk.upgradeFirmware(
|
||
sn: sdk.sn,
|
||
fileUrl: fileUrl,
|
||
firmwareId: target.id,
|
||
fromVersion: sdk.version,
|
||
targetVersion: target.version
|
||
) { result in
|
||
switch result {
|
||
case .success:
|
||
// OTA 包发完,设备开始烧录并重启
|
||
print("烧录命令完成,等待设备重启")
|
||
case .failure(let err):
|
||
print("烧录失败:\(err.localizedDescription)")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 可读取状态
|
||
|
||
| 属性 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `btState` | `ConnectionState` | idle / scanning / connecting / connected / disconnecting / disconnected |
|
||
| `discoveredDevices` | `[DiscoveredDevice]` | 扫描到的设备列表 |
|
||
| `connectedDevice` | `DiscoveredDevice?` | 当前连接设备 |
|
||
| `deviceInfo` | `DeviceInfo?` | 设备信息 |
|
||
| `version` | `String` | 固件版本 |
|
||
| `isActivated` | `Bool` | 是否已绑定 |
|
||
| `transferProgress` | `Int` | 传输进度 0-100 |
|
||
| `error` | `String?` | 最近错误 |
|
||
|
||
### 汇总:可用方法与属性
|
||
|
||
> 一张表速查所有 public API。异步方法的失败分支均为 `Result` 的 `.failure(Error)`,`Error` 多为 `DuooomiBleError`,少数透传 `DecodingError` / `URLError`(详见各方法注释)。
|
||
|
||
#### 方法
|
||
|
||
| 方法 | 参数 | 返回 | 说明 |
|
||
|------|------|------|------|
|
||
| `scan()` | — | 无(`didUpdateDevices` 回调) | 开始扫描 |
|
||
| `stopScan()` | — | 无 | 停止扫描 |
|
||
| `connect(deviceId:completion:)` | `deviceId: String` | `Result<DiscoveredDevice, Error>` | 连接设备 |
|
||
| `disconnect(completion:)` | — | `Result<Void, Error>` | 断开连接 |
|
||
| `getConnectedDevices()` | — | `[DiscoveredDevice]`(同步) | 当前已连接设备列表 |
|
||
| `bind(userId:completion:)` | `userId: String` | `Result<BindingResponse, Error>` | 绑定 + 同步拉版本,成功后 `sn`/`version`/`isActivated` 就绪 |
|
||
| `unbind(userId:completion:)` | `userId: String` | `Result<UnbindResponse, Error>` | 解绑 |
|
||
| `getDeviceInfo(completion:)` | — | `Result<DeviceInfo, Error>` | 读品牌/电量/存储/loop,写入 `deviceInfo` |
|
||
| `getVersion(completion:)` | — | `Result<VersionInfo, Error>` | 读固件版本,写入 `version`/`versionLog` |
|
||
| `setPlayMode(_:userId:completion:)` | `_ mode: PlayMode`, `userId: String` | `Result<BindingResponse, Error>` | 切单播/循环;**须先 bind** |
|
||
| `deleteFile(key:completion:)` | `key: String` | `Result<DeleteFileResponse, Error>` | 删除设备文件 |
|
||
| `prepareTransfer(key:size:completion:)` | `key: String`, `size: Int` | `Result<PrepareTransferResponse, Error>` | 传输前预协商(一般无需直调) |
|
||
| `transferMedia(fileUrl:completion:)` | `fileUrl: String` | `Result<Void, Error>` | 一步:校验→ANI 转换→prepare→传输 |
|
||
| `transferFile(fileUri:commandType:completion:)` | `fileUri: String`, `commandType: CommandType = .transferAniVideo` | `Result<Void, Error>` | 底层文件传输 |
|
||
| `fetchLatestFirmware(sn:currentVersion:userId:completion:)` | `sn: String`, `currentVersion: String`, `userId: String` | `Result<FirmwareUpgradeCheckResult, Error>` | 对账上报 + 拉最新固件,写入 `firmwareUpgrade` |
|
||
| `upgradeFirmware(sn:fileUrl:firmwareId:fromVersion:targetVersion:completion:)` | `sn`, `fileUrl`, `firmwareId`, `fromVersion`, `targetVersion`: `String` | `Result<Void, Error>` | OTA 烧录;下发前校验实时电量,**< 40% 返回 `.batteryTooLow`,不发任何字节** |
|
||
| `tryReportPendingUpgrade(sn:currentVersion:userId:)` | `sn: String`, `currentVersion: String`, `userId: String` | 无(fire-and-forget,结果见 `upgradeRecords`) | 对账上报本地待报记录(`fetchLatestFirmware` 已内置调用) |
|
||
|
||
#### 属性(除 `delegate` 外均只读)
|
||
|
||
| 属性 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `delegate` | `DuooomiBleSDKDelegate?` | 事件回调代理(weak,可读写) |
|
||
| `config` | `DuooomiBleConfig` | 初始化配置(`let`) |
|
||
| `btState` | `ConnectionState` | idle / scanning / connecting / connected / disconnecting / disconnected |
|
||
| `discoveredDevices` | `[DiscoveredDevice]` | 扫描到的设备列表 |
|
||
| `connectedDevice` | `DiscoveredDevice?` | 当前连接设备 |
|
||
| `deviceInfo` | `DeviceInfo?` | 设备信息(品牌/电量/存储/loop) |
|
||
| `version` | `String` | 固件版本(bind 后就绪) |
|
||
| `versionLog` | `[String: Any]?` | 设备版本附带 JSON(透传给 metadata 上报) |
|
||
| `isActivated` | `Bool` | 是否已绑定 |
|
||
| `sn` | `String` | 设备序列号(bind 成功后就绪) |
|
||
| `transferProgress` | `Int` | 传输进度 0–100 |
|
||
| `error` | `String?` | 最近错误描述 |
|
||
| `firmwareUpgrade` | `FirmwareUpgradeCheckResult?` | 最近一次 `fetchLatestFirmware` 结果 |
|
||
| `upgradeRecords` | `[UpgradeRecord]` | 本地 OTA 对账记录(computed) |
|
||
|
||
### 数据类型
|
||
|
||
```swift
|
||
// FirmwareUpgradeCheckResult — fetchLatestFirmware 返回,同步写入 sdk.firmwareUpgrade
|
||
public struct FirmwareUpgradeCheckResult {
|
||
let upgradeAvailable: Bool
|
||
let reason: FirmwareUpgradeCheckReason // UPGRADE_AVAILABLE / ALREADY_LATEST / DEVICE_NOT_FOUND ...
|
||
let currentSystemVersion: String?
|
||
let targetFirmware: TargetFirmware?
|
||
}
|
||
|
||
public struct TargetFirmware {
|
||
let id: String // firmwareId(对账时使用)
|
||
let version: String // 目标版本
|
||
let fileUrl: String? // 固件下载地址
|
||
let fileSize: String? // 字节数(字符串)
|
||
let fileMd5: String?
|
||
}
|
||
|
||
// PlayMode — 播放模式
|
||
public enum PlayMode: Int {
|
||
case single = 0 // 单播
|
||
case loop = 1 // 循环播放
|
||
}
|
||
|
||
// DeviceInfo — getDeviceInfo 返回
|
||
public struct DeviceInfo {
|
||
let name: String // 产品名
|
||
let brand: String // 品牌
|
||
let size: String // 屏幕尺寸
|
||
let powerlevel: Int // 电量百分比
|
||
let allspace: UInt64 // 总存储(字节)
|
||
let freespace: UInt64 // 可用存储(字节)
|
||
let loop: PlayMode? // 播放模式(旧固件无此字段时为 nil)
|
||
}
|
||
|
||
// BindingResponse — bind 返回
|
||
public struct BindingResponse {
|
||
let success: Int // 1=成功
|
||
let sn: String // 设备序列号
|
||
let contents: [String] // 设备文件 key 列表
|
||
}
|
||
```
|
||
|
||
## Info.plist
|
||
|
||
```xml
|
||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||
<string>This app needs Bluetooth to communicate with your device</string>
|
||
```
|
||
|
||
## 技术规格
|
||
|
||
- iOS 12.2+ / Swift 5.0+ / 零依赖
|
||
- Delegate 回调模式,所有状态变化在主线程通知
|
||
- BLE 操作专用串行队列
|