From b4398518a9c62e7e4239c93f2945050e6564180b Mon Sep 17 00:00:00 2001 From: km2023 Date: Fri, 8 May 2026 15:30:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20PlayMode=20=E5=88=87=E6=8D=A2=20+=20bin?= =?UTF-8?q?d/unbind/setPlayMode=20=E6=90=BA=E5=B8=A6=E5=8C=97=E4=BA=AC?= =?UTF-8?q?=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要改动(与 iOS SDK 1:1 对齐): - 新增 BeijingTimeProvider:苏宁公开 API → header Date → 本地+8h 三级 fallback; 5 秒缓存 + Mutex 单飞复用 - 新增 PlayMode enum (SINGLE=0, LOOP=1) - DeviceInfo 增加可选 loop 字段(旧固件无此字段时为 null) - DeviceInfoService.bind/unbind/setPlayMode 共用 payload,带 time 字段; loop == null 时严格省略 key(与 expo zod.optional 对齐) - DuooomiBleSDK.setPlayMode(mode, userId):复用 BIND_DEVICE 命令通道, isActivated 守卫;成功后自动刷新 deviceInfo - Demo 增加 Single / Loop 按钮(gated by isActivated) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../kotlin/com/duooomi/ble/demo/DemoScreen.kt | 37 +++++ .../kotlin/com/duooomi/ble/DuooomiBleSDK.kt | 34 ++++- .../com/duooomi/ble/models/DeviceInfo.kt | 8 +- .../kotlin/com/duooomi/ble/models/PlayMode.kt | 20 +++ .../duooomi/ble/services/DeviceInfoService.kt | 40 +++++- .../duooomi/ble/utils/BeijingTimeProvider.kt | 134 ++++++++++++++++++ 6 files changed, 263 insertions(+), 10 deletions(-) create mode 100644 sdk/src/main/kotlin/com/duooomi/ble/models/PlayMode.kt create mode 100644 sdk/src/main/kotlin/com/duooomi/ble/utils/BeijingTimeProvider.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 3cd6abb..a01a667 100644 --- a/demo/src/main/kotlin/com/duooomi/ble/demo/DemoScreen.kt +++ b/demo/src/main/kotlin/com/duooomi/ble/demo/DemoScreen.kt @@ -14,6 +14,7 @@ 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.PlayMode import com.duooomi.ble.protocol.CommandType import kotlinx.coroutines.launch @@ -52,6 +53,7 @@ fun DemoScreen(sdk: DuooomiBleSDK) { val connectedDevice by sdk.connectedDevice.collectAsState() val deviceInfo by sdk.deviceInfo.collectAsState() val version by sdk.version.collectAsState() + val isActivated by sdk.isActivated.collectAsState() val transferProgress by sdk.transferProgress.collectAsState() val sdkError by sdk.error.collectAsState() @@ -253,6 +255,41 @@ fun DemoScreen(sdk: DuooomiBleSDK) { } }) { Text("unbind") } } + + // Play mode toggle —— gated by isActivated + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = { + scope.launch { + isBusy = true + log("-> setPlayMode(SINGLE, $userId)") + try { + sdk.setPlayMode(PlayMode.SINGLE, userId) + log("setPlayMode SINGLE OK", LogLevel.SUCCESS) + } catch (e: Exception) { + log("setPlayMode FAIL: ${e.message}", LogLevel.ERROR) + } finally { isBusy = false } + } + }, + enabled = isActivated && !isBusy + ) { Text("Single") } + + OutlinedButton( + onClick = { + scope.launch { + isBusy = true + log("-> setPlayMode(LOOP, $userId)") + try { + sdk.setPlayMode(PlayMode.LOOP, userId) + log("setPlayMode LOOP OK", LogLevel.SUCCESS) + } catch (e: Exception) { + log("setPlayMode FAIL: ${e.message}", LogLevel.ERROR) + } finally { isBusy = false } + } + }, + enabled = isActivated && !isBusy + ) { Text("Loop") } + } } // === Transfer Section === diff --git a/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleSDK.kt b/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleSDK.kt index 0051305..33e8a9a 100644 --- a/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleSDK.kt +++ b/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleSDK.kt @@ -5,6 +5,7 @@ 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.* @@ -45,7 +46,8 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) { private val bleClient = BleClient(context) private val protocolService = BleProtocolService(bleClient) - private val deviceInfoService = DeviceInfoService(protocolService) + private val timeProvider = BeijingTimeProvider() + private val deviceInfoService = DeviceInfoService(protocolService, timeProvider) private val fileTransferService = FileTransferService(protocolService) private val aniConverter = AniConverter(config) private val firmwareService = FirmwareService(config) @@ -229,6 +231,36 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) { 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 + } + suspend fun unbind(userId: String): UnbindResponse { ensureConnected() BleLog.d("Sending unbind (userId=$userId)", "Command") diff --git a/sdk/src/main/kotlin/com/duooomi/ble/models/DeviceInfo.kt b/sdk/src/main/kotlin/com/duooomi/ble/models/DeviceInfo.kt index e54ebc2..9bad03a 100644 --- a/sdk/src/main/kotlin/com/duooomi/ble/models/DeviceInfo.kt +++ b/sdk/src/main/kotlin/com/duooomi/ble/models/DeviceInfo.kt @@ -8,17 +8,21 @@ data class DeviceInfo( val name: String, val size: String, val brand: String, - val powerlevel: Int + val powerlevel: Int, + /** 播放模式(旧固件无此字段时为 null) */ + val loop: PlayMode? ) { companion object { fun fromJson(json: JSONObject): DeviceInfo { + val rawLoop = if (json.has("loop")) json.optInt("loop", -1) else -1 return DeviceInfo( allspace = json.optLong("allspace", 0L), freespace = json.optLong("freespace", 0L), name = json.optString("name", ""), size = json.optString("size", ""), brand = json.optString("brand", ""), - powerlevel = json.optInt("powerlevel", 0) + powerlevel = json.optInt("powerlevel", 0), + loop = PlayMode.fromRaw(if (rawLoop >= 0) rawLoop else null) ) } } diff --git a/sdk/src/main/kotlin/com/duooomi/ble/models/PlayMode.kt b/sdk/src/main/kotlin/com/duooomi/ble/models/PlayMode.kt new file mode 100644 index 0000000..c24e659 --- /dev/null +++ b/sdk/src/main/kotlin/com/duooomi/ble/models/PlayMode.kt @@ -0,0 +1,20 @@ +package com.duooomi.ble.models + +/** + * 播放模式(设备端协议字段 `loop`)。 + * + * - [SINGLE] (0): 单播 —— 设备播完一次后停止 + * - [LOOP] (1): 循环播放 —— 设备循环播放当前内容 + */ +enum class PlayMode(val raw: Int) { + SINGLE(0), + LOOP(1); + + companion object { + fun fromRaw(raw: Int?): PlayMode? = when (raw) { + 0 -> SINGLE + 1 -> LOOP + else -> null + } + } +} diff --git a/sdk/src/main/kotlin/com/duooomi/ble/services/DeviceInfoService.kt b/sdk/src/main/kotlin/com/duooomi/ble/services/DeviceInfoService.kt index 58f0987..a9430c8 100644 --- a/sdk/src/main/kotlin/com/duooomi/ble/services/DeviceInfoService.kt +++ b/sdk/src/main/kotlin/com/duooomi/ble/services/DeviceInfoService.kt @@ -1,9 +1,14 @@ package com.duooomi.ble.services +import com.duooomi.ble.models.PlayMode import com.duooomi.ble.protocol.CommandType +import com.duooomi.ble.utils.BeijingTimeProvider import org.json.JSONObject -internal class DeviceInfoService(private val protocolService: BleProtocolService) { +internal class DeviceInfoService( + private val protocolService: BleProtocolService, + private val timeProvider: BeijingTimeProvider +) { suspend fun getDeviceInfo() { protocolService.sendJSON( @@ -19,21 +24,26 @@ internal class DeviceInfoService(private val protocolService: BleProtocolService ) } + /** 绑定:先取北京时间再发命令,与 RN/iOS 行为对齐。 */ suspend fun bindDevice(userId: String) { protocolService.sendJSON( type = CommandType.BIND_DEVICE, - payload = JSONObject() - .put("type", CommandType.BIND_DEVICE.value) - .put("userId", userId) + payload = bindPayload(CommandType.BIND_DEVICE.value, userId, loop = null) ) } suspend fun unbindDevice(userId: String) { protocolService.sendJSON( type = CommandType.UNBIND_DEVICE, - payload = JSONObject() - .put("type", CommandType.UNBIND_DEVICE.value) - .put("userId", userId) + payload = bindPayload(CommandType.UNBIND_DEVICE.value, userId, loop = null) + ) + } + + /** 切换播放模式(复用 BIND_DEVICE 命令字 0x0F,payload 携带 loop 字段)。 */ + suspend fun setPlayMode(userId: String, loop: PlayMode) { + protocolService.sendJSON( + type = CommandType.BIND_DEVICE, + payload = bindPayload(CommandType.BIND_DEVICE.value, userId, loop = loop.raw) ) } @@ -55,4 +65,20 @@ internal class DeviceInfoService(private val protocolService: BleProtocolService .put("size", size) ) } + + /** + * 绑定 / 解绑 / 切模式 共用 payload。 + * `loop == null` 时严格省略该字段(不写 NULL),与 expo zod.optional 行为对齐。 + */ + private suspend fun bindPayload(type: Int, userId: String, loop: Int?): JSONObject { + val time = timeProvider.fetch() + val payload = JSONObject() + .put("type", type) + .put("userId", userId) + .put("time", time) + if (loop != null) { + payload.put("loop", loop) + } + return payload + } } diff --git a/sdk/src/main/kotlin/com/duooomi/ble/utils/BeijingTimeProvider.kt b/sdk/src/main/kotlin/com/duooomi/ble/utils/BeijingTimeProvider.kt new file mode 100644 index 0000000..ebf8dfc --- /dev/null +++ b/sdk/src/main/kotlin/com/duooomi/ble/utils/BeijingTimeProvider.kt @@ -0,0 +1,134 @@ +package com.duooomi.ble.utils + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** + * 提供 ISO 8601 格式的北京时间字符串(如 `2026-05-07T16:58:00+08:00`), + * 用于 bind / unbind / setPlayMode payload 的 `time` 字段。 + * + * 三级 fallback: + * 1. 苏宁公开时间 API 的 body(`sysTime2` 或 `sysTime1`) + * 2. 苏宁 response.headers["Date"](HTTP RFC 1123) + * 3. 本地 Date() + 8h + * + * 5 秒内单飞复用,避免 bind/setPlayMode 连发时重复请求。 + */ +internal class BeijingTimeProvider { + + private val mutex = Mutex() + + @Volatile + private var cachedValue: String? = null + + @Volatile + private var cachedAtMs: Long = 0L + + suspend fun fetch(): String = withContext(Dispatchers.IO) { + // 命中 5s 缓存 → 直接返回(无锁路径) + cachedValue?.let { v -> + if (System.currentTimeMillis() - cachedAtMs < CACHE_TTL_MS) return@withContext v + } + + mutex.withLock { + // 取锁后再判一次(双检) + cachedValue?.let { v -> + if (System.currentTimeMillis() - cachedAtMs < CACHE_TTL_MS) return@withLock v + } + val value = performFetch() + cachedValue = value + cachedAtMs = System.currentTimeMillis() + value + } + } + + private fun performFetch(): String { + val conn = runCatching { (URL(SUNING_URL).openConnection() as HttpURLConnection) } + .getOrNull() ?: return formatLocal() + try { + conn.requestMethod = "GET" + conn.connectTimeout = FETCH_TIMEOUT_MS + conn.readTimeout = FETCH_TIMEOUT_MS + + val body = runCatching { + conn.inputStream.bufferedReader().use { it.readText() } + }.getOrNull() + + body?.let { + val json = runCatching { JSONObject(it) }.getOrNull() + if (json != null) { + formatSuningBody(json)?.let { return it } + } + } + + // 退到 header Date + val dateHeader = conn.getHeaderField("Date") + if (!dateHeader.isNullOrBlank()) { + formatHttpDateHeader(dateHeader)?.let { return it } + } + } catch (_: Exception) { + // ignore — 进入本地 fallback + } finally { + runCatching { conn.disconnect() } + } + return formatLocal() + } + + private fun formatSuningBody(json: JSONObject): String? { + val sysTime2 = json.optString("sysTime2").takeIf { it.isNotEmpty() } + if (sysTime2 != null && Regex("""^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$""").matches(sysTime2)) { + return sysTime2.replace(' ', 'T') + "+08:00" + } + val sysTime1 = json.optString("sysTime1").takeIf { it.isNotEmpty() } + if (sysTime1 != null && sysTime1.length == 14 && sysTime1.all { it.isDigit() }) { + val y = sysTime1.substring(0, 4) + val mo = sysTime1.substring(4, 6) + val d = sysTime1.substring(6, 8) + val h = sysTime1.substring(8, 10) + val mi = sysTime1.substring(10, 12) + val se = sysTime1.substring(12, 14) + return "$y-$mo-${d}T$h:$mi:$se+08:00" + } + return null + } + + private fun formatHttpDateHeader(raw: String): String? { + val formatter = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US).apply { + timeZone = TimeZone.getTimeZone("GMT") + } + val date = runCatching { formatter.parse(raw) }.getOrNull() ?: return null + return formatBeijing(date) + } + + private fun formatLocal(): String = formatBeijing(Date()) + + private fun formatBeijing(date: Date): String { + val cal = Calendar.getInstance(TimeZone.getTimeZone("GMT+08:00"), Locale.US).apply { time = date } + return String.format( + Locale.US, + "%04d-%02d-%02dT%02d:%02d:%02d+08:00", + cal.get(Calendar.YEAR), + cal.get(Calendar.MONTH) + 1, + cal.get(Calendar.DAY_OF_MONTH), + cal.get(Calendar.HOUR_OF_DAY), + cal.get(Calendar.MINUTE), + cal.get(Calendar.SECOND) + ) + } + + companion object { + private const val SUNING_URL = "https://quan.suning.com/getSysTime.do" + private const val CACHE_TTL_MS = 5_000L + private const val FETCH_TIMEOUT_MS = 3_000 + } +}