feat: PlayMode 切换 + bind/unbind/setPlayMode 携带北京时间

主要改动(与 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) <noreply@anthropic.com>
This commit is contained in:
km2023
2026-05-08 15:30:01 +08:00
parent 8b18292100
commit b4398518a9
6 changed files with 263 additions and 10 deletions

View File

@@ -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 ===

View File

@@ -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")

View File

@@ -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)
)
}
}

View File

@@ -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
}
}
}

View File

@@ -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 命令字 0x0Fpayload 携带 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
}
}

View File

@@ -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
}
}