feat(sdk): 同步 iOS 改动 — owner→brand 重命名 + 硬绑前 brand 校验 + README 内部细节抹除

- DuooomiBleConfig.owner → brand(语义:设备品牌名),HTTP header x-owner 不变
- bind() 前置 verifyBrand:缺 deviceInfo 先 getDeviceInfo,与 config.brand 严格匹配,
  不一致主动 disconnect 并抛 DuooomiBleError.BrandMismatch
- README 抹除软绑/对账/UpgradeRecord 等内部实现细节,仅保留厂商面向的公开 API
  (功能代码完全保留:fetchLatestFirmware 内部三段串、unbind 软解、tryReportPendingUpgrade)
- README 顶部加 demo 优先验证提示

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
km2023
2026-05-09 12:03:26 +08:00
parent d336ac358e
commit e63865092c
8 changed files with 45 additions and 59 deletions

View File

@@ -2,6 +2,8 @@
Duooomi 蓝牙 BLE SDK 的 Android 版本(与 iOS / expo 版业务行为 1:1 对齐)。
> ⚠️ **请先跑通仓库内 demo 再开始对接 SDK。** demo 已串完整链路(扫描 / 连接 / 绑定 / OTA / 文件传输),是验证设备 + apiKey + brand 是否对的最快途径,避免在自家工程里盲调试。
## 环境要求
**SDK 集成要求(消费方最低版本):**
@@ -34,7 +36,7 @@ duooomi-android-sdk/
│ ├── DuooomiBleConfig.kt # SDK 配置
│ ├── core/ # BLE 核心层 (BleClient, 类型定义, 错误类型)
│ ├── protocol/ # BLE 协议层 (帧格式, UUID, 协议管理)
│ ├── services/ # 服务层 (设备命令 / OTA / 软绑 / 升级记录持久化)
│ ├── services/ # 服务层 (设备命令 / OTA / HTTP)
│ ├── models/ # 数据模型
│ └── utils/ # 北京时间 / 日志
├── demo/ # 演示 App (Jetpack Compose)
@@ -81,7 +83,7 @@ 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 转换 / OTA / 软绑) -->
<!-- 网络 (ANI 转换 / OTA) -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
@@ -96,7 +98,7 @@ val sdk = DuooomiBleSDK(
context = this,
config = DuooomiBleConfig(
apiKey = "YOUR_API_KEY",
owner = "YOUR_OWNER", // 多租户标识,所有 RPC 必带
brand = "YOUR_BRAND", // 设备品牌名,所有 RPC 必带
scanNamePrefix = "Duooomi-" // 扫描时按广播名前缀过滤
)
)
@@ -107,7 +109,7 @@ val sdk = DuooomiBleSDK(
| 参数 | 必填 | 说明 |
|------|:----:|------|
| `apiKey` | ✅ | Loomart x-api-key 鉴权 |
| `owner` | ✅ | 多租户 owner所有 RPC 带 `x-owner` header |
| `brand` | ✅ | 设备品牌名(所有 RPC 带 `x-owner` header |
| `scanNamePrefix` | ✅ | 扫描时按广播名前缀过滤(大小写不敏感) |
| `apiHost` | | 默认 `https://api.duooomi.com` |
| `cdnHost` | | 默认 `https://cdn.bowong.cc/` |
@@ -126,7 +128,6 @@ 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?
```
@@ -150,13 +151,11 @@ val info = sdk.getDeviceInfo()
val versionInfo = sdk.getVersion()
// versionInfo.version / versionInfo.type
// 绑定设备 —— 只做 BLE 硬绑 + getVersion()
// 完成后写入 sdk.sn / sdk.version / sdk.isActivated
// 软绑 / 拉最新固件 / 对账上报 —— 全部走 fetchLatestFirmware(...)
// 绑定设备 —— 完成后写入 sdk.sn / sdk.version / sdk.isActivated
val bindResult = sdk.bind(userId)
// bindResult.sn / bindResult.contents (设备文件列表)
// 解绑 —— 硬解 + 软解(软解失败仅 warn 不抛)
// 解绑
sdk.unbind(userId)
// 删除设备文件
@@ -190,28 +189,27 @@ sdk.transferProgress.collect { percent -> /* 0-100 */ }
### OTA 升级流程
完整三段式 OTA与 expo `bindDeviceWithOrchestration` 对齐):
完整流程:**扫描 → 连接 → bind → fetchLatestFirmware → upgradeFirmware**。设备重启重连后再次走一遍即可。
```kotlin
// Step 1: 绑(前置:已 connect
// Step 1: 绑(前置:已 connect
val bindResult = sdk.bind(userId)
val sn = sdk.sn.value // 设备序列号
val cur = sdk.version.value // 当前固件版本
// Step 2: 拉最新固件 —— 内部串「对账上报 → 软绑 → checkUpgrade」
// Step 2: 拉最新固件信息
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.id // 固件 ID
target.version // 目标版本字符串
target.fileUrl // 固件包下载 URL
target.fileSize // 字节数(字符串编码)
target.fileMd5 // MD5
target.forceUpgrade // 强制升级标记true 时建议弹不可关闭弹窗)
// Step 3: 烧录 —— 完成后自动落 UpgradeRecord 到本地
// Step 3: 烧录
sdk.upgradeFirmware(
sn = sn,
fileUrl = target.fileUrl!!,
@@ -219,24 +217,9 @@ sdk.upgradeFirmware(
fromVersion = cur,
targetVersion = target.version
)
// Step 4 (下一次启动):重新连接 → bind → 再调 fetchLatestFirmware
// 会自动触发 tryReportPendingUpgrade把上次 OTA 的对账结果上报服务端
// OTA 包发完,设备开始烧录并重启
```
### 升级对账上报状态判定表
`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
@@ -250,13 +233,13 @@ try {
catch (e: DuooomiBleError.Timeout) { /* 超时 */ }
catch (e: DuooomiBleError.PermissionDenied) { /* 权限不足 */ }
catch (e: DuooomiBleError.AlreadyBoundByOtherUser) { /* 设备已绑给他人 */ }
catch (e: DuooomiBleError.SoftBindFailed) { /* 软绑失败(已自动硬解回滚 */ }
catch (e: DuooomiBleError.BrandMismatch) { /* 不支持该品牌设备(已自动断连 */ }
catch (e: DuooomiBleError.FirmwareUpgradeUnavailable) { /* sn 为空等前置不满足 */ }
```
## 运行 Demo
`demo/src/main/kotlin/com/duooomi/ble/demo/DemoSecrets.kt` 是本地秘钥文件(已 gitignore`docs/DemoSecrets.template.kt` 拷贝并填入真实 `apiKey` / `owner`
`demo/src/main/kotlin/com/duooomi/ble/demo/DemoSecrets.kt` 是本地秘钥文件(已 gitignore`docs/DemoSecrets.template.kt` 拷贝并填入真实 `apiKey` / `brand`
```bash
./gradlew :demo:assembleDebug
@@ -273,18 +256,6 @@ adb install demo/build/outputs/apk/debug/demo-debug.apk
- **大数据分片:** 自动拆分,帧间延迟 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
Proprietary - Duooomi

View File

@@ -65,7 +65,7 @@ fun DemoScreen(
val upgradeRecords by sdk.upgradeRecords.collectAsState()
// Editable config (rekey on sdk change so new SDK 不会带着旧编辑残留)
var configOwner by remember(sdk) { mutableStateOf(config.owner) }
var configBrand by remember(sdk) { mutableStateOf(config.brand) }
var configApiKey by remember(sdk) { mutableStateOf(config.apiKey) }
var configScanPrefix by remember(sdk) { mutableStateOf(config.scanPrefix) }
@@ -126,14 +126,14 @@ fun DemoScreen(
// === Config Section ===
item {
SectionHeader("SDK Config修改后点 Reload 生效)")
ConfigField("x-owner", configOwner) { configOwner = it }
ConfigField("brand", configBrand) { configBrand = it }
ConfigField("apiKey", configApiKey) { configApiKey = it }
ConfigField("scanPrefix", configScanPrefix) { configScanPrefix = it }
Button(
onClick = {
onReload(DemoConfig(configOwner, configApiKey, configScanPrefix))
onReload(DemoConfig(configBrand, configApiKey, configScanPrefix))
log(
"SDK reloaded with owner=$configOwner, prefix=$configScanPrefix",
"SDK reloaded with brand=$configBrand, prefix=$configScanPrefix",
LogLevel.SUCCESS
)
},

View File

@@ -35,7 +35,7 @@ class MainActivity : ComponentActivity() {
val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val initialConfig = DemoConfig(
owner = prefs.getString(KEY_OWNER, DemoSecrets.OWNER) ?: DemoSecrets.OWNER,
brand = prefs.getString(KEY_BRAND, DemoSecrets.BRAND) ?: DemoSecrets.BRAND,
apiKey = prefs.getString(KEY_API_KEY, DemoSecrets.API_KEY) ?: DemoSecrets.API_KEY,
scanPrefix = prefs.getString(KEY_SCAN_PREFIX, DemoSecrets.SCAN_NAME_PREFIX)
?: DemoSecrets.SCAN_NAME_PREFIX
@@ -69,14 +69,14 @@ class MainActivity : ComponentActivity() {
context = this,
config = DuooomiBleConfig(
apiKey = c.apiKey,
owner = c.owner,
brand = c.brand,
scanNamePrefix = c.scanPrefix
)
)
private fun persist(prefs: SharedPreferences, c: DemoConfig) {
prefs.edit()
.putString(KEY_OWNER, c.owner)
.putString(KEY_BRAND, c.brand)
.putString(KEY_API_KEY, c.apiKey)
.putString(KEY_SCAN_PREFIX, c.scanPrefix)
.apply()
@@ -101,14 +101,14 @@ class MainActivity : ComponentActivity() {
private companion object {
const val PREFS = "demo_config"
const val KEY_OWNER = "demo.config.owner"
const val KEY_BRAND = "demo.config.brand"
const val KEY_API_KEY = "demo.config.apiKey"
const val KEY_SCAN_PREFIX = "demo.config.scanPrefix"
}
}
data class DemoConfig(
val owner: String,
val brand: String,
val apiKey: String,
val scanPrefix: String
)

View File

@@ -6,6 +6,6 @@ package com.duooomi.ble.demo
*/
object DemoSecrets {
const val API_KEY: String = "<paste-api-key>"
const val OWNER: String = "xmly"
const val BRAND: String = "xmly"
const val SCAN_NAME_PREFIX: String = "Duooomi-"
}

View File

@@ -8,8 +8,8 @@ data class DuooomiBleConfig(
val apiHost: String = "https://api.duooomi.com",
/** API 鉴权 keyLoomart x-api-key */
val apiKey: String,
/** 多租户 owner 标识,所有 RPC 必带 `x-owner` header */
val owner: String,
/** 设备品牌名(所有 RPC 必带 `x-owner` header */
val brand: String,
/** 扫描时按设备蓝牙广播名前缀过滤(大小写不敏感)。**必填**,由集成方约定固件广播名前缀。 */
val scanNamePrefix: String,
/** CDN 资源地址(末尾自动补 `/` */

View File

@@ -83,7 +83,7 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
private var flushJob: Job? = null
init {
BleLog.i("SDK initialized (apiHost=${config.apiHost}, owner=${config.owner})", "SDK")
BleLog.i("SDK initialized (apiHost=${config.apiHost}, brand=${config.brand})", "SDK")
setupDisconnectHandler()
// 启动时把持久化记录回放到 StateFlow
_upgradeRecords.value = upgradeRecordStore.all
@@ -253,6 +253,7 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
*/
suspend fun bind(userId: String): BindingResponse {
ensureConnected()
verifyBrand()
BleLog.d("Sending bind (userId=$userId)", "Command")
val data = sendAndWait(CommandType.BIND_DEVICE) {
deviceInfoService.bindDevice(userId)
@@ -732,6 +733,19 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
}
}
/**
* 校验设备 brand 与 SDK 配置 brand 是否一致;缺失则先拉一次 deviceInfo。
* 不一致时主动断连并抛 [DuooomiBleError.BrandMismatch]。
*/
private suspend fun verifyBrand() {
val actual = _deviceInfo.value?.brand ?: getDeviceInfo().brand
val expected = config.brand
if (actual == expected) return
BleLog.w("Brand mismatch: expected=$expected, actual=$actual; disconnecting", "Command")
runCatching { disconnect() }
throw DuooomiBleError.BrandMismatch(expected = expected, actual = actual)
}
private fun parseJsonResponse(data: ByteArray): JSONObject {
// Device responses may contain null bytes
val cleaned = data.filter { it != 0.toByte() }.toByteArray()

View File

@@ -31,4 +31,5 @@ sealed class DuooomiBleError(message: String) : Exception(message) {
class UnsupportedFormat(val format: String) : DuooomiBleError("Unsupported file format: $format. Supported: jpeg, png, gif, mp4, webm, webp")
object InvalidFrame : DuooomiBleError("Invalid protocol frame")
object PermissionDenied : DuooomiBleError("Bluetooth permission denied")
class BrandMismatch(val expected: String, val actual: String) : DuooomiBleError("不支持该品牌设备 (expected=$expected, actual=$actual)")
}

View File

@@ -32,7 +32,7 @@ internal object HttpHelper {
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("Accept", "application/json")
conn.setRequestProperty("x-api-key", config.apiKey)
conn.setRequestProperty("x-owner", config.owner)
conn.setRequestProperty("x-owner", config.brand)
val payload = JSONObject().apply {
for ((k, v) in body) {