feat(sdk): OTA 升级流程对齐 expo + 秘钥本地化

主要改动(与 iOS SDK 1:1 对齐):
- 新增 fetchLatestFirmware/upgradeFirmware/tryReportPendingUpgrade
  三段 OTA 接口,对齐 expo bindDeviceWithOrchestration 行为
- bind 仅做硬绑+getVersion,软绑/对账上报/拉最新固件搬到
  fetchLatestFirmware 内部串;软绑失败自动硬解回滚
- unbind 改胖:硬解 + 软解(容错失败仅 warn)
- UpgradeRecord 复合主键 (sn, firmwareId) 持久化(SharedPreferences)
  + sn 级 in-flight 互斥
- 新增 sn / firmwareUpgrade / upgradeRecords StateFlow,集成方
  可观察对账上报结果(reported/outcome 状态)
- DuooomiBleConfig:删 firmwareIdentifier/firmwareStatus,
  改 owner/scanNamePrefix 为必填字段
- 新增 ShipmentSnDeviceService(软绑/软解 HTTP)+
  FirmwareUpgradeService(checkUpgrade/reportSuccess/reportFailure)
  + HttpHelper 共享 POST 工具
- 删 FirmwareInfo + FirmwareService + hasNewerFirmware(旧 API)
- 新增 FirmwareUpgradeCheckResult 模型(含 forceUpgrade 字段)
- BleClient 增加 service UUID 列表 log,便于固件兼容调试
- demo 秘钥移至本地 DemoSecrets.kt(gitignored),模板放 docs/
- demo 改造到新 OTA 流程 + 升级记录展示

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
km2023
2026-05-08 15:42:04 +08:00
parent b4398518a9
commit 2dde274578
16 changed files with 697 additions and 157 deletions

View File

@@ -36,6 +36,18 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
private val _isActivated = MutableStateFlow(false)
val isActivated: StateFlow<Boolean> = _isActivated.asStateFlow()
/** 当前已绑定设备的 SN绑定成功后写入解绑后清空 */
private val _sn = MutableStateFlow("")
val sn: StateFlow<String> = _sn.asStateFlow()
/** 服务端最新一次 [fetchLatestFirmware] 的结果。 */
private val _firmwareUpgrade = MutableStateFlow<FirmwareUpgradeCheckResult?>(null)
val firmwareUpgrade: StateFlow<FirmwareUpgradeCheckResult?> = _firmwareUpgrade.asStateFlow()
/** 当前 OTA 升级记录快照(按 triggeredAt 倒序展示用;持久化于 SharedPreferences。 */
private val _upgradeRecords = MutableStateFlow<List<UpgradeRecord>>(emptyList())
val upgradeRecords: StateFlow<List<UpgradeRecord>> = _upgradeRecords.asStateFlow()
private val _transferProgress = MutableStateFlow(0)
val transferProgress: StateFlow<Int> = _transferProgress.asStateFlow()
@@ -50,7 +62,9 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
private val deviceInfoService = DeviceInfoService(protocolService, timeProvider)
private val fileTransferService = FileTransferService(protocolService)
private val aniConverter = AniConverter(config)
private val firmwareService = FirmwareService(config)
private val shipmentService = ShipmentSnDeviceService(config)
private val firmwareUpgradeService = FirmwareUpgradeService(config)
private val upgradeRecordStore = UpgradeRecordStore(context)
// MARK: - Request-Response
@@ -69,8 +83,13 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
private var flushJob: Job? = null
init {
BleLog.i("SDK initialized (apiHost=${config.apiHost}, firmware=${config.firmwareIdentifier}/${config.firmwareStatus})", "SDK")
BleLog.i("SDK initialized (apiHost=${config.apiHost}, owner=${config.owner})", "SDK")
setupDisconnectHandler()
// 启动时把持久化记录回放到 StateFlow
_upgradeRecords.value = upgradeRecordStore.all
upgradeRecordStore.onChange = { records ->
scope.launch { _upgradeRecords.value = records }
}
}
private fun setupDisconnectHandler() {
@@ -81,6 +100,8 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
_deviceInfo.value = null
_version.value = ""
_isActivated.value = false
_sn.value = ""
_firmwareUpgrade.value = null
_btState.value = ConnectionState.DISCONNECTED
protocolService.stopListening()
cancelAllPending(DuooomiBleError.NotConnected)
@@ -177,6 +198,8 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
_deviceInfo.value = null
_version.value = ""
_isActivated.value = false
_sn.value = ""
_firmwareUpgrade.value = null
_btState.value = ConnectionState.DISCONNECTED
BleLog.i("Disconnected", "Connect")
@@ -215,6 +238,20 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
return info
}
/**
* 硬绑BLE 协议层把当前用户绑定到设备(命令 0x0F完成后同步拉一次版本号。
*
* **作用域**:只做 BLE 通道的绑定 + [getVersion]。
* 软绑 / 升级对账上报 / 拉最新固件 → 全部移到 [fetchLatestFirmware] 内部串。
*
* 完成后:
* - [sn] 写入设备序列号
* - [version] 写入当前固件版本getVersion 失败时保持空,但 bind 不算失败)
* - [isActivated] = true
*
* @throws DuooomiBleError.NotConnected 未连接
* @throws DuooomiBleError.AlreadyBoundByOtherUser BLE 层 success != 1设备已绑给他人
*/
suspend fun bind(userId: String): BindingResponse {
ensureConnected()
BleLog.d("Sending bind (userId=$userId)", "Command")
@@ -224,10 +261,13 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
val resp = BindingResponse.fromJson(parseJsonResponse(data))
_isActivated.value = resp.success == 1
if (resp.success != 1) {
BleLog.w("Bind failed: device already bound", "Command")
throw DuooomiBleError.BindingFailed("Device already bound to another user")
BleLog.w("Hard bind failed: success=0", "Command")
throw DuooomiBleError.AlreadyBoundByOtherUser
}
BleLog.i("Bind success: sn=${resp.sn}", "Command")
_sn.value = resp.sn
BleLog.i("Hard bind success: sn=${resp.sn}", "Command")
// 同步拉版本,让后续 fetchLatestFirmware 的对账上报有 currentVersion 可用
runCatching { getVersion() }
return resp
}
@@ -261,20 +301,33 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
return resp
}
/**
* 解绑硬解绑BLE 0x12→ 软解绑shipment-sn HTTP。SDK 内部完成全套。
*
* 软解失败仅 warn 不阻断硬解结果回传 —— 对端云记录如果残留,由后续 bind 自然修正。
*/
suspend fun unbind(userId: String): UnbindResponse {
ensureConnected()
val cachedSn = _sn.value
BleLog.d("Sending unbind (userId=$userId)", "Command")
val data = sendAndWait(CommandType.UNBIND_DEVICE) {
deviceInfoService.unbindDevice(userId)
}
val resp = UnbindResponse.fromJson(parseJsonResponse(data))
if (resp.success == 1) {
_isActivated.value = false
BleLog.i("Unbind success", "Command")
} else {
if (resp.success != 1) {
BleLog.w("Unbind failed", "Command")
throw DuooomiBleError.UnbindFailed
}
_isActivated.value = false
_sn.value = ""
BleLog.i("Hard unbind success", "Command")
if (cachedSn.isNotEmpty()) {
runCatching {
shipmentService.unbind(sn = cachedSn, bindUserId = userId)
}.onFailure { e ->
BleLog.w("Soft unbind failed (ignored): ${e.message}", "Command")
}
}
return resp
}
@@ -407,18 +460,136 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
transferFile(fileUri = aniUrl, commandType = CommandType.TRANSFER_ANI_VIDEO)
}
/**
* 拉取服务端最新固件信息。**内部串三步业务编排**(顺序固定):
*
* 1. **对账上报**:把本地 pending [UpgradeRecord] 逐条报给服务端(容错失败,不阻断)
* 2. **软绑**POST `/api/auth/loomart/shipment-sn/device/bind`,登记云端绑定关系
* - 软绑失败 → 自动硬解绑BLE 0x12回滚 + 重置 `isActivated`/`sn`,整个 fetch 失败
* 3. **拉服务端最新固件**:写入 [firmwareUpgrade],通过返回值回传
*/
suspend fun fetchLatestFirmware(
identifier: String? = null,
status: String? = null
): FirmwareInfo? {
return firmwareService.fetchLatest(identifier, status)
sn: String,
currentVersion: String,
userId: String
): FirmwareUpgradeCheckResult {
if (sn.isEmpty()) {
throw DuooomiBleError.FirmwareUpgradeUnavailable("sn is empty (bind first)")
}
// 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
val result = firmwareUpgradeService.checkUpgrade(sn = sn, currentVersion = currentVersion)
_firmwareUpgrade.value = result
return result
}
suspend fun upgradeFirmware(fileUrl: String) {
/**
* OTA 固件烧录。完成后自动落 [UpgradeRecord] 到本地(复合主键 `(sn, firmwareId)`
* 等待下次 [fetchLatestFirmware] 触发 [tryReportPendingUpgrade] 对账上报。
*/
suspend fun upgradeFirmware(
sn: String,
fileUrl: String,
firmwareId: String,
fromVersion: String,
targetVersion: String
) {
ensureConnected()
BleLog.i("OTA upgrade start: $fileUrl", "Firmware")
BleLog.i("OTA upgrade start: sn=$sn $fromVersion$targetVersion (fw=$firmwareId)", "Firmware")
transferFile(fileUri = fileUrl, commandType = CommandType.OTA_PACKAGE)
BleLog.i("OTA upgrade completed", "Firmware")
if (sn.isNotEmpty() && firmwareId.isNotEmpty() && fromVersion.isNotEmpty() && targetVersion.isNotEmpty()) {
upgradeRecordStore.upsert(
UpgradeRecord(
sn = sn,
firmwareId = firmwareId,
fromVersion = fromVersion,
targetVersion = targetVersion,
triggeredAt = UpgradeRecordStore.nowMillis()
)
)
BleLog.i("UpgradeRecord upserted: sn=$sn, fw=$firmwareId, $fromVersion$targetVersion", "Firmware")
}
}
/**
* 升级对账上报:把本地 pending [UpgradeRecord] 逐条报给服务端。
*
* [fetchLatestFirmware] 内部第一步会调,集成方一般无需直接调用。**fire-and-forget**——
* 没有异常抛出,结果通过 [upgradeRecords] 中 `reported` / `outcome` 字段反映。
*
* 内部逻辑(对齐 expo `bleStore.tryReportPendingUpgrade`
* - 30 天 TTL超期记录 → `markReported(outcome=null)`,保留为审计轨迹
* - 设备实际版本 == 目标版本 → `report-success` → `outcome=SUCCESS`
* - 不命中 → `report-failure(VERSION_MISMATCH ...)` → `outcome=VERSION_MISMATCH`
* - HTTP 失败 → 保持 `reported=false`,下次再重试
* - sn 级 in-flight 互斥锁防止并发重复上报
*/
fun tryReportPendingUpgrade(sn: String, currentVersion: String, userId: String) {
if (sn.isEmpty() || currentVersion.isEmpty()) return
if (!upgradeRecordStore.tryAcquire(sn)) return
scope.launch {
try {
val pending = upgradeRecordStore.pending(sn)
val now = UpgradeRecordStore.nowMillis()
for (r in pending) {
if (now - r.triggeredAt > UpgradeRecordStore.TTL_MILLIS) {
upgradeRecordStore.markReported(r.sn, r.firmwareId, outcome = null)
continue
}
if (currentVersion == r.targetVersion) {
runCatching {
firmwareUpgradeService.reportUpgradeSuccess(
sn = r.sn,
firmwareId = r.firmwareId,
fromVersion = r.fromVersion,
bindUserId = userId
)
}.onSuccess {
upgradeRecordStore.markReported(r.sn, r.firmwareId, UpgradeOutcome.SUCCESS)
}.onFailure { e ->
BleLog.w("Report success failed for fw ${r.firmwareId}: ${e.message}; will retry", "Firmware")
}
} else {
val reason = "VERSION_MISMATCH expected=${r.targetVersion} actual=$currentVersion sn=${r.sn} userId=$userId"
runCatching {
firmwareUpgradeService.reportUpgradeFailure(
sn = r.sn,
firmwareId = r.firmwareId,
fromVersion = r.fromVersion,
failureReason = reason
)
}.onSuccess {
upgradeRecordStore.markReported(r.sn, r.firmwareId, UpgradeOutcome.VERSION_MISMATCH)
}.onFailure { e ->
BleLog.w("Report failure failed for fw ${r.firmwareId}: ${e.message}; will retry", "Firmware")
}
}
}
} finally {
upgradeRecordStore.release(sn)
}
}
}
// MARK: - Internal Helpers
@@ -553,16 +724,6 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
} ?: BleLog.w("No pending continuation for opId=$opId", "RPC")
}
// MARK: - Version Check
fun hasNewerFirmware(deviceVersion: String?, serverVersion: String?): Boolean {
if (deviceVersion.isNullOrEmpty() || serverVersion.isNullOrEmpty()) return false
if (deviceVersion.length < 10 || serverVersion.length < 10) return false
val deviceTs = deviceVersion.takeLast(10).toLongOrNull() ?: return false
val serverTs = serverVersion.takeLast(10).toLongOrNull() ?: return false
return serverTs > deviceTs
}
// MARK: - Helpers
private fun ensureConnected() {