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 = _btState.asStateFlow() private val _discoveredDevices = MutableStateFlow>(emptyList()) val discoveredDevices: StateFlow> = _discoveredDevices.asStateFlow() private val _connectedDevice = MutableStateFlow(null) val connectedDevice: StateFlow = _connectedDevice.asStateFlow() private val _deviceInfo = MutableStateFlow(null) val deviceInfo: StateFlow = _deviceInfo.asStateFlow() private val _version = MutableStateFlow("") val version: StateFlow = _version.asStateFlow() /** * 设备返回的 `log` 字段(任意 JSON 对象)。`getVersion` 成功时写入,断连 / 解绑 / 软绑回滚时清空。 * `bind` 软绑成功后透传给 shipment-sn `update-metadata`,集成方一般无需直接读。 */ private val _versionLog = MutableStateFlow(null) val versionLog: StateFlow = _versionLog.asStateFlow() 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() private val _error = MutableStateFlow(null) val error: StateFlow = _error.asStateFlow() /** * BLE JSON 协议层原始收发事件流。**调试用**——日志/抓包/UI 联调展示, * 不属业务行为契约(iOS 版无对应通道)。集成方不要依赖它做业务逻辑。 */ private val _rawJsonEvents = MutableSharedFlow(extraBufferCapacity = 32) val rawJsonEvents: SharedFlow = _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>() 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() 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 { 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") } }