feat(sdk): 绑定时上报设备 log 到 update-metadata 并内聚双层绑定

1:1 复刻 iOS commit 4bc8fdb。

- bind(userId) 内聚为硬绑 + getVersion + 软绑 + updateMetadata 一次原子调用,
  软绑失败自动硬解绑回滚 + 清状态;集成方无需感知双层绑定
- fetchLatestFirmware 简化为对账上报 + checkUpgrade 两步,不再重复软绑
- VersionInfo 增加 log: JSONObject?,fromJson 用 optJSONObject 解析;
  SDK 暴露只读 versionLog: StateFlow<JSONObject?>,断连/解绑/软绑回滚时清空
- ShipmentSnDeviceService 新增 updateMetadata(sn, data: JSONObject),
  HttpHelper.post 已支持 JSONObject 作为 body 子节点透传
- 老固件不返回 log → versionLog == null → 自动跳过 updateMetadata,前向兼容
- 同步更新 .wiki/tech-architecture.md(状态清理表 + OTA 编排图)
  + product-decisions.md(新增决策条目)+ README.md(versionLog 状态)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
km2023
2026-05-18 16:38:03 +08:00
parent f844bce927
commit 8d83588e0b
6 changed files with 143 additions and 48 deletions

View File

@@ -13,7 +13,7 @@
**影响**:集成方升级时构造函数签名报错,必须显式传两个参数。可接受,因为 demo / iOS / Harmony 都同步改了。
## bind 改瘦 + fetchLatestFirmware 串三步
## bind 改瘦 + fetchLatestFirmware 串三步(已废弃,见下条)
**决策时间**2026-05-08commit `2dde274`
@@ -27,6 +27,28 @@
**集成方迁移指南**bind 之后必须显式调 `fetchLatestFirmware(sn, currentVersion, userId)` 才能拿到固件信息和触发对账。
## bind 内聚双层绑定 + updateMetadata 上报
**决策时间**2026-05-18同步 iOS commit `4bc8fdb` 复刻)
把软绑 + updateMetadata 从 `fetchLatestFirmware` 重新搬回 `bind` 内部,集成方一次调用完成"完整绑定"。
| 阶段 | 上一版2026-05-08 | 本版2026-05-18 |
|---|---|---|
| `bind(userId)` | BLE 硬绑 + getVersion | BLE 硬绑 + getVersion + **软绑** + **updateMetadata** + 失败回滚 |
| `fetchLatestFirmware(sn, currentVersion, userId)` | 对账 + 软绑 + checkUpgrade | 对账 + checkUpgrade不再软绑 |
**原因**
- 软绑是"绑定"语义的一部分,集成方不该感知双层之分(与 iOS HEAD 对齐)
- updateMetadata 在 bind 时 versionLog 刚新鲜,语义上"绑定时一并上报状态"更连贯
- 上一版"bind 改瘦"是过度拆分;事实证明拆开反而让集成方容易遗漏软绑
**集成方迁移指南**bind 调用方式不变,但**不再需要**调 `fetchLatestFirmware` 才完成软绑。检查更新仍调 `fetchLatestFirmware`,但它只做对账+checkUpgrade。
**新增 SDK 状态**`versionLog: StateFlow<JSONObject?>` —— 设备 BLE getVersion 响应中 `log` 字段(任意 JSON 对象,老固件不返回则 null。bind 时 fire-and-forget 透传到 `POST /api/auth/loomart/shipment-sn/device/update-metadata`。**前向兼容**:老固件 versionLog == null → 自动跳过 updateMetadata。
**JSON object 选型**:直接用 `org.json.JSONObject?` 透传(不解析、不转 MapHttpHelper.post 已支持 `JSONObject` 作为 body 子节点序列化。对应 iOS 的 `[String: Any]?` 方案。
## disconnect 时不清 sn / firmwareUpgrade
**决策时间**2026-05-08commit `0dbfab9`

View File

@@ -32,9 +32,9 @@ sdk/src/main/kotlin/com/duooomi/ble/
└── BeijingTimeProvider.kt # 三级 fallback 时间获取
```
## OTA 三段式编排(核心架构)
## OTA 编排(核心架构 · 2026-05-18 重构
`bind` 改瘦 + `unbind` 改胖 + `fetchLatestFirmware` 串三步,是与 expo / iOS HEAD 1:1 对齐的核心。**不要回退到"bind 一键搞定全套"的旧设计**
`bind` 内聚(硬绑+getVersion+软绑+updateMetadata+ `fetchLatestFirmware` 瘦身(对账+checkUpgrade+ `unbind` 改胖(硬解+软解),与 iOS HEAD 1:1 对齐
```
┌──────────────────────────────────────────────────────────────┐
@@ -44,17 +44,21 @@ sdk/src/main/kotlin/com/duooomi/ble/
connect(deviceId)
└─ BLE 连接 + 启动协议监听
bind(userId) ← 只做 BLE 硬绑 + getVersion
├─ sendAndWait(BIND_DEVICE) payload = {type, userId, time}
├─ 写入 sdk.sn / sdk.isActivated
└─ runCatching { getVersion() } // 失败不阻断
bind(userId) ← 完整绑定4 步内部编排,对集成方原子)
├─ Step 1: verifyBrand
├─ Step 2: sendAndWait(BIND_DEVICE) BLE 硬绑 0x0F
│ └─ 写 sdk.sn / sdk.isActivated
├─ Step 3: runCatching { getVersion() } // 失败不阻断
│ └─ 写 sdk.version / sdk.versionLog
├─ Step 4: shipmentService.bind(sn, userId) 软绑
│ └─ 失败 → 自动硬解回滚 + 清 sn/isActivated/versionLog → 整体失败抛 SoftBindFailed
└─ Step 5: scope.launch { updateMetadata(sn, versionLog) } (fire-and-forget)
└─ 仅当 versionLog 非 null 且非空对象;失败仅 warn
fetchLatestFirmware(sn, currentVersion, userId) ← 步编排
fetchLatestFirmware(sn, currentVersion, userId) ← 步编排
├─ Step 1: tryReportPendingUpgrade(...) (fire-and-forget)
│ └─ 把本地 pending UpgradeRecord 报给服务端
─ Step 2: shipmentService.bind(sn, userId) 软绑
│ └─ 失败 → 自动硬解回滚 + 清 sn / isActivated
└─ Step 3: firmwareUpgradeService.checkUpgrade(sn, currentVersion)
─ Step 2: firmwareUpgradeService.checkUpgrade(sn, currentVersion)
└─ 写 sdk.firmwareUpgrade
upgradeFirmware(sn, fileUrl, firmwareId, fromVersion, targetVersion)
@@ -64,7 +68,7 @@ upgradeFirmware(sn, fileUrl, firmwareId, fromVersion, targetVersion)
unbind(userId) ← 硬解 + 软解(容错)
├─ 缓存 cachedSn = sdk.sn
├─ sendAndWait(UNBIND_DEVICE)
├─ 写 sdk.isActivated = false / sdk.sn = ""
├─ 写 sdk.isActivated = false / sdk.sn = "" / sdk.versionLog = null
└─ if cachedSn 非空 → shipmentService.unbind(...)(失败仅 warn
```
@@ -74,10 +78,11 @@ unbind(userId) ← 硬解 + 软解(容错)
| 状态字段 | 写入时机 | 清理时机 |
|---|---|---|
| `sn` | bind 成功BindingResponse.sn | unbind 成功;fetchLatestFirmware 软绑失败回滚 |
| `sn` | bind 硬绑成功BindingResponse.sn | unbind 成功;**bind 软绑失败回滚** |
| `firmwareUpgrade` | fetchLatestFirmware 成功 | **从不主动清** |
| `isActivated` | bind 成功(true) / unbind 成功(false) / 软绑失败回滚(false) | disconnect handler / 主动 disconnect |
| `isActivated` | bind 硬绑成功(true) / unbind 成功(false) / **bind 软绑失败回滚(false)** | disconnect handler / 主动 disconnect |
| `version` | getVersion / bind | disconnect handler / 主动 disconnect |
| `versionLog` | getVersion 成功log 非 null | unbind 成功 / **bind 软绑失败回滚** / disconnect handler / 主动 disconnect |
| `deviceInfo` | getDeviceInfo / setPlayMode 后自动刷新 | disconnect handler / 主动 disconnect |
| `connectedDevice` | connect 成功 | disconnect handler / 主动 disconnect |
| `transferProgress` | transferFile 进度回调 | (持续写,无显式清) |
@@ -89,6 +94,8 @@ unbind(userId) ← 硬解 + 软解(容错)
之前 Android 这两个字段在 disconnect 时被清,与 iOS 不一致commit `0dbfab9` 修正。
⚠️ **2026-05-18 重构**:把软绑从 `fetchLatestFirmware` 搬到 `bind` 内部。理由:软绑是"绑定"语义的一部分,不该让"检查更新"承担。同步新增 `versionLog` 状态 + `updateMetadata` 上报。详见 `product-decisions.md`
## sn 级 in-flight 互斥锁UpgradeRecordStore
`tryReportPendingUpgrade` 是 fire-and-forget。如果 demo 短时间内连续两次调 `fetchLatestFirmware`(用户狂点),会出现两个协程并行处理同一 sn 的 pending records 重复 markReported。

View File

@@ -125,6 +125,7 @@ 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? (最新一次拉取结果)
@@ -151,7 +152,9 @@ val info = sdk.getDeviceInfo()
val versionInfo = sdk.getVersion()
// versionInfo.version / versionInfo.type
// 绑定设备 —— 完成后写入 sdk.sn / sdk.version / sdk.isActivated
// 绑定设备 —— 一次调用完成 BLE 硬绑 + getVersion + 软绑 + metadata 上报
// 完成后写入 sdk.sn / sdk.version / sdk.versionLog / sdk.isActivated
// 软绑失败 → 自动硬解绑回滚 + 抛 SoftBindFailed
val bindResult = sdk.bind(userId)
// bindResult.sn / bindResult.contents (设备文件列表)

View File

@@ -33,6 +33,13 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
private val _version = MutableStateFlow("")
val version: StateFlow<String> = _version.asStateFlow()
/**
* 设备返回的 `log` 字段(任意 JSON 对象)。`getVersion` 成功时写入,断连 / 解绑 / 软绑回滚时清空。
* `bind` 软绑成功后透传给 shipment-sn `update-metadata`,集成方一般无需直接读。
*/
private val _versionLog = MutableStateFlow<JSONObject?>(null)
val versionLog: StateFlow<JSONObject?> = _versionLog.asStateFlow()
private val _isActivated = MutableStateFlow(false)
val isActivated: StateFlow<Boolean> = _isActivated.asStateFlow()
@@ -107,12 +114,13 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
_connectedDevice.value = null
_deviceInfo.value = null
_version.value = ""
_versionLog.value = null
_isActivated.value = false
_btState.value = ConnectionState.DISCONNECTED
protocolService.stopListening()
cancelAllPending(DuooomiBleError.NotConnected)
// 与 iOS 一致sn / firmwareUpgrade 不在断开时重置
// —— sn 仅在 unbind 成功 / fetchLatestFirmware 软绑失败回滚时清空
// —— sn 仅在 unbind 成功 / bind 软绑失败回滚时清空
// —— firmwareUpgrade 仅在 fetchLatestFirmware 成功时覆盖
}
}
@@ -206,6 +214,7 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
_connectedDevice.value = null
_deviceInfo.value = null
_version.value = ""
_versionLog.value = null
_isActivated.value = false
_btState.value = ConnectionState.DISCONNECTED
BleLog.i("Disconnected", "Connect")
@@ -241,23 +250,32 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
}
val info = VersionInfo.fromJson(parseJsonResponse(data))
_version.value = info.version
BleLog.i("Version received: ${info.version} (type=${info.type})", "Command")
_versionLog.value = info.log
BleLog.i(
"Version received: ${info.version} (type=${info.type}, log=${if (info.log != null) "present" else "nil"})",
"Command"
)
return info
}
/**
* 硬绑BLE 协议层把当前用户绑定到设备(命令 0x0F完成后同步拉一次版本号
* 完整绑定:内部串四步业务编排(顺序固定),对集成方表现为一次原子调用
*
* **作用域**:只做 BLE 通道的绑定 + [getVersion]。
* 软绑 / 升级对账上报 / 拉最新固件 → 全部移到 [fetchLatestFirmware] 内部串。
* 1. **BLE 硬绑**(命令 0x0F写 [sn] / [isActivated]
* 2. **getVersion**:写 [version] / [versionLog]log 为任意 JSON 对象,缺失则 null
* 3. **软绑**POST `/api/auth/loomart/shipment-sn/device/bind`,登记云端绑定关系
* - 软绑失败 → 自动硬解绑BLE 0x12回滚 + 重置 `isActivated`/`sn`/`versionLog`bind 整体失败
* 4. **updateMetadata**fire-and-forget把 `versionLog` 透传给 `shipment-sn/device/update-metadata`
* - 失败只 warn 不阻断versionLog 为 null / 空对象则跳过
*
* 完成后:
* - [sn] 写入设备序列号
* - [version] 写入当前固件版本getVersion 失败时保持空,但 bind 不算失败)
* - [version] / [versionLog] 写入当前设备状态getVersion 失败时保持空,但 bind 不算失败)
* - [isActivated] = true
*
* @throws DuooomiBleError.NotConnected 未连接
* @throws DuooomiBleError.AlreadyBoundByOtherUser BLE 层 success != 1设备已绑给他人
* @throws DuooomiBleError.SoftBindFailed 软绑失败(含硬解绑回滚后)
*/
suspend fun bind(userId: String): BindingResponse {
ensureConnected()
@@ -274,8 +292,43 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
}
_sn.value = resp.sn
BleLog.i("Hard bind success: sn=${resp.sn}", "Command")
// 同步拉版本,让后续 fetchLatestFirmware 的对账上报有 currentVersion 可用
// Step 2: 同步拉版本(写 version / versionLog 供软绑后的 updateMetadata 透传)
runCatching { getVersion() }
// Step 3: 软绑(失败 → 硬解绑回滚 + 清状态 + 整体失败)
val bindSn = resp.sn
BleLog.d("Soft bind start: sn=$bindSn", "Command")
try {
shipmentService.bind(sn = bindSn, bindUserId = userId)
} catch (e: Exception) {
BleLog.e("Soft bind failed: ${e.message}; rolling back hard unbind", "Command")
runCatching {
deviceInfoService.unbindDevice(userId)
}.onFailure { rb ->
BleLog.w("Hard unbind rollback failed (ignored): ${rb.message}", "Command")
}
_isActivated.value = false
_sn.value = ""
_versionLog.value = null
throw e
}
BleLog.i("Soft bind success: sn=$bindSn", "Command")
// Step 4: updateMetadatafire-and-forgetversionLog 缺失/空则跳过)
val log = _versionLog.value
if (log != null && log.length() > 0) {
scope.launch {
runCatching {
shipmentService.updateMetadata(sn = bindSn, data = log)
}.onSuccess {
BleLog.i("updateMetadata success: sn=$bindSn", "Command")
}.onFailure { e ->
BleLog.w("updateMetadata failed (ignored): ${e.message}", "Command")
}
}
}
return resp
}
@@ -328,6 +381,7 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
}
_isActivated.value = false
_sn.value = ""
_versionLog.value = null
BleLog.i("Hard unbind success", "Command")
if (cachedSn.isNotEmpty()) {
runCatching {
@@ -469,12 +523,12 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
}
/**
* 拉取服务端最新固件信息。**内部串步业务编排**(顺序固定):
* 拉取服务端最新固件信息。**内部串步业务编排**(顺序固定):
*
* 1. **对账上报**:把本地 pending [UpgradeRecord] 逐条报给服务端(容错失败,不阻断)
* 2. **软绑**POST `/api/auth/loomart/shipment-sn/device/bind`,登记云端绑定关系
* - 软绑失败 → 自动硬解绑BLE 0x12回滚 + 重置 `isActivated`/`sn`,整个 fetch 失败
* 3. **拉服务端最新固件**:写入 [firmwareUpgrade],通过返回值回传
* 2. **拉服务端最新固件**:写入 [firmwareUpgrade],通过返回值回传
*
* 软绑 / metadata 上报 / 硬绑失败回滚 → 已全部内聚到 [bind],本方法不再处理。
*/
suspend fun fetchLatestFirmware(
sn: String,
@@ -487,25 +541,7 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
// Step 1: 对账上报历史 OTA 记录容错失败sn 级 in-flight 互斥)
tryReportPendingUpgrade(sn = sn, currentVersion = currentVersion, userId = userId)
// Step 2: 软绑shipment-sn HTTP
BleLog.d("Soft bind start: sn=$sn", "Firmware")
try {
shipmentService.bind(sn = sn, bindUserId = userId)
} catch (e: Exception) {
BleLog.e("Soft bind failed during fetchLatestFirmware: ${e.message}; rolling back hard unbind", "Firmware")
// 硬解绑回滚fire-and-forgetBLE ack 不影响本次 fetch 错误回传)
runCatching {
deviceInfoService.unbindDevice(userId)
}.onFailure { rb ->
BleLog.w("Hard unbind rollback failed (ignored): ${rb.message}", "Firmware")
}
_isActivated.value = false
_sn.value = ""
throw e
}
BleLog.i("Soft bind success: sn=$sn", "Firmware")
// Step 3: 服务端 checkUpgrade
// Step 2: 服务端 checkUpgrade
val result = firmwareUpgradeService.checkUpgrade(sn = sn, currentVersion = currentVersion)
_firmwareUpgrade.value = result
return result

View File

@@ -2,9 +2,14 @@ package com.duooomi.ble.models
import org.json.JSONObject
/**
* @param log 设备返回的 `log` 字段(任意 JSON 对象),缺失/非对象时为 null。
* SDK 不解析其内部结构,原样透传给 `update-metadata` 接口。
*/
data class VersionInfo(
val version: String,
val type: String
val type: String,
val log: JSONObject? = null
) {
companion object {
fun fromJson(json: JSONObject): VersionInfo {
@@ -15,7 +20,8 @@ data class VersionInfo(
}
return VersionInfo(
version = json.optString("version", ""),
type = typeValue
type = typeValue,
log = json.optJSONObject("log")
)
}
}

View File

@@ -4,9 +4,11 @@ import com.duooomi.ble.DuooomiBleConfig
import com.duooomi.ble.core.DuooomiBleError
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
/**
* 服务端 shipment-sn 软绑/软解绑:本地 BLE 硬绑成功后由 SDK 内部调用,集成方无需手动调起。
* 服务端 shipment-sn 软绑/软解绑 / metadata 上报:本地 BLE 硬绑成功后由 SDK 内部调用,
* 集成方无需手动调起。
*/
internal class ShipmentSnDeviceService(private val config: DuooomiBleConfig) {
@@ -41,4 +43,23 @@ internal class ShipmentSnDeviceService(private val config: DuooomiBleConfig) {
}
Unit
}
/**
* 上报设备运行 metadata。`data` 是任意 JSON 对象典型场景BLE getVersion 返回的 log 字段),
* SDK 不做 schema 校验原样透传。2xx 即视为成功。
*/
suspend fun updateMetadata(sn: String, data: JSONObject) = withContext(Dispatchers.IO) {
try {
HttpHelper.post(
config = config,
path = "/api/auth/loomart/shipment-sn/device/update-metadata",
body = mapOf("sn" to sn, "data" to data)
)
} catch (e: HttpHelper.HttpError) {
throw DuooomiBleError.SoftBindFailed(e.message ?: "HTTP ${e.code}")
} catch (e: Exception) {
throw DuooomiBleError.SoftBindFailed(e.message ?: "unknown")
}
Unit
}
}