diff --git a/.gitignore b/.gitignore index 48e67a5..52fe9c9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ local.properties .idea/ .DS_Store sdk/build/ +demo/src/main/kotlin/com/duooomi/ble/demo/DemoSecrets.kt diff --git a/demo/src/main/kotlin/com/duooomi/ble/demo/DemoScreen.kt b/demo/src/main/kotlin/com/duooomi/ble/demo/DemoScreen.kt index a01a667..df67525 100644 --- a/demo/src/main/kotlin/com/duooomi/ble/demo/DemoScreen.kt +++ b/demo/src/main/kotlin/com/duooomi/ble/demo/DemoScreen.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.duooomi.ble.DuooomiBleSDK import com.duooomi.ble.core.ConnectionState -import com.duooomi.ble.models.FirmwareInfo +import com.duooomi.ble.models.FirmwareUpgradeCheckResult import com.duooomi.ble.models.PlayMode import com.duooomi.ble.protocol.CommandType import kotlinx.coroutines.launch @@ -60,9 +60,9 @@ fun DemoScreen(sdk: DuooomiBleSDK) { // Local state var userId by remember { mutableStateOf("test-user-001") } var fileUrl by remember { mutableStateOf("https://cdn.bowong.cc/material/569f48a8e29f47859b3a9808be37f94c.mp4") } - var firmwareBrand by remember { mutableStateOf(sdk.config.firmwareIdentifier) } - var firmwareStatus by remember { mutableStateOf(sdk.config.firmwareStatus) } - var firmwareInfo by remember { mutableStateOf(null) } + val sn by sdk.sn.collectAsState() + val firmwareUpgrade by sdk.firmwareUpgrade.collectAsState() + val upgradeRecords by sdk.upgradeRecords.collectAsState() var firmwareLoading by remember { mutableStateOf(false) } var isBusy by remember { mutableStateOf(false) } var connectingDeviceId by remember { mutableStateOf(null) } @@ -322,40 +322,21 @@ fun DemoScreen(sdk: DuooomiBleSDK) { // === Firmware Section === item { SectionHeader("Firmware Update") + StatusRow("SN", sn.ifEmpty { "--" }) StatusRow("Current Version", version.ifEmpty { "--" }) - OutlinedTextField( - value = firmwareBrand, - onValueChange = { firmwareBrand = it }, - label = { Text("Identifier") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - listOf("DRAFT", "PUBLISHED").forEach { status -> - FilterChip( - selected = firmwareStatus == status, - onClick = { firmwareStatus = status }, - label = { Text(status) } - ) - } - } - - firmwareInfo?.let { info -> + firmwareUpgrade?.targetFirmware?.let { target -> Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(12.dp)) { - StatusRow("Latest", info.version) - info.fileSize?.let { StatusRow("Size", "$it bytes") } - info.fileMd5?.takeIf { it.isNotEmpty() }?.let { - Text("MD5: $it", fontSize = 10.sp, color = Color.Gray) - } - info.description?.takeIf { it.isNotEmpty() }?.let { - Text(it, fontSize = 12.sp, color = Color.Gray) - } - Text(info.fileUrl, fontSize = 10.sp, color = Color.Gray, maxLines = 2) + StatusRow("Latest", target.version) + target.fileSize?.let { StatusRow("Size", "$it bytes") } + target.fileMd5?.let { Text("MD5: $it", fontSize = 10.sp, color = Color.Gray) } + target.forceUpgrade?.let { Text("forceUpgrade=$it", fontSize = 11.sp, color = Color.Red) } + target.fileUrl?.let { Text(it, fontSize = 10.sp, color = Color.Gray, maxLines = 2) } } } + } ?: firmwareUpgrade?.let { + Text("Already up-to-date", fontSize = 12.sp, color = Color.Gray) } if (firmwareLoading) { @@ -371,40 +352,67 @@ fun DemoScreen(sdk: DuooomiBleSDK) { onClick = { scope.launch { firmwareLoading = true - log("-> fetchLatestFirmware($firmwareBrand, $firmwareStatus)") + log("-> fetchLatestFirmware(sn=$sn, version=$version, $userId)") try { - val info = sdk.fetchLatestFirmware(firmwareBrand, firmwareStatus) - firmwareInfo = info - if (info != null) { - log("firmware latest: ${info.version}", LogLevel.SUCCESS) + val r = sdk.fetchLatestFirmware(sn = sn, currentVersion = version, userId = userId) + val target = r.targetFirmware + if (target != null) { + log("firmware latest: ${target.version}", LogLevel.SUCCESS) } else { - log("no firmware available") + log("already up-to-date") } } catch (e: Exception) { log("firmware info FAIL: ${e.message}", LogLevel.ERROR) } finally { firmwareLoading = false } } }, - enabled = !firmwareLoading && firmwareBrand.isNotEmpty() + enabled = !firmwareLoading && sn.isNotEmpty() ) { Text("Get Update Info") } Button( onClick = { scope.launch { - val info = firmwareInfo ?: return@launch + val target: FirmwareUpgradeCheckResult.TargetFirmware = + firmwareUpgrade?.targetFirmware ?: return@launch + val targetFileUrl = target.fileUrl ?: return@launch isBusy = true - log("-> upgradeFirmware ${info.version}") + log("-> upgradeFirmware ${target.version}") try { - sdk.upgradeFirmware(info.fileUrl) + sdk.upgradeFirmware( + sn = sn, + fileUrl = targetFileUrl, + firmwareId = target.id, + fromVersion = version, + targetVersion = target.version + ) log("OTA OK", LogLevel.SUCCESS) } catch (e: Exception) { log("OTA FAIL: ${e.message}", LogLevel.ERROR) } finally { isBusy = false } } }, - enabled = firmwareInfo?.fileUrl?.isNotEmpty() == true && connectedDevice != null && !isBusy + enabled = firmwareUpgrade?.targetFirmware?.fileUrl?.isNotEmpty() == true + && connectedDevice != null && !isBusy ) { Text("Upgrade") } } + + if (upgradeRecords.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Text("Upgrade Records (${upgradeRecords.size})", fontSize = 12.sp, fontWeight = FontWeight.Bold) + upgradeRecords.sortedByDescending { it.triggeredAt }.take(5).forEach { r -> + val outcome = r.outcome + val outcomeText = when { + !r.reported -> "pending" + outcome == null -> "expired" + else -> outcome.raw + } + Text( + "[$outcomeText] fw=${r.firmwareId} ${r.fromVersion}→${r.targetVersion}", + fontSize = 10.sp, + color = Color.Gray + ) + } + } } // === Device Files Section === diff --git a/demo/src/main/kotlin/com/duooomi/ble/demo/MainActivity.kt b/demo/src/main/kotlin/com/duooomi/ble/demo/MainActivity.kt index 5cd514c..ad3467b 100644 --- a/demo/src/main/kotlin/com/duooomi/ble/demo/MainActivity.kt +++ b/demo/src/main/kotlin/com/duooomi/ble/demo/MainActivity.kt @@ -28,7 +28,11 @@ class MainActivity : ComponentActivity() { sdk = DuooomiBleSDK( context = this, - config = DuooomiBleConfig(apiKey = "") + config = DuooomiBleConfig( + apiKey = DemoSecrets.API_KEY, + owner = DemoSecrets.OWNER, + scanNamePrefix = DemoSecrets.SCAN_NAME_PREFIX + ) ) requestBlePermissions() diff --git a/docs/DemoSecrets.template.kt b/docs/DemoSecrets.template.kt new file mode 100644 index 0000000..daa4915 --- /dev/null +++ b/docs/DemoSecrets.template.kt @@ -0,0 +1,10 @@ +package com.duooomi.ble.demo + +/** + * Copy this file to `DemoSecrets.kt` (gitignored) and fill in real credentials. + */ +object DemoSecrets { + const val API_KEY: String = "" + const val OWNER: String = "" + const val SCAN_NAME_PREFIX: String = "Duooomi-" +} diff --git a/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleConfig.kt b/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleConfig.kt index c6629f8..f92848b 100644 --- a/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleConfig.kt +++ b/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleConfig.kt @@ -1,11 +1,19 @@ package com.duooomi.ble +/** + * SDK 全局配置,在初始化时一次性传入。 + */ data class DuooomiBleConfig( + /** API 服务地址 */ val apiHost: String = "https://api.duooomi.com", + /** API 鉴权 key(Loomart x-api-key) */ val apiKey: String, + /** 多租户 owner 标识,所有 RPC 必带 `x-owner` header */ + val owner: String, + /** 扫描时按设备蓝牙广播名前缀过滤(大小写不敏感)。**必填**,由集成方约定固件广播名前缀。 */ + val scanNamePrefix: String, + /** CDN 资源地址(末尾自动补 `/`) */ val cdnHost: String = "https://cdn.bowong.cc/", - val firmwareIdentifier: String = "duomi", - val firmwareStatus: String = "DRAFT", /** ANI 转换宽度(像素) */ val aniWidth: Int = 360, /** ANI 转换高度(像素) */ diff --git a/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleSDK.kt b/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleSDK.kt index 33e8a9a..14a879c 100644 --- a/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleSDK.kt +++ b/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleSDK.kt @@ -36,6 +36,18 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) { private val _isActivated = MutableStateFlow(false) val isActivated: StateFlow = _isActivated.asStateFlow() + /** 当前已绑定设备的 SN(绑定成功后写入,解绑后清空) */ + private val _sn = MutableStateFlow("") + val sn: StateFlow = _sn.asStateFlow() + + /** 服务端最新一次 [fetchLatestFirmware] 的结果。 */ + private val _firmwareUpgrade = MutableStateFlow(null) + val firmwareUpgrade: StateFlow = _firmwareUpgrade.asStateFlow() + + /** 当前 OTA 升级记录快照(按 triggeredAt 倒序展示用;持久化于 SharedPreferences)。 */ + private val _upgradeRecords = MutableStateFlow>(emptyList()) + val upgradeRecords: StateFlow> = _upgradeRecords.asStateFlow() + private val _transferProgress = MutableStateFlow(0) val transferProgress: StateFlow = _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() { diff --git a/sdk/src/main/kotlin/com/duooomi/ble/core/BleClient.kt b/sdk/src/main/kotlin/com/duooomi/ble/core/BleClient.kt index 30e9e52..1dff89e 100644 --- a/sdk/src/main/kotlin/com/duooomi/ble/core/BleClient.kt +++ b/sdk/src/main/kotlin/com/duooomi/ble/core/BleClient.kt @@ -287,6 +287,8 @@ internal class BleClient(context: Context) { return } + val services = gatt.services ?: emptyList() + BleLog.d("Discovered ${services.size} service(s): ${services.map { it.uuid.toString() }}", "BLE") val service = gatt.getService(BleUUIDs.SERVICE) if (service == null) { val cont = connectContinuation diff --git a/sdk/src/main/kotlin/com/duooomi/ble/core/BleTypes.kt b/sdk/src/main/kotlin/com/duooomi/ble/core/BleTypes.kt index 76a2415..8220709 100644 --- a/sdk/src/main/kotlin/com/duooomi/ble/core/BleTypes.kt +++ b/sdk/src/main/kotlin/com/duooomi/ble/core/BleTypes.kt @@ -20,6 +20,9 @@ sealed class DuooomiBleError(message: String) : Exception(message) { object ServiceNotFound : DuooomiBleError("BLE service not found") object CharacteristicNotFound : DuooomiBleError("Characteristic not found") class BindingFailed(reason: String) : DuooomiBleError("Binding failed: $reason") + object AlreadyBoundByOtherUser : DuooomiBleError("Device already bound to another user") + class SoftBindFailed(reason: String) : DuooomiBleError("Soft bind failed: $reason") + class FirmwareUpgradeUnavailable(reason: String) : DuooomiBleError("Firmware upgrade unavailable: $reason") object UnbindFailed : DuooomiBleError("Unbind failed") class DeleteFileFailed(val status: Int) : DuooomiBleError("Delete failed: status=$status") class PrepareTransferFailed(val status: String) : DuooomiBleError("Prepare failed: $status") diff --git a/sdk/src/main/kotlin/com/duooomi/ble/models/FirmwareInfo.kt b/sdk/src/main/kotlin/com/duooomi/ble/models/FirmwareInfo.kt deleted file mode 100644 index 7b4ccfb..0000000 --- a/sdk/src/main/kotlin/com/duooomi/ble/models/FirmwareInfo.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.duooomi.ble.models - -import org.json.JSONObject - -data class FirmwareInfo( - val version: String, - val fileUrl: String, - val description: String?, - val fileSize: String?, - val fileMd5: String?, - val identifier: String?, - val status: String? -) { - companion object { - fun fromJson(json: JSONObject): FirmwareInfo { - return FirmwareInfo( - version = json.optString("version", ""), - fileUrl = json.optString("fileUrl", ""), - description = json.optString("description").ifEmpty { null }, - fileSize = json.optString("fileSize").ifEmpty { null }, - fileMd5 = json.optString("fileMd5").ifEmpty { null }, - identifier = json.optString("identifier").ifEmpty { null }, - status = json.optString("status").ifEmpty { null } - ) - } - } -} diff --git a/sdk/src/main/kotlin/com/duooomi/ble/models/FirmwareUpgradeCheckResult.kt b/sdk/src/main/kotlin/com/duooomi/ble/models/FirmwareUpgradeCheckResult.kt new file mode 100644 index 0000000..1aafdbb --- /dev/null +++ b/sdk/src/main/kotlin/com/duooomi/ble/models/FirmwareUpgradeCheckResult.kt @@ -0,0 +1,53 @@ +package com.duooomi.ble.models + +import org.json.JSONObject + +/** + * 服务端 `/firmware/upgrade/check` 响应。 + * + * **判断能否升级看 [targetFirmware] 是否非空** —— SDK 不再暴露 client 端 "判断"语义, + * 回归"拉取最新固件信息"单一职责。 + */ +data class FirmwareUpgradeCheckResult( + val currentSystemVersion: String?, + val targetFirmware: TargetFirmware? +) { + data class TargetFirmware( + /** 固件 ID。`UpgradeRecord.firmwareId` 来源、对账上报必带。 */ + val id: String, + /** 固件版本字符串(如 "1.2.3")。UI 展示「最新版本」+ 落 `UpgradeRecord.targetVersion`。 */ + val version: String, + /** 固件包下载 URL。烧录入参 `upgradeFirmware(fileUrl=)`. */ + val fileUrl: String?, + /** 固件包字节数(字符串编码,避免 Long 溢出)。 */ + val fileSize: String?, + /** 固件包 MD5。集成方有需要可校验下载内容;SDK 不做校验。 */ + val fileMd5: String?, + /** + * 强制升级标记。`true` 表示该版本必须升级(典型场景:安全补丁 / 协议破坏性变更), + * 集成方应据此弹不可关闭的升级弹窗、阻断设备其他操作;`false` / `null` 由用户自行决定。 + */ + val forceUpgrade: Boolean? + ) { + companion object { + fun fromJson(json: JSONObject): TargetFirmware = TargetFirmware( + id = json.optString("id", ""), + version = json.optString("version", ""), + fileUrl = json.optString("fileUrl").takeIf { it.isNotEmpty() }, + fileSize = json.optString("fileSize").takeIf { it.isNotEmpty() }, + fileMd5 = json.optString("fileMd5").takeIf { it.isNotEmpty() }, + forceUpgrade = if (json.has("forceUpgrade")) json.optBoolean("forceUpgrade") else null + ) + } + } + + companion object { + fun fromJson(json: JSONObject): FirmwareUpgradeCheckResult { + val target = json.optJSONObject("targetFirmware")?.let { TargetFirmware.fromJson(it) } + return FirmwareUpgradeCheckResult( + currentSystemVersion = json.optString("currentSystemVersion").takeIf { it.isNotEmpty() }, + targetFirmware = target + ) + } + } +} diff --git a/sdk/src/main/kotlin/com/duooomi/ble/models/UpgradeRecord.kt b/sdk/src/main/kotlin/com/duooomi/ble/models/UpgradeRecord.kt new file mode 100644 index 0000000..a0646b2 --- /dev/null +++ b/sdk/src/main/kotlin/com/duooomi/ble/models/UpgradeRecord.kt @@ -0,0 +1,66 @@ +package com.duooomi.ble.models + +import org.json.JSONObject + +/** + * 升级对账结果。 + * - [SUCCESS]: 设备实际版本 == 目标版本,已成功上报 + * - [VERSION_MISMATCH]: 实际版本 ≠ 目标版本,已上报 failure + * - 持久化时 `outcome=null` 含义:30 天 TTL 过期未上报,保留为审计轨迹 + */ +enum class UpgradeOutcome(val raw: String) { + SUCCESS("success"), + VERSION_MISMATCH("version_mismatch"); + + companion object { + fun fromRaw(raw: String?): UpgradeOutcome? = when (raw) { + SUCCESS.raw -> SUCCESS + VERSION_MISMATCH.raw -> VERSION_MISMATCH + else -> null + } + } +} + +/** + * OTA 升级记录。复合主键 `(sn, firmwareId)`。 + * 与 expo `bleStore.upgradeRecords` 1:1 对齐。 + */ +data class UpgradeRecord( + val sn: String, + val firmwareId: String, + val fromVersion: String, + val targetVersion: String, + /** 触发 OTA 烧录的时间戳(毫秒,与 RN `Date.now()` 同口径) */ + val triggeredAt: Long, + val reported: Boolean = false, + val reportedAt: Long? = null, + val outcome: UpgradeOutcome? = null +) { + fun toJson(): JSONObject { + val o = JSONObject() + .put("sn", sn) + .put("firmwareId", firmwareId) + .put("fromVersion", fromVersion) + .put("targetVersion", targetVersion) + .put("triggeredAt", triggeredAt) + .put("reported", reported) + if (reportedAt != null) o.put("reportedAt", reportedAt) + if (outcome != null) o.put("outcome", outcome.raw) + return o + } + + companion object { + fun fromJson(json: JSONObject): UpgradeRecord = UpgradeRecord( + sn = json.optString("sn", ""), + firmwareId = json.optString("firmwareId", ""), + fromVersion = json.optString("fromVersion", ""), + targetVersion = json.optString("targetVersion", ""), + triggeredAt = json.optLong("triggeredAt", 0L), + reported = json.optBoolean("reported", false), + reportedAt = if (json.has("reportedAt") && !json.isNull("reportedAt")) json.optLong("reportedAt") else null, + outcome = UpgradeOutcome.fromRaw( + if (json.has("outcome") && !json.isNull("outcome")) json.optString("outcome") else null + ) + ) + } +} diff --git a/sdk/src/main/kotlin/com/duooomi/ble/services/FirmwareService.kt b/sdk/src/main/kotlin/com/duooomi/ble/services/FirmwareService.kt deleted file mode 100644 index a6c539e..0000000 --- a/sdk/src/main/kotlin/com/duooomi/ble/services/FirmwareService.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.duooomi.ble.services - -import com.duooomi.ble.DuooomiBleConfig -import com.duooomi.ble.core.DuooomiBleError -import com.duooomi.ble.models.FirmwareInfo -import com.duooomi.ble.utils.BleLog -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.json.JSONObject -import java.net.HttpURLConnection -import java.net.URL -import java.net.URLEncoder - -internal class FirmwareService(private val config: DuooomiBleConfig) { - - private val latestPath = "api/auth/loomart/firmware/latest-published" - - suspend fun fetchLatest( - identifier: String? = null, - status: String? = null - ): FirmwareInfo? = withContext(Dispatchers.IO) { - val id = (identifier ?: config.firmwareIdentifier).trim() - val st = status ?: config.firmwareStatus - - if (id.isEmpty()) { - throw DuooomiBleError.TransferFailed("Invalid firmware identifier") - } - - val encodedId = URLEncoder.encode(id, "UTF-8") - val encodedStatus = URLEncoder.encode(st, "UTF-8") - val urlStr = "${config.apiHost}/$latestPath?identifier=$encodedId&status=$encodedStatus" - - val connection = URL(urlStr).openConnection() as HttpURLConnection - try { - connection.requestMethod = "GET" - connection.setRequestProperty("x-api-key", config.apiKey) - connection.setRequestProperty("accept", "application/json") - connection.connectTimeout = 30_000 - connection.readTimeout = 30_000 - - val code = connection.responseCode - if (code !in 200..299) { - throw DuooomiBleError.TransferFailed("HTTP $code") - } - - val responseText = connection.inputStream.bufferedReader().readText() - val json = JSONObject(responseText) - - if (json.optBoolean("success") != true) { - return@withContext null - } - - val dataObj = json.optJSONObject("data") ?: return@withContext null - FirmwareInfo.fromJson(dataObj) - } finally { - connection.disconnect() - } - } -} diff --git a/sdk/src/main/kotlin/com/duooomi/ble/services/FirmwareUpgradeService.kt b/sdk/src/main/kotlin/com/duooomi/ble/services/FirmwareUpgradeService.kt new file mode 100644 index 0000000..7394550 --- /dev/null +++ b/sdk/src/main/kotlin/com/duooomi/ble/services/FirmwareUpgradeService.kt @@ -0,0 +1,79 @@ +package com.duooomi.ble.services + +import com.duooomi.ble.DuooomiBleConfig +import com.duooomi.ble.core.DuooomiBleError +import com.duooomi.ble.models.FirmwareUpgradeCheckResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject + +/** + * 固件升级 RPC:检查升级 / 升级成功上报 / 升级失败上报。 + * 与 RN `FirmwareController.{checkUpgrade, reportUpgradeSuccessBySn, reportUpgradeFailureBySn}` 1:1 对齐。 + */ +internal class FirmwareUpgradeService(private val config: DuooomiBleConfig) { + + /** 由服务端判断是否需要升级。 */ + suspend fun checkUpgrade(sn: String, currentVersion: String?): FirmwareUpgradeCheckResult = + withContext(Dispatchers.IO) { + val body = mutableMapOf("sn" to sn) + if (!currentVersion.isNullOrEmpty()) body["currentVersion"] = currentVersion + val raw = try { + HttpHelper.post( + config = config, + path = "/api/auth/loomart/firmware/upgrade/check", + body = body + ) + } catch (e: HttpHelper.HttpError) { + throw DuooomiBleError.TransferFailed(e.message ?: "HTTP ${e.code}") + } + FirmwareUpgradeCheckResult.fromJson(JSONObject(raw)) + } + + suspend fun reportUpgradeSuccess( + sn: String, + firmwareId: String, + fromVersion: String, + bindUserId: String? + ) = withContext(Dispatchers.IO) { + val body = mutableMapOf( + "sn" to sn, + "firmwareId" to firmwareId, + "fromVersion" to fromVersion + ) + if (!bindUserId.isNullOrEmpty()) body["bindUserId"] = bindUserId + try { + HttpHelper.post( + config = config, + path = "/api/auth/loomart/firmware/upgrade/report-success", + body = body + ) + } catch (e: HttpHelper.HttpError) { + throw DuooomiBleError.TransferFailed(e.message ?: "HTTP ${e.code}") + } + Unit + } + + suspend fun reportUpgradeFailure( + sn: String, + firmwareId: String, + fromVersion: String, + failureReason: String + ) = withContext(Dispatchers.IO) { + try { + HttpHelper.post( + config = config, + path = "/api/auth/loomart/firmware/upgrade/report-failure", + body = mapOf( + "sn" to sn, + "firmwareId" to firmwareId, + "fromVersion" to fromVersion, + "failureReason" to failureReason + ) + ) + } catch (e: HttpHelper.HttpError) { + throw DuooomiBleError.TransferFailed(e.message ?: "HTTP ${e.code}") + } + Unit + } +} diff --git a/sdk/src/main/kotlin/com/duooomi/ble/services/HttpHelper.kt b/sdk/src/main/kotlin/com/duooomi/ble/services/HttpHelper.kt new file mode 100644 index 0000000..56452ac --- /dev/null +++ b/sdk/src/main/kotlin/com/duooomi/ble/services/HttpHelper.kt @@ -0,0 +1,84 @@ +package com.duooomi.ble.services + +import com.duooomi.ble.DuooomiBleConfig +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL + +/** + * SDK 内部共享的最小 HTTP POST helper。 + * 统一带 `x-api-key` + `x-owner` header,统一剥离后端 `API error:` 前缀。 + */ +internal object HttpHelper { + + /** + * @return 响应体 String(2xx)。`unwrap = true` 时尝试从 `{success, data}` 包装中抽出 `data` 字段(JSON 字符串)。 + * @throws IOException / HttpError on non-2xx + */ + fun post( + config: DuooomiBleConfig, + path: String, + body: Map, + timeoutMs: Int = 10_000, + unwrap: Boolean = true + ): String { + val url = URL(config.apiHost.trimEnd('/') + path) + val conn = url.openConnection() as HttpURLConnection + try { + conn.requestMethod = "POST" + conn.connectTimeout = timeoutMs + conn.readTimeout = timeoutMs + conn.doOutput = true + conn.setRequestProperty("Content-Type", "application/json") + conn.setRequestProperty("Accept", "application/json") + conn.setRequestProperty("x-api-key", config.apiKey) + conn.setRequestProperty("x-owner", config.owner) + + val payload = JSONObject().apply { + for ((k, v) in body) { + if (v != null) put(k, v) + } + } + conn.outputStream.use { it.write(payload.toString().toByteArray(Charsets.UTF_8)) } + + val code = conn.responseCode + val raw = if (code in 200..299) { + conn.inputStream.bufferedReader().use { it.readText() } + } else { + val errBody = runCatching { conn.errorStream?.bufferedReader()?.use { it.readText() } }.getOrNull() + throw HttpError(code, parseErrorMessage(errBody) ?: "HTTP $code") + } + + if (!unwrap) return raw + + return runCatching { + val o = JSONObject(raw) + if (o.optBoolean("success", false) && o.has("data")) { + val inner = o.opt("data") + inner?.toString() ?: raw + } else raw + }.getOrDefault(raw) + } finally { + runCatching { conn.disconnect() } + } + } + + /** 剥离 `API error:` 前缀,必要时从 JSON 中抽 message 字段,与 RN 端 stripApiErrorPrefix 行为对齐。 */ + fun parseErrorMessage(raw: String?): String? { + if (raw.isNullOrBlank()) return null + var stripped = raw + if (stripped.startsWith("API error:")) { + stripped = stripped.removePrefix("API error:").trim() + } + if (stripped.startsWith("{")) { + runCatching { + val json = JSONObject(stripped) + json.optString("message").takeIf { it.isNotEmpty() }?.let { return it } + json.optJSONObject("error")?.optString("message")?.takeIf { it.isNotEmpty() }?.let { return it } + } + } + return stripped.ifEmpty { null } + } + + class HttpError(val code: Int, message: String) : Exception(message) +} diff --git a/sdk/src/main/kotlin/com/duooomi/ble/services/ShipmentSnDeviceService.kt b/sdk/src/main/kotlin/com/duooomi/ble/services/ShipmentSnDeviceService.kt new file mode 100644 index 0000000..04b01bc --- /dev/null +++ b/sdk/src/main/kotlin/com/duooomi/ble/services/ShipmentSnDeviceService.kt @@ -0,0 +1,44 @@ +package com.duooomi.ble.services + +import com.duooomi.ble.DuooomiBleConfig +import com.duooomi.ble.core.DuooomiBleError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * 服务端 shipment-sn 软绑/软解绑:本地 BLE 硬绑成功后由 SDK 内部调用,集成方无需手动调起。 + */ +internal class ShipmentSnDeviceService(private val config: DuooomiBleConfig) { + + suspend fun bind(sn: String, bindUserId: String) = withContext(Dispatchers.IO) { + try { + HttpHelper.post( + config = config, + path = "/api/auth/loomart/shipment-sn/device/bind", + body = mapOf("sn" to sn, "bindUserId" to bindUserId) + ) + } catch (e: HttpHelper.HttpError) { + throw DuooomiBleError.SoftBindFailed(e.message ?: "HTTP ${e.code}") + } catch (e: Exception) { + throw DuooomiBleError.SoftBindFailed(e.message ?: "unknown") + } + Unit + } + + suspend fun unbind(sn: String, bindUserId: String? = null) = withContext(Dispatchers.IO) { + val body = mutableMapOf("sn" to sn) + if (!bindUserId.isNullOrEmpty()) body["bindUserId"] = bindUserId + try { + HttpHelper.post( + config = config, + path = "/api/auth/loomart/shipment-sn/device/unbind", + body = body + ) + } catch (e: HttpHelper.HttpError) { + throw DuooomiBleError.SoftBindFailed(e.message ?: "HTTP ${e.code}") + } catch (e: Exception) { + throw DuooomiBleError.SoftBindFailed(e.message ?: "unknown") + } + Unit + } +} diff --git a/sdk/src/main/kotlin/com/duooomi/ble/services/UpgradeRecordStore.kt b/sdk/src/main/kotlin/com/duooomi/ble/services/UpgradeRecordStore.kt new file mode 100644 index 0000000..672ca84 --- /dev/null +++ b/sdk/src/main/kotlin/com/duooomi/ble/services/UpgradeRecordStore.kt @@ -0,0 +1,103 @@ +package com.duooomi.ble.services + +import android.content.Context +import android.content.SharedPreferences +import com.duooomi.ble.models.UpgradeOutcome +import com.duooomi.ble.models.UpgradeRecord +import org.json.JSONArray +import org.json.JSONObject + +/** + * OTA 升级记录持久化存储。与 RN `bleStore.upgradeRecords` 1:1 对齐: + * - 复合主键 `(sn, firmwareId)` 的 upsert + * - 30 天 TTL:过期但保留为审计轨迹(标记 `reported=true, outcome=null`) + * - sn 级 in-flight 互斥锁,防止并发重复上报 + */ +internal class UpgradeRecordStore(context: Context) { + + private val prefs: SharedPreferences = + context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + private val lock = Any() + private val records: MutableList = loadRecords().toMutableList() + private val inFlight: MutableSet = mutableSetOf() + + /** mutation 后回调当前快照(SDK 用它通过 StateFlow 发布给集成方)。 */ + var onChange: ((List) -> Unit)? = null + + val all: List + get() = synchronized(lock) { records.toList() } + + /** 取该 sn 下所有未上报的记录,按 triggeredAt 升序(旧→新)。 */ + fun pending(sn: String): List = synchronized(lock) { + records.filter { it.sn == sn && !it.reported }.sortedBy { it.triggeredAt } + } + + /** 复合主键 `(sn, firmwareId)`:已存在 → 覆盖并复位 reported/outcome;不存在 → 追加。 */ + fun upsert(record: UpgradeRecord) { + if (record.sn.isEmpty() || record.firmwareId.isEmpty()) return + val snapshot: List + synchronized(lock) { + val idx = records.indexOfFirst { it.sn == record.sn && it.firmwareId == record.firmwareId } + if (idx >= 0) records[idx] = record else records.add(record) + persistLocked() + snapshot = records.toList() + } + onChange?.invoke(snapshot) + } + + /** 标记某条记录已上报(线程安全)。 */ + fun markReported(sn: String, firmwareId: String, outcome: UpgradeOutcome?) { + val snapshot: List? + synchronized(lock) { + val idx = records.indexOfFirst { it.sn == sn && it.firmwareId == firmwareId } + if (idx < 0) { + snapshot = null + } else { + val orig = records[idx] + records[idx] = orig.copy( + reported = true, + reportedAt = nowMillis(), + outcome = outcome + ) + persistLocked() + snapshot = records.toList() + } + } + snapshot?.let { onChange?.invoke(it) } + } + + /** 尝试占用 sn 互斥锁。返回 false 表示已有进行中的上报。 */ + fun tryAcquire(sn: String): Boolean = synchronized(lock) { + if (inFlight.contains(sn)) return false + inFlight.add(sn) + true + } + + fun release(sn: String) = synchronized(lock) { + inFlight.remove(sn) + Unit + } + + private fun loadRecords(): List { + val raw = prefs.getString(KEY_RECORDS, null) ?: return emptyList() + return runCatching { + val arr = JSONArray(raw) + (0 until arr.length()).map { UpgradeRecord.fromJson(arr.getJSONObject(it)) } + }.getOrDefault(emptyList()) + } + + /** 必须在 lock 内调用。 */ + private fun persistLocked() { + val arr = JSONArray() + for (r in records) arr.put(r.toJson()) + prefs.edit().putString(KEY_RECORDS, arr.toString()).apply() + } + + companion object { + private const val PREFS_NAME = "duooomi_ble_sdk" + private const val KEY_RECORDS = "duooomi.firmware_upgrade_records" + const val TTL_MILLIS: Long = 30L * 24 * 60 * 60 * 1000 + fun nowMillis(): Long = System.currentTimeMillis() + } +}