Files
opendance-android/app/src/main/java/com/opendance/studio/net/PoseWebSocketClient.kt
T

329 lines
11 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.opendance.studio.net
import android.os.Build
import android.util.Log
import com.opendance.studio.BuildConfig
import com.opendance.studio.model.KeypointFrame
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.json.JSONObject
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.min
/**
* T-AND-005:WebSocket 推流客户端(本端为**发送方**)
*
* 契约 contracts/websocket-keypoints.md:
* - 端点 ws://<windows-host>:8765/pose,子协议 opendance.pose.v1
* - 二进制帧 = 239 字节关键点(见 [KeypointFrame])
* - 文本控制帧 hello / config / stats / bye
*
* 关键设计:
* - 二进制帧走 `send(ByteString)`,控制帧走 `send(text)`
* - 自动重连采用指数退避(1s → 2s → 4s … 上限 15s),连接成功即重置
* - `sendFrame` 在非连接态直接丢弃并计数,绝不阻塞相机线程
* - 统计信息(发送帧数、丢弃帧数、FPS、RTT)由本类维护,供运行页展示
*/
class PoseWebSocketClient(
private val scope: CoroutineScope,
private val subprotocol: String = BuildConfig.WS_SUBPROTOCOL,
) {
enum class State { IDLE, CONNECTING, CONNECTED, RECONNECTING, CLOSED }
data class Stats(
val state: State = State.IDLE,
val url: String = "",
val sentFrames: Int = 0,
val droppedFrames: Int = 0,
val lastFrameId: Int = -1,
val sendFps: Float = 0f,
val roundTripMs: Long = -1,
val reconnectAttempts: Int = 0,
val lastError: String = "",
val serverSessionId: String = "",
)
private val client: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS) // WebSocket 长连接不设读超时
.writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.pingInterval(PING_INTERVAL_SECONDS, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
private val _stats = MutableStateFlow(Stats())
val stats: StateFlow<Stats> = _stats.asStateFlow()
private var webSocket: WebSocket? = null
private var reconnectJob: Job? = null
private var url: String = ""
private var manualClose = false
private val sentCounter = AtomicInteger(0)
private val droppedCounter = AtomicInteger(0)
private val reconnectCounter = AtomicInteger(0)
private val frameIdCounter = AtomicInteger(0)
/** 首帧时间戳与计数,用于计算发送 FPS */
private var fpsWindowStart = 0L
private var fpsWindowFrames = 0
private val deviceLabel: String =
"${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android" }
// ------------------------------------------------------------------ 连接
fun connect(targetUrl: String, autoReconnect: Boolean = true) {
url = targetUrl
manualClose = false
this.autoReconnect = autoReconnect
reconnectCounter.set(0)
openSocket(initial = true)
}
private var autoReconnect = true
private fun openSocket(initial: Boolean) {
closeSocketQuietly()
val state = if (initial) State.CONNECTING else State.RECONNECTING
update(state = state, error = "")
val request = Request.Builder()
.url(url)
.header("Sec-WebSocket-Protocol", subprotocol)
.build()
Log.i(TAG, "连接 $url(子协议 $subprotocol)")
webSocket = client.newWebSocket(request, listener)
}
private val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.i(TAG, "已连接:${response.code}")
reconnectCounter.set(0)
fpsWindowStart = 0L
fpsWindowFrames = 0
update(state = State.CONNECTED, error = "")
sendHello()
}
override fun onMessage(webSocket: WebSocket, text: String) {
handleControlMessage(text)
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
Log.i(TAG, "服务端关闭:$code $reason")
webSocket.close(1000, null)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.i(TAG, "连接已关闭:$code $reason")
update(state = State.CLOSED)
scheduleReconnect()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
val reason = t.message ?: t.javaClass.simpleName
Log.w(TAG, "连接失败:$reason")
update(state = State.CLOSED, error = reason)
scheduleReconnect()
}
}
private fun scheduleReconnect() {
if (manualClose || !autoReconnect) return
reconnectJob?.cancel()
val attempt = reconnectCounter.incrementAndGet()
val delayMs = min(BASE_RECONNECT_MS shl (attempt - 1), MAX_RECONNECT_MS.toInt()).toLong()
update(state = State.RECONNECTING, attempts = attempt)
reconnectJob = scope.launch {
delay(delayMs)
if (!manualClose && autoReconnect) {
Log.i(TAG, "第 $attempt 次重连(等待 ${delayMs}ms)")
openSocket(initial = false)
}
}
}
fun disconnect() {
manualClose = true
reconnectJob?.cancel()
reconnectJob = null
sendBye("user_stop")
closeSocketQuietly()
update(state = State.CLOSED)
}
private fun closeSocketQuietly() {
webSocket?.let {
it.cancel()
}
webSocket = null
}
// ------------------------------------------------------------------ 发送
/**
* 发送一帧关键点。非连接态直接丢弃并计入 droppedFrames —— 绝不能阻塞相机线程。
* @return 实际是否发出
*/
fun sendFrame(frame: KeypointFrame.PoseFrame): Boolean {
val socket = webSocket ?: return drop()
if (_stats.value.state != State.CONNECTED) return drop()
val frameId = frameIdCounter.getAndIncrement()
val bytes = KeypointFrame.encode(
frameId = frameId,
xs = frame.x,
ys = frame.y,
zs = frame.z,
confidence = frame.confidence,
flags = frame.flags,
)
val ok = socket.send(okio.ByteString.of(*bytes))
if (!ok) return drop()
sentCounter.incrementAndGet()
fpsWindowFrames++
val now = System.currentTimeMillis()
if (fpsWindowStart == 0L) fpsWindowStart = now
val elapsed = now - fpsWindowStart
val fps = if (elapsed >= FPS_WINDOW_MS) {
val value = fpsWindowFrames * 1000f / elapsed
fpsWindowStart = now
fpsWindowFrames = 0
value
} else {
_stats.value.sendFps
}
_stats.value = _stats.value.copy(
sentFrames = sentCounter.get(),
droppedFrames = droppedCounter.get(),
lastFrameId = frameId,
sendFps = fps,
)
return true
}
private fun drop(): Boolean {
droppedCounter.incrementAndGet()
_stats.value = _stats.value.copy(droppedFrames = droppedCounter.get())
return false
}
fun sendHello() {
sendJson(
JSONObject().apply {
put("type", "hello")
put("protocol", subprotocol)
put("device", deviceLabel)
put("appVersion", BuildConfig.VERSION_NAME)
put("cameraFacing", "front")
put("targetFps", TARGET_FPS_FIELD)
put("resolution", RESOLUTION_FIELD)
},
)
}
fun sendStats(fps: Float, dropped: Int, latencyMs: Long, confidenceAvg: Float) {
sendJson(
JSONObject().apply {
put("type", "stats")
put("fps", fps.toDouble())
put("dropped", dropped)
put("latencyMs", latencyMs)
put("confidenceAvg", confidenceAvg.toDouble())
},
)
}
fun sendBye(reason: String) {
sendJson(
JSONObject().apply {
put("type", "bye")
put("reason", reason)
},
)
}
private fun sendJson(json: JSONObject) {
val socket = webSocket ?: return
if (_stats.value.state != State.CONNECTED) return
socket.send(json.toString())
}
private fun handleControlMessage(text: String) {
try {
val json = JSONObject(text)
when (json.optString("type")) {
"welcome" -> {
_stats.value = _stats.value.copy(
serverSessionId = json.optString("sessionId", ""),
roundTripMs = 0,
)
}
"config" -> onRemoteConfig?.invoke(json)
"bye" -> Log.i(TAG, "服务端要求断开:${json.optString("reason")}")
}
} catch (e: Exception) {
Log.w(TAG, "控制帧解析失败:${e.message}")
}
}
/** 服务端下发 config 时回调(契约 §3.3) */
var onRemoteConfig: ((JSONObject) -> Unit)? = null
private fun update(
state: State,
error: String = _stats.value.lastError,
attempts: Int = _stats.value.reconnectAttempts,
) {
_stats.value = _stats.value.copy(
state = state,
url = url,
lastError = error,
reconnectAttempts = attempts,
sentFrames = sentCounter.get(),
droppedFrames = droppedCounter.get(),
)
}
fun release() {
disconnect()
client.dispatcher.executorService.shutdown()
}
companion object {
private const val TAG = "PoseWebSocket"
private const val CONNECT_TIMEOUT_SECONDS = 8L
private const val WRITE_TIMEOUT_SECONDS = 5L
private const val PING_INTERVAL_SECONDS = 15L
private const val BASE_RECONNECT_MS = 1000
private const val MAX_RECONNECT_MS = 15000L
private const val FPS_WINDOW_MS = 1000L
/** hello 里的字段由设置填充,运行时由调用方覆盖 */
@Volatile var TARGET_FPS_FIELD: Int = 30
@Volatile var RESOLUTION_FIELD: String = "640x480"
}
}