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.DuooomiBleSDK
import com.duooomi.ble.core.ConnectionState import com.duooomi.ble.core.ConnectionState
import com.duooomi.ble.models.FirmwareInfo import com.duooomi.ble.models.FirmwareInfo
import com.duooomi.ble.models.PlayMode
import com.duooomi.ble.protocol.CommandType import com.duooomi.ble.protocol.CommandType
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -52,6 +53,7 @@ fun DemoScreen(sdk: DuooomiBleSDK) {
val connectedDevice by sdk.connectedDevice.collectAsState() val connectedDevice by sdk.connectedDevice.collectAsState()
val deviceInfo by sdk.deviceInfo.collectAsState() val deviceInfo by sdk.deviceInfo.collectAsState()
val version by sdk.version.collectAsState() val version by sdk.version.collectAsState()
val isActivated by sdk.isActivated.collectAsState()
val transferProgress by sdk.transferProgress.collectAsState() val transferProgress by sdk.transferProgress.collectAsState()
val sdkError by sdk.error.collectAsState() val sdkError by sdk.error.collectAsState()
@@ -253,6 +255,41 @@ fun DemoScreen(sdk: DuooomiBleSDK) {
} }
}) { Text("unbind") } }) { 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 === // === Transfer Section ===

View File

@@ -5,6 +5,7 @@ import com.duooomi.ble.core.*
import com.duooomi.ble.models.* import com.duooomi.ble.models.*
import com.duooomi.ble.protocol.CommandType import com.duooomi.ble.protocol.CommandType
import com.duooomi.ble.services.* import com.duooomi.ble.services.*
import com.duooomi.ble.utils.BeijingTimeProvider
import com.duooomi.ble.utils.BleLog import com.duooomi.ble.utils.BleLog
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@@ -45,7 +46,8 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
private val bleClient = BleClient(context) private val bleClient = BleClient(context)
private val protocolService = BleProtocolService(bleClient) 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 fileTransferService = FileTransferService(protocolService)
private val aniConverter = AniConverter(config) private val aniConverter = AniConverter(config)
private val firmwareService = FirmwareService(config) private val firmwareService = FirmwareService(config)
@@ -229,6 +231,36 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
return resp 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 { suspend fun unbind(userId: String): UnbindResponse {
ensureConnected() ensureConnected()
BleLog.d("Sending unbind (userId=$userId)", "Command") BleLog.d("Sending unbind (userId=$userId)", "Command")

View File

@@ -8,17 +8,21 @@ data class DeviceInfo(
val name: String, val name: String,
val size: String, val size: String,
val brand: String, val brand: String,
val powerlevel: Int val powerlevel: Int,
/** 播放模式(旧固件无此字段时为 null */
val loop: PlayMode?
) { ) {
companion object { companion object {
fun fromJson(json: JSONObject): DeviceInfo { fun fromJson(json: JSONObject): DeviceInfo {
val rawLoop = if (json.has("loop")) json.optInt("loop", -1) else -1
return DeviceInfo( return DeviceInfo(
allspace = json.optLong("allspace", 0L), allspace = json.optLong("allspace", 0L),
freespace = json.optLong("freespace", 0L), freespace = json.optLong("freespace", 0L),
name = json.optString("name", ""), name = json.optString("name", ""),
size = json.optString("size", ""), size = json.optString("size", ""),
brand = json.optString("brand", ""), 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 package com.duooomi.ble.services
import com.duooomi.ble.models.PlayMode
import com.duooomi.ble.protocol.CommandType import com.duooomi.ble.protocol.CommandType
import com.duooomi.ble.utils.BeijingTimeProvider
import org.json.JSONObject 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() { suspend fun getDeviceInfo() {
protocolService.sendJSON( protocolService.sendJSON(
@@ -19,21 +24,26 @@ internal class DeviceInfoService(private val protocolService: BleProtocolService
) )
} }
/** 绑定:先取北京时间再发命令,与 RN/iOS 行为对齐。 */
suspend fun bindDevice(userId: String) { suspend fun bindDevice(userId: String) {
protocolService.sendJSON( protocolService.sendJSON(
type = CommandType.BIND_DEVICE, type = CommandType.BIND_DEVICE,
payload = JSONObject() payload = bindPayload(CommandType.BIND_DEVICE.value, userId, loop = null)
.put("type", CommandType.BIND_DEVICE.value)
.put("userId", userId)
) )
} }
suspend fun unbindDevice(userId: String) { suspend fun unbindDevice(userId: String) {
protocolService.sendJSON( protocolService.sendJSON(
type = CommandType.UNBIND_DEVICE, type = CommandType.UNBIND_DEVICE,
payload = JSONObject() payload = bindPayload(CommandType.UNBIND_DEVICE.value, userId, loop = null)
.put("type", CommandType.UNBIND_DEVICE.value) )
.put("userId", userId) }
/** 切换播放模式(复用 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) .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
}
}