旧实现里 _transferProgress 只在 transferFile() 内部才置 0,但 transferMedia 要先走 validateMediaFormat → aniConverter.convert → prepareTransfer 这条数秒级异步链,期间 StateFlow 还停留在上次结束 的 100。集成方一 collect 就看到旧值 100,体感像「上次进度被带过来」。 修法:transferMedia 在 ensureConnected() 后立即 _transferProgress.value = 0, collector 立即收到 0;后续 transferFile() 内的 = 0 因 StateFlow 的 distinct-value 语义被天然跳过。 对齐 ios:ed7de42。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
875 lines
35 KiB
Kotlin
875 lines
35 KiB
Kotlin
package com.duooomi.ble
|
||
|
||
import android.content.Context
|
||
import com.duooomi.ble.core.*
|
||
import com.duooomi.ble.models.*
|
||
import com.duooomi.ble.protocol.CommandType
|
||
import com.duooomi.ble.services.*
|
||
import com.duooomi.ble.utils.BeijingTimeProvider
|
||
import com.duooomi.ble.utils.BleLog
|
||
import kotlinx.coroutines.*
|
||
import kotlinx.coroutines.flow.*
|
||
import org.json.JSONObject
|
||
import java.net.HttpURLConnection
|
||
import java.net.URI
|
||
import java.net.URL
|
||
|
||
class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
|
||
|
||
// MARK: - Observable State
|
||
|
||
private val _btState = MutableStateFlow(ConnectionState.IDLE)
|
||
val btState: StateFlow<ConnectionState> = _btState.asStateFlow()
|
||
|
||
private val _discoveredDevices = MutableStateFlow<List<DiscoveredDevice>>(emptyList())
|
||
val discoveredDevices: StateFlow<List<DiscoveredDevice>> = _discoveredDevices.asStateFlow()
|
||
|
||
private val _connectedDevice = MutableStateFlow<DiscoveredDevice?>(null)
|
||
val connectedDevice: StateFlow<DiscoveredDevice?> = _connectedDevice.asStateFlow()
|
||
|
||
private val _deviceInfo = MutableStateFlow<DeviceInfo?>(null)
|
||
val deviceInfo: StateFlow<DeviceInfo?> = _deviceInfo.asStateFlow()
|
||
|
||
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()
|
||
|
||
/** 当前已绑定设备的 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()
|
||
|
||
private val _error = MutableStateFlow<String?>(null)
|
||
val error: StateFlow<String?> = _error.asStateFlow()
|
||
|
||
/**
|
||
* BLE JSON 协议层原始收发事件流。**调试用**——日志/抓包/UI 联调展示,
|
||
* 不属业务行为契约(iOS 版无对应通道)。集成方不要依赖它做业务逻辑。
|
||
*/
|
||
private val _rawJsonEvents = MutableSharedFlow<BleRawJsonEvent>(extraBufferCapacity = 32)
|
||
val rawJsonEvents: SharedFlow<BleRawJsonEvent> = _rawJsonEvents.asSharedFlow()
|
||
|
||
// MARK: - Internal Services
|
||
|
||
private val bleClient = BleClient(context)
|
||
private val protocolService = BleProtocolService(bleClient, config.frameIntervalMs)
|
||
private val timeProvider = BeijingTimeProvider()
|
||
private val deviceInfoService = DeviceInfoService(protocolService, timeProvider)
|
||
private val fileTransferService = FileTransferService(protocolService)
|
||
private val aniConverter = AniConverter(config)
|
||
private val shipmentService = ShipmentSnDeviceService(config)
|
||
private val firmwareUpgradeService = FirmwareUpgradeService(config)
|
||
private val upgradeRecordStore = UpgradeRecordStore(context)
|
||
|
||
// MARK: - Request-Response
|
||
|
||
private val pendingContinuations = mutableMapOf<String, CancellableContinuation<ByteArray>>()
|
||
private var messageListenerJob: Job? = null
|
||
|
||
// MARK: - Scan State
|
||
|
||
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||
BleLog.e("Coroutine exception: ${throwable.message}", "SDK")
|
||
_error.value = throwable.message
|
||
}
|
||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + exceptionHandler)
|
||
private var scanJob: Job? = null
|
||
private val allDiscoveredDevices = mutableListOf<DiscoveredDevice>()
|
||
private var flushJob: Job? = null
|
||
|
||
init {
|
||
BleLog.level = config.logLevel
|
||
BleLog.i("SDK initialized (apiHost=${config.apiHost}, brand=${config.brand}, logLevel=${config.logLevel})", "SDK")
|
||
if (config.frameIntervalMs !in 0..500) {
|
||
BleLog.w("frameIntervalMs=${config.frameIntervalMs} out of [0,500], will clamp at use site", "Config")
|
||
}
|
||
setupDisconnectHandler()
|
||
protocolService.rawJsonEmitter = { evt -> _rawJsonEvents.tryEmit(evt) }
|
||
// 启动时把持久化记录回放到 StateFlow
|
||
_upgradeRecords.value = upgradeRecordStore.all
|
||
upgradeRecordStore.onChange = { records ->
|
||
scope.launch { _upgradeRecords.value = records }
|
||
}
|
||
}
|
||
|
||
private fun setupDisconnectHandler() {
|
||
bleClient.onDisconnected = {
|
||
scope.launch {
|
||
BleLog.w("Peripheral disconnected; resetting state", "SDK")
|
||
_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 成功 / bind 软绑失败回滚时清空
|
||
// —— firmwareUpgrade 仅在 fetchLatestFirmware 成功时覆盖
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Scanning
|
||
|
||
fun scan() {
|
||
stopScan()
|
||
_btState.value = ConnectionState.SCANNING
|
||
_discoveredDevices.value = emptyList()
|
||
allDiscoveredDevices.clear()
|
||
|
||
BleLog.i("Start scanning...", "Scan")
|
||
scanJob = scope.launch {
|
||
try {
|
||
bleClient.scan().collect { device ->
|
||
BleLog.d("Discovered: id=${device.id}, name=${device.name ?: "-"}, rssi=${device.rssi}", "Scan")
|
||
queueDevice(device)
|
||
}
|
||
} catch (e: Exception) {
|
||
BleLog.e("Scan error: ${e.message}", "Scan")
|
||
_error.value = e.message
|
||
_btState.value = ConnectionState.IDLE
|
||
}
|
||
}
|
||
}
|
||
|
||
fun stopScan() {
|
||
scanJob?.cancel()
|
||
scanJob = null
|
||
bleClient.stopScan()
|
||
flushDevices()
|
||
|
||
if (_connectedDevice.value != null) {
|
||
_btState.value = ConnectionState.CONNECTED
|
||
} else if (_btState.value == ConnectionState.SCANNING) {
|
||
_btState.value = ConnectionState.IDLE
|
||
}
|
||
BleLog.i("Stop scanning (state=${_btState.value})", "Scan")
|
||
}
|
||
|
||
// MARK: - Connection
|
||
|
||
suspend fun connect(deviceId: String): DiscoveredDevice {
|
||
stopScan()
|
||
_btState.value = ConnectionState.CONNECTING
|
||
BleLog.i("Connecting to $deviceId...", "Connect")
|
||
|
||
try {
|
||
val gatt = bleClient.connect(deviceId)
|
||
|
||
// Start protocol listener
|
||
protocolService.startListening(scope)
|
||
startMessageListener()
|
||
|
||
val device = DiscoveredDevice(
|
||
id = gatt.device.address,
|
||
name = gatt.device.name,
|
||
rssi = 0
|
||
)
|
||
_connectedDevice.value = device
|
||
_btState.value = ConnectionState.CONNECTED
|
||
BleLog.i("Connected: ${device.id}", "Connect")
|
||
return device
|
||
} catch (e: Exception) {
|
||
_btState.value = ConnectionState.IDLE
|
||
_error.value = e.message
|
||
BleLog.e("Connect failed: ${e.message}", "Connect")
|
||
throw e
|
||
}
|
||
}
|
||
|
||
suspend fun disconnect() {
|
||
_btState.value = ConnectionState.DISCONNECTING
|
||
BleLog.i("Disconnect requested", "Connect")
|
||
protocolService.stopListening()
|
||
messageListenerJob?.cancel()
|
||
messageListenerJob = null
|
||
cancelAllPending(DuooomiBleError.NotConnected)
|
||
|
||
var disconnectError: Exception? = null
|
||
try {
|
||
bleClient.disconnect()
|
||
} catch (e: Exception) {
|
||
disconnectError = e
|
||
_error.value = e.message
|
||
BleLog.e("Disconnect error: ${e.message}", "Connect")
|
||
}
|
||
|
||
_connectedDevice.value = null
|
||
_deviceInfo.value = null
|
||
_version.value = ""
|
||
_versionLog.value = null
|
||
_isActivated.value = false
|
||
_btState.value = ConnectionState.DISCONNECTED
|
||
BleLog.i("Disconnected", "Connect")
|
||
|
||
disconnectError?.let { throw it }
|
||
}
|
||
|
||
fun getConnectedDevices(): List<DiscoveredDevice> {
|
||
val list = bleClient.retrieveConnectedDevices()
|
||
BleLog.d("System-connected devices: ${list.size}", "Connect")
|
||
return list
|
||
}
|
||
|
||
// MARK: - Device Commands
|
||
|
||
suspend fun getDeviceInfo(): DeviceInfo {
|
||
ensureConnected()
|
||
BleLog.d("Sending getDeviceInfo", "Command")
|
||
val data = sendAndWait(CommandType.GET_DEVICE_INFO) {
|
||
deviceInfoService.getDeviceInfo()
|
||
}
|
||
val info = DeviceInfo.fromJson(parseJsonResponse(data))
|
||
BleLog.i("DeviceInfo received: name=${info.name}, brand=${info.brand}", "Command")
|
||
_deviceInfo.value = info
|
||
return info
|
||
}
|
||
|
||
suspend fun getVersion(): VersionInfo {
|
||
ensureConnected()
|
||
BleLog.d("Sending getDeviceVersion", "Command")
|
||
val data = sendAndWait(CommandType.GET_DEVICE_VERSION) {
|
||
deviceInfoService.getDeviceVersion()
|
||
}
|
||
val info = VersionInfo.fromJson(parseJsonResponse(data))
|
||
_version.value = info.version
|
||
_versionLog.value = info.log
|
||
BleLog.i(
|
||
"Version received: ${info.version} (type=${info.type}, log=${if (info.log != null) "present" else "nil"})",
|
||
"Command"
|
||
)
|
||
return info
|
||
}
|
||
|
||
/**
|
||
* 完整绑定:内部串四步业务编排(顺序固定),对集成方表现为一次原子调用。
|
||
*
|
||
* 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] / [versionLog] 写入当前设备状态(getVersion 失败时保持空,但 bind 不算失败)
|
||
* - [isActivated] = true
|
||
*
|
||
* @throws DuooomiBleError.NotConnected 未连接
|
||
* @throws DuooomiBleError.AlreadyBoundByOtherUser BLE 层 success != 1(设备已绑给他人)
|
||
* @throws DuooomiBleError.SoftBindFailed 软绑失败(含硬解绑回滚后)
|
||
*/
|
||
suspend fun bind(userId: String): BindingResponse {
|
||
ensureConnected()
|
||
verifyBrand()
|
||
BleLog.d("Sending bind (userId=$userId)", "Command")
|
||
val data = sendAndWait(CommandType.BIND_DEVICE) {
|
||
deviceInfoService.bindDevice(userId)
|
||
}
|
||
val resp = BindingResponse.fromJson(parseJsonResponse(data))
|
||
_isActivated.value = resp.success == 1
|
||
if (resp.success != 1) {
|
||
BleLog.w("Hard bind failed: success=0", "Command")
|
||
throw DuooomiBleError.AlreadyBoundByOtherUser
|
||
}
|
||
_sn.value = resp.sn
|
||
BleLog.i("Hard bind success: sn=${resp.sn}", "Command")
|
||
|
||
// 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: updateMetadata(fire-and-forget;versionLog 缺失/空则跳过)
|
||
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
|
||
}
|
||
|
||
/**
|
||
* 切换播放模式(0=单播 / 1=循环播放)。
|
||
*
|
||
* 复用 [CommandType.BIND_DEVICE] (0x0F) 命令通道;响应类型为 [BindingResponse]。
|
||
* 成功后自动刷新一次 `deviceInfo`(含最新 `loop` 字段),与 expo 端行为对齐。
|
||
*
|
||
* 前置:必须已 [bind] 完成(`isActivated == true`)。
|
||
* `bind` / `unbind` / `setPlayMode` 共享 0x0F / 0x12 opId 通道,**不能并发**。
|
||
*/
|
||
suspend fun setPlayMode(mode: com.duooomi.ble.models.PlayMode, userId: String): BindingResponse {
|
||
ensureConnected()
|
||
if (!_isActivated.value) {
|
||
BleLog.w("setPlayMode called before bind (isActivated=false)", "Command")
|
||
throw DuooomiBleError.BindingFailed("Device not bound; call bind(userId) first")
|
||
}
|
||
BleLog.d("Sending setPlayMode (mode=$mode, userId=$userId)", "Command")
|
||
val data = sendAndWait(CommandType.BIND_DEVICE) {
|
||
deviceInfoService.setPlayMode(userId, mode)
|
||
}
|
||
val resp = BindingResponse.fromJson(parseJsonResponse(data))
|
||
if (resp.success != 1) {
|
||
BleLog.w("setPlayMode failed: device rejected", "Command")
|
||
throw DuooomiBleError.BindingFailed("Set play mode failed")
|
||
}
|
||
BleLog.i("setPlayMode success: mode=$mode", "Command")
|
||
// 自动刷新 deviceInfo(含最新 loop 字段)
|
||
runCatching { getDeviceInfo() }
|
||
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) {
|
||
BleLog.w("Unbind failed", "Command")
|
||
throw DuooomiBleError.UnbindFailed
|
||
}
|
||
_isActivated.value = false
|
||
_sn.value = ""
|
||
_versionLog.value = null
|
||
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
|
||
}
|
||
|
||
suspend fun deleteFile(key: String): DeleteFileResponse {
|
||
ensureConnected()
|
||
BleLog.d("Sending deleteFile (key=$key)", "Command")
|
||
val data = sendAndWait(CommandType.DELETE_FILE) {
|
||
deviceInfoService.deleteFile(key)
|
||
}
|
||
val resp = DeleteFileResponse.fromJson(parseJsonResponse(data))
|
||
if (resp.success != 0) {
|
||
BleLog.w("Delete failed with status=${resp.success}", "Command")
|
||
throw DuooomiBleError.DeleteFileFailed(resp.success)
|
||
}
|
||
BleLog.i("Delete success (key=$key)", "Command")
|
||
return resp
|
||
}
|
||
|
||
suspend fun prepareTransfer(key: String, size: Int): PrepareTransferResponse {
|
||
ensureConnected()
|
||
val opId = "${CommandType.PREPARE_TRANSFER.value}_$key"
|
||
BleLog.d("Sending prepareTransfer (key=$key, size=$size)", "Transfer")
|
||
val data = sendAndWait(opId = opId, command = CommandType.PREPARE_TRANSFER) {
|
||
deviceInfoService.prepareTransfer(key, size)
|
||
}
|
||
val resp = PrepareTransferResponse.fromJson(parseJsonResponse(data))
|
||
if (resp.status != "ready") {
|
||
BleLog.w("PrepareTransfer not ready: status=${resp.status}", "Transfer")
|
||
throw DuooomiBleError.PrepareTransferFailed(resp.status)
|
||
}
|
||
BleLog.i("PrepareTransfer ready (key=$key)", "Transfer")
|
||
return resp
|
||
}
|
||
|
||
// MARK: - File Transfer
|
||
|
||
suspend fun transferFile(
|
||
fileUri: String,
|
||
commandType: CommandType = CommandType.TRANSFER_ANI_VIDEO
|
||
) {
|
||
ensureConnected()
|
||
_transferProgress.value = 0
|
||
BleLog.i("Transfer start: uri=$fileUri, cmd=$commandType", "Transfer")
|
||
|
||
fileTransferService.transferFile(
|
||
fileUri = fileUri,
|
||
commandType = commandType
|
||
) { progress ->
|
||
val percent = (progress * 100).toInt()
|
||
_transferProgress.value = percent
|
||
if (percent % 10 == 0) {
|
||
BleLog.d("Progress: $percent%", "Transfer")
|
||
}
|
||
}
|
||
|
||
_transferProgress.value = 100
|
||
BleLog.i("Transfer completed", "Transfer")
|
||
}
|
||
|
||
// MARK: - High-Level APIs
|
||
|
||
companion object {
|
||
/** OTA 烧录前置电量门槛(百分比)。低于此值拒绝下发,避免烧录中途断电变砖。 */
|
||
internal const val MIN_BATTERY_FOR_UPGRADE = 40
|
||
|
||
private val SUPPORTED_EXTENSIONS = setOf("jpeg", "jpg", "png", "gif", "mp4", "webm", "webp")
|
||
private val SUPPORTED_MIME_TYPES = setOf(
|
||
"image/jpeg", "image/png", "image/gif", "image/webp",
|
||
"video/mp4", "video/webm"
|
||
)
|
||
}
|
||
|
||
private fun extractExtension(urlString: String): String? {
|
||
return try {
|
||
val path = URI(urlString).path ?: return null
|
||
val dot = path.lastIndexOf('.')
|
||
if (dot >= 0 && dot < path.length - 1) path.substring(dot + 1).lowercase() else null
|
||
} catch (_: Exception) { null }
|
||
}
|
||
|
||
private suspend fun validateMediaFormat(url: String) {
|
||
val ext = extractExtension(url)
|
||
if (ext != null) {
|
||
if (SUPPORTED_EXTENSIONS.contains(ext)) return
|
||
throw DuooomiBleError.UnsupportedFormat(ext)
|
||
}
|
||
|
||
val mime = withContext(Dispatchers.IO) {
|
||
val conn = java.net.URL(url).openConnection() as HttpURLConnection
|
||
try {
|
||
conn.requestMethod = "HEAD"
|
||
conn.connectTimeout = 10_000
|
||
conn.readTimeout = 10_000
|
||
conn.contentType?.split(";")?.firstOrNull()?.trim()?.lowercase()
|
||
} finally {
|
||
conn.disconnect()
|
||
}
|
||
}
|
||
|
||
if (mime == null || !SUPPORTED_MIME_TYPES.contains(mime)) {
|
||
throw DuooomiBleError.UnsupportedFormat(mime ?: "unknown")
|
||
}
|
||
}
|
||
|
||
suspend fun transferMedia(fileUrl: String) {
|
||
ensureConnected()
|
||
_transferProgress.value = 0
|
||
val url = fileUrl.trim()
|
||
if (url.isEmpty()) throw DuooomiBleError.TransferFailed("Empty file URL")
|
||
|
||
val isAni = url.lowercase().endsWith(".ani")
|
||
val aniUrl: String
|
||
|
||
if (isAni) {
|
||
aniUrl = url
|
||
BleLog.i("Direct ANI transfer: $url", "Transfer")
|
||
} else {
|
||
validateMediaFormat(url)
|
||
BleLog.i("Converting to ANI: $url", "Transfer")
|
||
try {
|
||
aniUrl = aniConverter.convert(url)
|
||
} catch (e: Exception) {
|
||
throw DuooomiBleError.TransferFailed("ANI conversion failed: ${e.message}")
|
||
}
|
||
BleLog.i("ANI ready: $aniUrl", "Transfer")
|
||
}
|
||
|
||
val aniSize = getRemoteFileSize(aniUrl)
|
||
BleLog.d("ANI size: $aniSize bytes", "Transfer")
|
||
|
||
val key = url
|
||
prepareTransfer(key = key, size = aniSize)
|
||
|
||
transferFile(fileUri = aniUrl, commandType = CommandType.TRANSFER_ANI_VIDEO)
|
||
}
|
||
|
||
/**
|
||
* 拉取服务端最新固件信息。**内部串两步业务编排**(顺序固定):
|
||
*
|
||
* 1. **对账上报**:把本地 pending [UpgradeRecord] 逐条报给服务端(容错失败,不阻断)
|
||
* 2. **拉服务端最新固件**:写入 [firmwareUpgrade],通过返回值回传
|
||
*
|
||
* 软绑 / metadata 上报 / 硬绑失败回滚 → 已全部内聚到 [bind],本方法不再处理。
|
||
*/
|
||
suspend fun fetchLatestFirmware(
|
||
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: 服务端 checkUpgrade
|
||
val result = firmwareUpgradeService.checkUpgrade(sn = sn, currentVersion = currentVersion)
|
||
_firmwareUpgrade.value = result
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* OTA 固件烧录。完成后自动落 [UpgradeRecord] 到本地(复合主键 `(sn, firmwareId)`),
|
||
* 等待下次 [fetchLatestFirmware] 触发 [tryReportPendingUpgrade] 对账上报。
|
||
*
|
||
* 下发前会**主动拉一次新鲜 [getDeviceInfo]** 校验实时电量,低于
|
||
* [MIN_BATTERY_FOR_UPGRADE](40%)直接抛 [DuooomiBleError.BatteryTooLow],不发任何 OTA 字节。
|
||
*
|
||
* @throws DuooomiBleError.NotConnected 未连接
|
||
* @throws DuooomiBleError.BatteryTooLow 电量 < [MIN_BATTERY_FOR_UPGRADE](未下发任何字节)
|
||
* @throws DuooomiBleError.TransferFailed 烧录阶段失败;[getDeviceInfo] 的 Timeout / 解码错误同样透传
|
||
*/
|
||
suspend fun upgradeFirmware(
|
||
sn: String,
|
||
fileUrl: String,
|
||
firmwareId: String,
|
||
fromVersion: String,
|
||
targetVersion: String
|
||
) {
|
||
ensureConnected()
|
||
// OTA 前置电量校验:取实时电量(不用缓存,可能过期),低于门槛拒绝下发。
|
||
val info = getDeviceInfo()
|
||
if (info.powerlevel < MIN_BATTERY_FOR_UPGRADE) {
|
||
BleLog.w("OTA blocked: battery ${info.powerlevel}% < $MIN_BATTERY_FOR_UPGRADE%", "Firmware")
|
||
throw DuooomiBleError.BatteryTooLow(current = info.powerlevel, required = MIN_BATTERY_FOR_UPGRADE)
|
||
}
|
||
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
|
||
|
||
private suspend fun getRemoteFileSize(url: String): Int = withContext(Dispatchers.IO) {
|
||
val fileUrl = URL(url)
|
||
|
||
// Try HEAD request first
|
||
val headConn = fileUrl.openConnection() as HttpURLConnection
|
||
try {
|
||
headConn.requestMethod = "HEAD"
|
||
headConn.connectTimeout = 15_000
|
||
headConn.connect()
|
||
val length = headConn.contentLength
|
||
if (length > 0) return@withContext length
|
||
} finally {
|
||
headConn.disconnect()
|
||
}
|
||
|
||
// Fallback: Range request
|
||
val rangeConn = fileUrl.openConnection() as HttpURLConnection
|
||
try {
|
||
rangeConn.requestMethod = "GET"
|
||
rangeConn.setRequestProperty("Range", "bytes=0-0")
|
||
rangeConn.connectTimeout = 15_000
|
||
rangeConn.connect()
|
||
val rangeHeader = rangeConn.getHeaderField("Content-Range")
|
||
if (rangeHeader != null) {
|
||
val totalStr = rangeHeader.split("/").lastOrNull()
|
||
val total = totalStr?.toIntOrNull()
|
||
if (total != null && total > 0) return@withContext total
|
||
}
|
||
} finally {
|
||
rangeConn.disconnect()
|
||
}
|
||
|
||
throw DuooomiBleError.TransferFailed("Cannot determine file size: $url")
|
||
}
|
||
|
||
// MARK: - Request-Response Pattern
|
||
|
||
private suspend fun sendAndWait(
|
||
commandType: CommandType,
|
||
timeoutMs: Long = 10_000,
|
||
send: suspend () -> Unit
|
||
): ByteArray {
|
||
return sendAndWait(
|
||
opId = "${commandType.value}",
|
||
command = commandType,
|
||
timeoutMs = timeoutMs,
|
||
send = send
|
||
)
|
||
}
|
||
|
||
private suspend fun sendAndWait(
|
||
opId: String,
|
||
command: CommandType,
|
||
timeoutMs: Long = 10_000,
|
||
send: suspend () -> Unit
|
||
): ByteArray {
|
||
BleLog.d("Register opId=$opId, cmd=$command", "RPC")
|
||
try {
|
||
return withTimeout(timeoutMs) {
|
||
coroutineScope {
|
||
val resultDeferred = async {
|
||
suspendCancellableCoroutine { continuation ->
|
||
pendingContinuations[opId] = continuation
|
||
continuation.invokeOnCancellation {
|
||
pendingContinuations.remove(opId)
|
||
}
|
||
BleLog.d("Continuation stored for opId=$opId", "RPC")
|
||
}
|
||
}
|
||
|
||
// Send command
|
||
send()
|
||
BleLog.d("Command sent for opId=$opId", "RPC")
|
||
|
||
// Wait for response
|
||
val result = resultDeferred.await()
|
||
BleLog.d("Result received for opId=$opId", "RPC")
|
||
result
|
||
}
|
||
}
|
||
} catch (e: Exception) {
|
||
pendingContinuations.remove(opId)?.let { stale ->
|
||
stale.cancel()
|
||
}
|
||
if (e is TimeoutCancellationException) {
|
||
BleLog.e("RPC timeout for opId=$opId", "RPC")
|
||
throw DuooomiBleError.Timeout(command)
|
||
}
|
||
BleLog.e("RPC failed for opId=$opId: ${e.message}", "RPC")
|
||
throw e
|
||
}
|
||
}
|
||
|
||
// MARK: - Message Listener
|
||
|
||
private fun startMessageListener() {
|
||
messageListenerJob?.cancel()
|
||
messageListenerJob = scope.launch {
|
||
BleLog.i("Message listener started", "RPC")
|
||
protocolService.incomingMessages.collect { (commandType, data) ->
|
||
BleLog.d("Incoming message: type=$commandType, size=${data.size}", "RPC")
|
||
handleIncomingMessage(commandType, data)
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun handleIncomingMessage(commandType: Byte, data: ByteArray) {
|
||
val typeValue = commandType.toInt() and 0xFF
|
||
emitRawJsonRx(typeValue, data)
|
||
val opId = "$typeValue"
|
||
|
||
// Check for prepareTransfer with key-based opId
|
||
if (typeValue == CommandType.PREPARE_TRANSFER.value) {
|
||
try {
|
||
val json = parseJsonResponse(data)
|
||
val resp = PrepareTransferResponse.fromJson(json)
|
||
val keyedOpId = "${typeValue}_${resp.key}"
|
||
pendingContinuations.remove(keyedOpId)?.let { continuation ->
|
||
BleLog.d("Resuming keyed opId=$keyedOpId", "RPC")
|
||
continuation.resume(data, null)
|
||
return
|
||
}
|
||
} catch (_: Exception) { }
|
||
}
|
||
|
||
pendingContinuations.remove(opId)?.let { continuation ->
|
||
BleLog.d("Resuming opId=$opId", "RPC")
|
||
continuation.resume(data, null)
|
||
} ?: BleLog.w("No pending continuation for opId=$opId", "RPC")
|
||
}
|
||
|
||
// MARK: - Helpers
|
||
|
||
private fun ensureConnected() {
|
||
if (_connectedDevice.value == null) {
|
||
BleLog.w("Operation requires connection", "SDK")
|
||
throw DuooomiBleError.NotConnected
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 校验设备 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 emitRawJsonRx(typeValue: Int, data: ByteArray) {
|
||
runCatching {
|
||
val cleaned = data.filter { it != 0.toByte() }.toByteArray()
|
||
if (cleaned.isEmpty()) return@runCatching
|
||
val jsonStr = String(cleaned, Charsets.UTF_8)
|
||
if (jsonStr.startsWith("{") || jsonStr.startsWith("[")) {
|
||
val name = CommandType.fromValue(typeValue)?.name
|
||
?: "UNKNOWN(0x%02X)".format(typeValue)
|
||
_rawJsonEvents.tryEmit(
|
||
BleRawJsonEvent(
|
||
direction = BleRawJsonEvent.Direction.RX,
|
||
commandType = typeValue,
|
||
commandName = name,
|
||
json = jsonStr
|
||
)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun parseJsonResponse(data: ByteArray): JSONObject {
|
||
// Device responses may contain null bytes
|
||
val cleaned = data.filter { it != 0.toByte() }.toByteArray()
|
||
val jsonStr = String(cleaned, Charsets.UTF_8)
|
||
return try {
|
||
JSONObject(jsonStr)
|
||
} catch (e: Exception) {
|
||
BleLog.e("JSON parse failed: ${e.message}\nRaw: $jsonStr", "Decode")
|
||
throw e
|
||
}
|
||
}
|
||
|
||
private fun cancelAllPending(error: Exception) {
|
||
val continuations = pendingContinuations.toMap()
|
||
pendingContinuations.clear()
|
||
for ((_, continuation) in continuations) {
|
||
continuation.cancel(CancellationException(error.message))
|
||
}
|
||
}
|
||
|
||
// MARK: - Scan Batching (500ms throttle)
|
||
|
||
private fun queueDevice(device: DiscoveredDevice) {
|
||
if (allDiscoveredDevices.any { it.id == device.id }) return
|
||
|
||
allDiscoveredDevices.add(device)
|
||
|
||
flushJob?.cancel()
|
||
flushJob = scope.launch {
|
||
delay(500)
|
||
flushDevices()
|
||
}
|
||
}
|
||
|
||
private fun flushDevices() {
|
||
if (allDiscoveredDevices.isEmpty()) return
|
||
_discoveredDevices.value = allDiscoveredDevices.toList()
|
||
BleLog.d("Discovered devices flushed: total=${_discoveredDevices.value.size}", "Scan")
|
||
}
|
||
}
|