Files
duooomi-android-sdk/README.md
km2023 f7e1a4769f docs(readme): 初始化 SDK 示例补齐所有默认值注释行(对齐 iOS)
之前只列了 3 个必填 + logLevel,apiHost / cdnHost / ani* / frameIntervalMs
四组默认值的注释行漏了,与 iOS README 不对齐。补齐 + 把 brand /
scanNamePrefix 措辞改成与 iOS 一致("必填,Loomart x-api-key" /
"设备品牌名(所有 RPC 必带 x-owner header)" / "大小写不敏感")。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:28:41 +08:00

340 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Duooomi Android BLE SDK
Duooomi 蓝牙 BLE SDK 的 Android 版本(与 iOS / expo 版业务行为 1:1 对齐)。
> ⚠️ **请先跑通仓库内 demo 再开始对接 SDK。** demo 已串完整链路(扫描 / 连接 / 绑定 / OTA / 文件传输),是验证设备 + apiKey + brand 是否对的最快途径,避免在自家工程里盲调试。
## 环境要求
**SDK 集成要求(消费方最低版本):**
| 项目 | 最低要求 |
|------|---------|
| Kotlin | 1.9.0+ |
| AGP | 7.4.2+ |
| minSdk | 23 (Android 6.0) |
| JDK | 11+ |
**SDK 开发构建环境:**
| 项目 | 要求 |
|------|------|
| Android Studio | Hedgehog (2023.1.1) 或更高 |
| Kotlin | 1.9.22 |
| AGP | 8.2.2 |
| Gradle | 8.x |
| JDK | 17 |
| compileSdk / targetSdk | 34 |
## 项目结构
```
duooomi-android-sdk/
├── sdk/ # SDK 库模块 (com.android.library)
│ └── src/main/kotlin/com/duooomi/ble/
│ ├── DuooomiBleSDK.kt # 公开 API 入口
│ ├── DuooomiBleConfig.kt # SDK 配置
│ ├── core/ # BLE 核心层 (BleClient, 类型定义, 错误类型)
│ ├── protocol/ # BLE 协议层 (帧格式, UUID, 协议管理)
│ ├── services/ # 服务层 (设备命令 / OTA / HTTP)
│ ├── models/ # 数据模型
│ └── utils/ # 北京时间 / 日志
├── demo/ # 演示 App (Jetpack Compose)
└── build.gradle.kts
```
## 依赖
SDK 仅依赖一个外部库:
```
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3
```
其余全部使用 Android 内置 APIBLE、JSON、HttpURLConnection、SharedPreferences
## 快速开始
### 1. 引入 SDK
`settings.gradle.kts`
```kotlin
include(":sdk")
```
app 模块的 `build.gradle.kts`
```kotlin
dependencies {
implementation(project(":sdk"))
}
```
### 2. 添加权限
`AndroidManifest.xml`
```xml
<!-- Android 11 及以下 -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<!-- Android 12+ -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- 网络 (ANI 转换 / OTA) -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
```
> 运行时仍需动态申请 `BLUETOOTH_SCAN`、`BLUETOOTH_CONNECT`Android 12+)和 `ACCESS_FINE_LOCATION`。
### 3. 初始化 SDK
```kotlin
val sdk = DuooomiBleSDK(
context = this,
config = DuooomiBleConfig(
apiKey = "YOUR_API_KEY", // 必填Loomart x-api-key
brand = "YOUR_BRAND", // 必填,设备品牌名(所有 RPC 必带 x-owner header
scanNamePrefix = "蓝牙广播名前缀" // 必填,大小写不敏感
// apiHost = "https://api.duooomi.com", // 默认
// cdnHost = "https://cdn.bowong.cc/", // 默认
// aniWidth = 360, aniHeight = 360, aniFps = "24", // ANI 转换默认参数
// frameIntervalMs = 35, // 协议帧间隔(ms),合法 [0,500],越界 warn 后 clamp
// logLevel = BleLogLevel.OFF, // 日志等级,默认 OFF完全静默排查时改 DEBUG
)
)
```
### 4. Config 字段说明
| 参数 | 必填 | 说明 |
|------|:----:|------|
| `apiKey` | ✅ | Loomart x-api-key 鉴权 |
| `brand` | ✅ | 设备品牌名(所有 RPC 带 `x-owner` header |
| `scanNamePrefix` | ✅ | 扫描时按广播名前缀过滤(大小写不敏感) |
| `apiHost` | | 默认 `https://api.duooomi.com` |
| `cdnHost` | | 默认 `https://cdn.bowong.cc/` |
| `aniWidth` / `aniHeight` / `aniFps` | | ANI 转换参数,默认 `360 / 360 / "24"` |
| `frameIntervalMs` | | 协议帧之间发送间隔ms默认 `35`,合法 `[0, 500]`;越界仅 warn使用点 `coerceIn`。调高用于排查接收端丢帧 |
| `logLevel` | | 日志等级(`BleLogLevel.OFF/ERROR/WARN/INFO/DEBUG`),默认 `OFF`。详见下文 |
### 5. 日志BleLogLevel
SDK 内部所有日志统一走 `BleLog``android.util.Log`,分级 `OFF / ERROR / WARN / INFO / DEBUG`。集成方通过 `config.logLevel` 控制输出:
```kotlin
// 对外发布:什么都不传,默认完全静默
DuooomiBleSDK(context, DuooomiBleConfig(apiKey = "...", brand = "...", scanNamePrefix = "..."))
// 本地排查:开 DEBUG 看到所有 BLE 帧、RPC opId、传输进度等
DuooomiBleSDK(context, DuooomiBleConfig(
apiKey = "...", brand = "...", scanNamePrefix = "...",
logLevel = BleLogLevel.DEBUG
))
```
- 等级单调:`WARN` 输出 warn + error`INFO` 输出 info + warn + error以此类推。
- 日志按 category 分桶:`SDK / Scan / Connect / BLE / RPC / Command / Transfer / Firmware / Protocol / Decode / Config`Logcat 按 tag `DuooomiBleSDK` + `[<category>]` 过滤。
- **Release 包剥离 `Log.d/i`**Android 平台没有 iOS `#if DEBUG` 那样的编译期消除,默认 `OFF` 已足够静默;如需彻底剥离 `android.util.Log` 调用,可在宿主 ProGuard/R8 配置中加 `-assumenosideeffects class android.util.Log { public static *** d(...); public static *** i(...); }`
## API 参考
### 可观察状态StateFlow
```kotlin
sdk.btState // ConnectionState: IDLE, SCANNING, CONNECTING, CONNECTED, ...
sdk.discoveredDevices // List<DiscoveredDevice>
sdk.connectedDevice // DiscoveredDevice?
sdk.deviceInfo // DeviceInfo? (含可选 loop: PlayMode?)
sdk.version // String (设备固件版本)
sdk.versionLog // JSONObject? (设备 log 字段bind 时透传到 update-metadata)
sdk.isActivated // Boolean (是否已 bind)
sdk.sn // String (绑定后写入设备序列号)
sdk.firmwareUpgrade // FirmwareUpgradeCheckResult? (最新一次拉取结果)
sdk.transferProgress // Int (0-100)
sdk.error // String?
```
### 扫描与连接
```kotlin
sdk.scan() // 开始扫描(按 scanNamePrefix 过滤)
sdk.stopScan()
val device = sdk.connect(deviceId) // suspend
sdk.disconnect() // suspend
```
### 设备操作(均需先连接)
```kotlin
val info = sdk.getDeviceInfo()
// info.brand / info.name / info.size / info.powerlevel
// info.freespace / info.allspace / info.loop (PlayMode?)
val versionInfo = sdk.getVersion()
// versionInfo.version / versionInfo.type
// 绑定设备 —— 一次调用完成 BLE 硬绑 + getVersion + 软绑 + metadata 上报
// 完成后写入 sdk.sn / sdk.version / sdk.versionLog / sdk.isActivated
// 软绑失败 → 自动硬解绑回滚 + 抛 SoftBindFailed
val bindResult = sdk.bind(userId)
// bindResult.sn / bindResult.contents (设备文件列表)
// 解绑
sdk.unbind(userId)
// 删除设备文件
sdk.deleteFile(fileKey)
```
### 播放模式切换
```kotlin
import com.duooomi.ble.models.PlayMode
// 单播(播完一次停)
sdk.setPlayMode(PlayMode.SINGLE, userId)
// 循环播放
sdk.setPlayMode(PlayMode.LOOP, userId)
```
> 复用 `BIND_DEVICE` (0x0F) 命令通道;前置必须已 `bind()` 完成(`isActivated == true`
> 否则直接抛 `BindingFailed("Device not bound; call bind(userId) first")`**不发送命令**。
> `bind` / `unbind` / `setPlayMode` 共享 `0x0F / 0x12` opId 通道,不能并发。
### 文件传输
```kotlin
sdk.transferMedia(fileUrl)
sdk.transferProgress.collect { percent -> /* 0-100 */ }
```
**支持格式:** JPEG / PNG / GIF / MP4 / WebM / WebP。先按 URL 扩展名校验,无扩展名时 HEAD 请求查 `Content-Type`,不支持的格式抛 `DuooomiBleError.UnsupportedFormat`
### OTA 升级流程
完整流程:**扫描 → 连接 → bind → fetchLatestFirmware → upgradeFirmware**。设备重启重连后再次走一遍即可。
```kotlin
// Step 1: 绑定(前置:已 connect
val bindResult = sdk.bind(userId)
val sn = sdk.sn.value // 设备序列号
val cur = sdk.version.value // 当前固件版本
// Step 2: 拉最新固件信息
val result = sdk.fetchLatestFirmware(sn = sn, currentVersion = cur, userId = userId)
// 成功 → 写入 sdk.firmwareUpgrade
val target = result.targetFirmware ?: return // null 表示已是最新
target.id // 固件 ID
target.version // 目标版本字符串
target.fileUrl // 固件包下载 URL
target.fileSize // 字节数(字符串编码)
target.fileMd5 // MD5
target.forceUpgrade // 强制升级标记true 时建议弹不可关闭弹窗)
// Step 3: 烧录
sdk.upgradeFirmware(
sn = sn,
fileUrl = target.fileUrl!!,
firmwareId = target.id,
fromVersion = cur,
targetVersion = target.version
)
// OTA 包发完,设备开始烧录并重启
```
> ⚠️ `upgradeFirmware` 下发前会**主动拉一次新鲜 `getDeviceInfo`** 校验实时电量,低于 40% 直接抛 `DuooomiBleError.BatteryTooLow`**不发任何 OTA 字节**(防烧录中途断电变砖)。
### 错误处理
```kotlin
try {
sdk.connect(deviceId)
sdk.bind(userId)
sdk.fetchLatestFirmware(sn = sdk.sn.value, currentVersion = sdk.version.value, userId = userId)
} catch (e: DuooomiBleError.BluetoothNotPoweredOn) { /* 蓝牙未开启 */ }
catch (e: DuooomiBleError.ConnectionFailed) { /* 连接失败 */ }
catch (e: DuooomiBleError.NotConnected) { /* 未连接 */ }
catch (e: DuooomiBleError.Timeout) { /* 超时 */ }
catch (e: DuooomiBleError.PermissionDenied) { /* 权限不足 */ }
catch (e: DuooomiBleError.AlreadyBoundByOtherUser) { /* 设备已绑给他人 */ }
catch (e: DuooomiBleError.BrandMismatch) { /* 不支持该品牌设备(已自动断连) */ }
catch (e: DuooomiBleError.FirmwareUpgradeUnavailable) { /* sn 为空等前置不满足 */ }
catch (e: DuooomiBleError.BatteryTooLow) { /* OTA 前电量 < 40%未下发任何字节e.current / e.required 可读 */ }
```
### 汇总:可用方法与属性
> 一张表速查所有 public API。异步方法均为 `suspend fun`,失败抛 `DuooomiBleError`(少数透传 `JSONException` / `IOException`)。
> 与 iOS 的契约一致项错误类型、字段名、HTTP/JSON 字段)严格 1:1API 形态因平台惯例差异iOS `Result` callback ↔ Android `suspend`iOS `delegate` 协议 ↔ Android `StateFlow`。
#### 方法
| 方法 | 参数 | 返回 | 说明 |
|------|------|------|------|
| `scan()` | — | `Unit`(结果发到 `discoveredDevices` Flow | 开始扫描 |
| `stopScan()` | — | `Unit` | 停止扫描 |
| `connect(deviceId:)` | `deviceId: String` | `suspend → DiscoveredDevice` | 连接设备 |
| `disconnect()` | — | `suspend → Unit` | 断开连接 |
| `getConnectedDevices()` | — | `List<DiscoveredDevice>`(同步) | 当前已连接设备列表 |
| `bind(userId:)` | `userId: String` | `suspend → BindingResponse` | 绑定 + 同步拉版本,成功后 `sn`/`version`/`isActivated` Flow 就绪 |
| `unbind(userId:)` | `userId: String` | `suspend → UnbindResponse` | 解绑 |
| `getDeviceInfo()` | — | `suspend → DeviceInfo` | 读品牌/电量/存储/loop写入 `deviceInfo` Flow |
| `getVersion()` | — | `suspend → VersionInfo` | 读固件版本,写入 `version`/`versionLog` Flow |
| `setPlayMode(mode:, userId:)` | `mode: PlayMode`, `userId: String` | `suspend → BindingResponse` | 切单播/循环;**须先 bind** |
| `deleteFile(key:)` | `key: String` | `suspend → DeleteFileResponse` | 删除设备文件 |
| `prepareTransfer(key:, size:)` | `key: String`, `size: Int` | `suspend → PrepareTransferResponse` | 传输前预协商(一般无需直调) |
| `transferMedia(fileUrl:)` | `fileUrl: String` | `suspend → Unit` | 一步校验→ANI 转换→prepare→传输 |
| `transferFile(fileUri:, commandType:)` | `fileUri: String`, `commandType: CommandType = TRANSFER_ANI_VIDEO` | `suspend → Unit` | 底层文件传输 |
| `fetchLatestFirmware(sn:, currentVersion:, userId:)` | `sn`, `currentVersion`, `userId`: `String` | `suspend → FirmwareUpgradeCheckResult` | 对账上报 + 拉最新固件,写入 `firmwareUpgrade` Flow |
| `upgradeFirmware(sn:, fileUrl:, firmwareId:, fromVersion:, targetVersion:)` | 全 `String` | `suspend → Unit` | OTA 烧录;下发前校验实时电量,**< 40% 抛 `BatteryTooLow`,不发任何字节** |
| `tryReportPendingUpgrade(sn:, currentVersion:, userId:)` | 全 `String` | `Unit`fire-and-forget结果见 `upgradeRecords` Flow | 对账上报本地待报记录(`fetchLatestFirmware` 已内置调用) |
#### 属性(除 `config` 是 `val` 外均 `StateFlow` / `SharedFlow`,集成方 `.collect { }` 订阅)
| 属性 | 类型 | 说明 |
|------|------|------|
| `config` | `DuooomiBleConfig` | 初始化配置(`val` |
| `btState` | `StateFlow<ConnectionState>` | IDLE / SCANNING / CONNECTING / CONNECTED / DISCONNECTING / DISCONNECTED |
| `discoveredDevices` | `StateFlow<List<DiscoveredDevice>>` | 扫描到的设备列表 |
| `connectedDevice` | `StateFlow<DiscoveredDevice?>` | 当前连接设备 |
| `deviceInfo` | `StateFlow<DeviceInfo?>` | 设备信息(品牌/电量/存储/loop |
| `version` | `StateFlow<String>` | 固件版本bind 后就绪) |
| `versionLog` | `StateFlow<JSONObject?>` | 设备版本附带 JSON透传给 metadata 上报) |
| `isActivated` | `StateFlow<Boolean>` | 是否已绑定 |
| `sn` | `StateFlow<String>` | 设备序列号bind 成功后就绪) |
| `transferProgress` | `StateFlow<Int>` | 传输进度 0100 |
| `error` | `StateFlow<String?>` | 最近错误描述 |
| `firmwareUpgrade` | `StateFlow<FirmwareUpgradeCheckResult?>` | 最近一次 `fetchLatestFirmware` 结果 |
| `upgradeRecords` | `StateFlow<List<UpgradeRecord>>` | 本地 OTA 对账记录SharedPreferences 持久化) |
| `rawJsonEvents` | `SharedFlow<BleRawJsonEvent>` | **调试用** BLE JSON 协议层 TX/RX 原始事件流iOS 无对应通道,不属业务契约) |
## 运行 Demo
`demo/src/main/kotlin/com/duooomi/ble/demo/DemoSecrets.kt` 是本地秘钥文件(已 gitignore`docs/DemoSecrets.template.kt` 拷贝并填入真实 `apiKey` / `brand`
```bash
./gradlew :demo:assembleDebug
adb install demo/build/outputs/apk/debug/demo-debug.apk
```
## BLE 协议简述
- **Service UUID:** `000002c4-0000-1000-8000-00805f9b34fb`
- **Write Characteristic:** `000002c5-...`
- **Read Characteristic (Notify):** `000002c6-...`
- **帧格式:** `[head:1][type:1][subpageTotal:2][curPage:2][dataLen:2][data:N][checksum:1]`
- **校验和:** 二进制补码 `(~sum + 1) & 0xFF`
- **大数据分片:** 自动拆分,帧间延迟 35ms
- **Service 发现:** 不传 UUID 全发现并过滤(兼容部分固件 scoped discovery 返回空的 bug
## License
Proprietary - Duooomi