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:
@@ -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-forget;BLE 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() {
|
||||
|
||||
Reference in New Issue
Block a user