docs: README 重写,对齐新 OTA 流程
- 删除已下线的 firmwareIdentifier/firmwareStatus / hasNewerFirmware 文档 - 增加完整 OTA 三段式示例:bind → fetchLatestFirmware → upgradeFirmware - 增加升级对账上报状态判定表(reported/outcome 四种状态) - 增加 setPlayMode / Config owner+scanNamePrefix 必填字段说明 - 增加错误类型完整列表(含新增 AlreadyBoundByOtherUser / SoftBindFailed / FirmwareUpgradeUnavailable) - 增加服务端 endpoint 表 + BeijingTimeProvider 三级 fallback 说明 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
203
README.md
203
README.md
@@ -1,6 +1,6 @@
|
||||
# Duooomi Android BLE SDK
|
||||
|
||||
Duooomi 蓝牙 BLE SDK 的 Android 版本。
|
||||
Duooomi 蓝牙 BLE SDK 的 Android 版本(与 iOS / expo 版业务行为 1:1 对齐)。
|
||||
|
||||
## 环境要求
|
||||
|
||||
@@ -31,12 +31,12 @@ 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/ # 服务层 (设备信息, 文件传输, ANI转换, 固件)
|
||||
│ ├── models/ # 数据模型
|
||||
│ └── utils/ # 日志工具
|
||||
│ ├── DuooomiBleConfig.kt # SDK 配置
|
||||
│ ├── core/ # BLE 核心层 (BleClient, 类型定义, 错误类型)
|
||||
│ ├── protocol/ # BLE 协议层 (帧格式, UUID, 协议管理)
|
||||
│ ├── services/ # 服务层 (设备命令 / OTA / 软绑 / 升级记录持久化)
|
||||
│ ├── models/ # 数据模型
|
||||
│ └── utils/ # 北京时间 / 日志
|
||||
├── demo/ # 演示 App (Jetpack Compose)
|
||||
└── build.gradle.kts
|
||||
```
|
||||
@@ -49,19 +49,19 @@ SDK 仅依赖一个外部库:
|
||||
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3
|
||||
```
|
||||
|
||||
其余全部使用 Android 内置 API(BLE、JSON、HttpURLConnection)。
|
||||
其余全部使用 Android 内置 API(BLE、JSON、HttpURLConnection、SharedPreferences)。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 引入 SDK
|
||||
|
||||
在 `settings.gradle.kts` 中:
|
||||
`settings.gradle.kts`:
|
||||
|
||||
```kotlin
|
||||
include(":sdk")
|
||||
```
|
||||
|
||||
在 app 模块的 `build.gradle.kts` 中:
|
||||
app 模块的 `build.gradle.kts`:
|
||||
|
||||
```kotlin
|
||||
dependencies {
|
||||
@@ -71,7 +71,7 @@ dependencies {
|
||||
|
||||
### 2. 添加权限
|
||||
|
||||
在 `AndroidManifest.xml` 中添加:
|
||||
`AndroidManifest.xml`:
|
||||
|
||||
```xml
|
||||
<!-- Android 11 及以下 -->
|
||||
@@ -81,13 +81,13 @@ dependencies {
|
||||
<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转换/固件下载) -->
|
||||
<!-- 网络 (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` 权限。
|
||||
> 运行时仍需动态申请 `BLUETOOTH_SCAN`、`BLUETOOTH_CONNECT`(Android 12+)和 `ACCESS_FINE_LOCATION`。
|
||||
|
||||
### 3. 初始化 SDK
|
||||
|
||||
@@ -95,35 +95,38 @@ dependencies {
|
||||
val sdk = DuooomiBleSDK(
|
||||
context = this,
|
||||
config = DuooomiBleConfig(
|
||||
apiKey = "YOUR_API_KEY"
|
||||
apiKey = "YOUR_API_KEY",
|
||||
owner = "YOUR_OWNER", // 多租户标识,所有 RPC 必带
|
||||
scanNamePrefix = "Duooomi-" // 扫描时按广播名前缀过滤
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### 4. API Key 说明
|
||||
### 4. Config 字段说明
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `apiKey` | **必填**。用于 ANI 转换和固件查询等后端 API 鉴权。|
|
||||
| `apiHost` | 默认 `https://api.duooomi.com`,一般无需修改 |
|
||||
| `cdnHost` | 默认 `https://cdn.bowong.cc/`,一般无需修改 |
|
||||
| `firmwareIdentifier` | 默认 `duomi`,设备固件标识符 |
|
||||
| `firmwareStatus` | 默认 `DRAFT`,可选 `PUBLISHED` |
|
||||
| `aniWidth` | ANI 转换宽度,默认 `360` |
|
||||
| `aniHeight` | ANI 转换高度,默认 `360` |
|
||||
| `aniFps` | ANI 转换帧率,默认 `"24"` |
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|:----:|------|
|
||||
| `apiKey` | ✅ | Loomart x-api-key 鉴权 |
|
||||
| `owner` | ✅ | 多租户 owner,所有 RPC 带 `x-owner` header |
|
||||
| `scanNamePrefix` | ✅ | 扫描时按广播名前缀过滤(大小写不敏感) |
|
||||
| `apiHost` | | 默认 `https://api.duooomi.com` |
|
||||
| `cdnHost` | | 默认 `https://cdn.bowong.cc/` |
|
||||
| `aniWidth` / `aniHeight` / `aniFps` | | ANI 转换参数,默认 `360 / 360 / "24"` |
|
||||
|
||||
## API 参考
|
||||
|
||||
### 可观察状态 (StateFlow)
|
||||
### 可观察状态(StateFlow)
|
||||
|
||||
```kotlin
|
||||
sdk.btState // ConnectionState: IDLE, SCANNING, CONNECTING, CONNECTED, ...
|
||||
sdk.discoveredDevices // List<DiscoveredDevice>
|
||||
sdk.connectedDevice // DiscoveredDevice?
|
||||
sdk.deviceInfo // DeviceInfo?
|
||||
sdk.version // String
|
||||
sdk.isActivated // Boolean
|
||||
sdk.deviceInfo // DeviceInfo? (含可选 loop: PlayMode?)
|
||||
sdk.version // String (设备固件版本)
|
||||
sdk.isActivated // Boolean (是否已 bind)
|
||||
sdk.sn // String (绑定后写入设备序列号)
|
||||
sdk.firmwareUpgrade // FirmwareUpgradeCheckResult? (最新一次拉取结果)
|
||||
sdk.upgradeRecords // List<UpgradeRecord> (持久化的 OTA 历史)
|
||||
sdk.transferProgress // Int (0-100)
|
||||
sdk.error // String?
|
||||
```
|
||||
@@ -131,97 +134,132 @@ sdk.error // String?
|
||||
### 扫描与连接
|
||||
|
||||
```kotlin
|
||||
// 开始扫描 (自动过滤 Duooomi 设备)
|
||||
sdk.scan()
|
||||
|
||||
// 停止扫描
|
||||
sdk.scan() // 开始扫描(按 scanNamePrefix 过滤)
|
||||
sdk.stopScan()
|
||||
|
||||
// 连接设备 (suspend)
|
||||
val device = sdk.connect(deviceId)
|
||||
|
||||
// 断开连接 (suspend)
|
||||
sdk.disconnect()
|
||||
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.brand / info.name / info.size / info.powerlevel
|
||||
// info.freespace / info.allspace / info.loop (PlayMode?)
|
||||
|
||||
// 获取固件版本
|
||||
val versionInfo = sdk.getVersion()
|
||||
// versionInfo.version, versionInfo.type
|
||||
// versionInfo.version / versionInfo.type
|
||||
|
||||
// 绑定设备
|
||||
// 绑定设备 —— 只做 BLE 硬绑 + getVersion()
|
||||
// 完成后写入 sdk.sn / sdk.version / sdk.isActivated
|
||||
// 软绑 / 拉最新固件 / 对账上报 —— 全部走 fetchLatestFirmware(...)
|
||||
val bindResult = sdk.bind(userId)
|
||||
// bindResult.sn, bindResult.contents (设备文件列表)
|
||||
// bindResult.sn / bindResult.contents (设备文件列表)
|
||||
|
||||
// 解绑设备
|
||||
// 解绑 —— 硬解 + 软解(软解失败仅 warn 不抛)
|
||||
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
|
||||
// 传输媒体文件 (格式校验 → ANI 转换 → prepare → transfer)
|
||||
sdk.transferMedia(fileUrl)
|
||||
|
||||
// 监听进度
|
||||
sdk.transferProgress.collect { percent -> /* 0-100 */ }
|
||||
```
|
||||
|
||||
**支持的媒体格式:** JPEG、PNG、GIF、MP4、WebM、WebP
|
||||
**支持格式:** JPEG / PNG / GIF / MP4 / WebM / WebP。先按 URL 扩展名校验,无扩展名时 HEAD 请求查 `Content-Type`,不支持的格式抛 `DuooomiBleError.UnsupportedFormat`。
|
||||
|
||||
格式校验规则:
|
||||
- 优先从 URL path 提取扩展名(自动忽略查询参数)
|
||||
- 无扩展名时通过 HEAD 请求检查 `Content-Type`
|
||||
- 不支持的格式抛出 `DuooomiBleError.UnsupportedFormat`
|
||||
### OTA 升级流程
|
||||
|
||||
### 固件升级
|
||||
完整三段式 OTA(与 expo `bindDeviceWithOrchestration` 对齐):
|
||||
|
||||
```kotlin
|
||||
// 查询最新固件
|
||||
val firmware = sdk.fetchLatestFirmware(identifier, status)
|
||||
// firmware?.version, firmware?.fileUrl, firmware?.fileSize
|
||||
// Step 1: 硬绑(前置:已 connect)
|
||||
val bindResult = sdk.bind(userId)
|
||||
val sn = sdk.sn.value // 设备序列号
|
||||
val cur = sdk.version.value // 当前固件版本
|
||||
|
||||
// 检查是否有新版本 (对比版本号末尾10位时间戳)
|
||||
val needUpdate = sdk.hasNewerFirmware(
|
||||
deviceVersion = sdk.version.value, // 当前设备版本
|
||||
serverVersion = firmware?.version // 服务端最新版本
|
||||
// Step 2: 拉最新固件 —— 内部串「对账上报 → 软绑 → checkUpgrade」
|
||||
val result = sdk.fetchLatestFirmware(sn = sn, currentVersion = cur, userId = userId)
|
||||
// 软绑失败 → 自动硬解回滚 + 重置 isActivated/sn,整个 fetch 抛错
|
||||
// 成功 → 写入 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: 烧录 —— 完成后自动落 UpgradeRecord 到本地
|
||||
sdk.upgradeFirmware(
|
||||
sn = sn,
|
||||
fileUrl = target.fileUrl!!,
|
||||
firmwareId = target.id,
|
||||
fromVersion = cur,
|
||||
targetVersion = target.version
|
||||
)
|
||||
// 参数为空/null/格式不合法时返回 false
|
||||
|
||||
// 执行 OTA 升级
|
||||
sdk.upgradeFirmware(firmware.fileUrl)
|
||||
// Step 4 (下一次启动):重新连接 → bind → 再调 fetchLatestFirmware
|
||||
// 会自动触发 tryReportPendingUpgrade,把上次 OTA 的对账结果上报服务端
|
||||
```
|
||||
|
||||
### 错误处理
|
||||
### 升级对账上报状态判定表
|
||||
|
||||
所有 suspend 方法在失败时抛出 `DuooomiBleError`:
|
||||
`fetchLatestFirmware` 内部第一步会调 `tryReportPendingUpgrade`,把本地 `pending` 的 `UpgradeRecord` 逐条报给服务端。结果通过 `sdk.upgradeRecords` 的 `reported` / `outcome` 字段反映:
|
||||
|
||||
| `reported` | `outcome` | 含义 | 触发条件 |
|
||||
|---|---|---|---|
|
||||
| `false` | `null` | 待上报 / HTTP 失败重试中 | 网络异常 / 服务端 5xx |
|
||||
| `true` | `SUCCESS` | 升级成功 | 设备实际版本 == 目标版本,`report-success` 已成功 |
|
||||
| `true` | `VERSION_MISMATCH` | 升级失败 | 设备实际版本 ≠ 目标版本,`report-failure(VERSION_MISMATCH …)` 已成功 |
|
||||
| `true` | `null` | 30 天 TTL 过期 | 触发 OTA 后 30 天内仍未匹配上 / 未上报,标记为审计轨迹保留 |
|
||||
|
||||
集成方可观察 `sdk.upgradeRecords` StateFlow,在 UI 上展示对账历史。
|
||||
|
||||
### 错误处理
|
||||
|
||||
```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.ConnectionFailed) { /* 连接失败 */ }
|
||||
catch (e: DuooomiBleError.NotConnected) { /* 未连接 */ }
|
||||
catch (e: DuooomiBleError.Timeout) { /* 超时 */ }
|
||||
catch (e: DuooomiBleError.PermissionDenied) { /* 权限不足 */ }
|
||||
catch (e: DuooomiBleError.AlreadyBoundByOtherUser) { /* 设备已绑给他人 */ }
|
||||
catch (e: DuooomiBleError.SoftBindFailed) { /* 软绑失败(已自动硬解回滚) */ }
|
||||
catch (e: DuooomiBleError.FirmwareUpgradeUnavailable) { /* sn 为空等前置不满足 */ }
|
||||
```
|
||||
|
||||
## 运行 Demo
|
||||
|
||||
```bash
|
||||
# 构建
|
||||
./gradlew :demo:assembleDebug
|
||||
`demo/src/main/kotlin/com/duooomi/ble/demo/DemoSecrets.kt` 是本地秘钥文件(已 gitignore),从 `docs/DemoSecrets.template.kt` 拷贝并填入真实 `apiKey` / `owner`。
|
||||
|
||||
# 安装到设备
|
||||
```bash
|
||||
./gradlew :demo:assembleDebug
|
||||
adb install demo/build/outputs/apk/debug/demo-debug.apk
|
||||
```
|
||||
|
||||
@@ -233,6 +271,19 @@ adb install demo/build/outputs/apk/debug/demo-debug.apk
|
||||
- **帧格式:** `[head:1][type:1][subpageTotal:2][curPage:2][dataLen:2][data:N][checksum:1]`
|
||||
- **校验和:** 二进制补码 `(~sum + 1) & 0xFF`
|
||||
- **大数据分片:** 自动拆分,帧间延迟 35ms
|
||||
- **Service 发现:** 不传 UUID 全发现并过滤(兼容部分固件 scoped discovery 返回空的 bug)
|
||||
|
||||
## 服务端依赖
|
||||
|
||||
| Endpoint | 触发时机 |
|
||||
|---|---|
|
||||
| `POST /api/auth/loomart/shipment-sn/device/bind` | `fetchLatestFirmware` 内部软绑 |
|
||||
| `POST /api/auth/loomart/shipment-sn/device/unbind` | `unbind` 软解 |
|
||||
| `POST /api/auth/loomart/firmware/upgrade/check` | `fetchLatestFirmware` checkUpgrade |
|
||||
| `POST /api/auth/loomart/firmware/upgrade/report-success` | `tryReportPendingUpgrade`(版本命中) |
|
||||
| `POST /api/auth/loomart/firmware/upgrade/report-failure` | `tryReportPendingUpgrade`(VERSION_MISMATCH) |
|
||||
|
||||
所有请求带 `x-api-key` + `x-owner` header。bind / unbind / setPlayMode BLE payload 还会携带 `time` 字段(北京时间 ISO 8601),来源走 `BeijingTimeProvider` 三级 fallback:苏宁公开 API → response Date header → 本地 + 8h。
|
||||
|
||||
## License
|
||||
|
||||
|
||||
Reference in New Issue
Block a user