# 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 内置 API(BLE、JSON、HttpURLConnection、SharedPreferences)。
## 快速开始
### 1. 引入 SDK
`settings.gradle.kts`:
```kotlin
include(":sdk")
```
app 模块的 `build.gradle.kts`:
```kotlin
dependencies {
implementation(project(":sdk"))
}
```
### 2. 添加权限
`AndroidManifest.xml`:
```xml
```
> 运行时仍需动态申请 `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` + `[]` 过滤。
- **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
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:1;API 形态因平台惯例差异: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`(同步) | 当前已连接设备列表 |
| `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` | IDLE / SCANNING / CONNECTING / CONNECTED / DISCONNECTING / DISCONNECTED |
| `discoveredDevices` | `StateFlow>` | 扫描到的设备列表 |
| `connectedDevice` | `StateFlow` | 当前连接设备 |
| `deviceInfo` | `StateFlow` | 设备信息(品牌/电量/存储/loop) |
| `version` | `StateFlow` | 固件版本(bind 后就绪) |
| `versionLog` | `StateFlow` | 设备版本附带 JSON(透传给 metadata 上报) |
| `isActivated` | `StateFlow` | 是否已绑定 |
| `sn` | `StateFlow` | 设备序列号(bind 成功后就绪) |
| `transferProgress` | `StateFlow` | 传输进度 0–100 |
| `error` | `StateFlow` | 最近错误描述 |
| `firmwareUpgrade` | `StateFlow` | 最近一次 `fetchLatestFirmware` 结果 |
| `upgradeRecords` | `StateFlow>` | 本地 OTA 对账记录(SharedPreferences 持久化) |
| `rawJsonEvents` | `SharedFlow` | **调试用** 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