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:
@@ -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 ===
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
20
sdk/src/main/kotlin/com/duooomi/ble/models/PlayMode.kt
Normal file
20
sdk/src/main/kotlin/com/duooomi/ble/models/PlayMode.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
134
sdk/src/main/kotlin/com/duooomi/ble/utils/BeijingTimeProvider.kt
Normal file
134
sdk/src/main/kotlin/com/duooomi/ble/utils/BeijingTimeProvider.kt
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user