feat(android): T-AND-005/006 WebSocket 推流 + 主机发现
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
package com.opendance.studio.net
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.NetworkInterface
|
||||
import java.net.Socket
|
||||
|
||||
/**
|
||||
* T-AND-006:主机发现
|
||||
*
|
||||
* Windows 客户端是**裸 WebSocket 服务端**(不是 HTTP 服务),因此不能靠扫描
|
||||
* HTTP 端点发现;这里用「TCP 能否连上 8765 端口」作为探测手段。
|
||||
*
|
||||
* 策略:取手机在 WiFi 网的 IPv4 地址,推导 /24 网段,并发探测该网段内所有
|
||||
* 主机的目标端口(默认并发 32、单机超时 300ms,整段约 2~3 秒)。
|
||||
*/
|
||||
object HostDiscovery {
|
||||
|
||||
private const val TAG = "HostDiscovery"
|
||||
const val DEFAULT_PORT = 8765
|
||||
private const val CONNECT_TIMEOUT_MS = 300
|
||||
private const val MAX_CONCURRENCY = 32
|
||||
|
||||
/** 发现结果 */
|
||||
data class Candidate(val ip: String, val port: Int, val latencyMs: Long)
|
||||
|
||||
/** 手机自身的 WiFi IPv4 地址(形如 192.168.1.23),无网络时返回 null */
|
||||
suspend fun localIpv4Address(): String? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val candidates = mutableListOf<String>()
|
||||
for (nif in NetworkInterface.getNetworkInterfaces()) {
|
||||
if (!nif.isUp || nif.isLoopback) continue
|
||||
val name = nif.name.lowercase()
|
||||
// 排除蜂窝与隧道网卡,优先 WiFi / 以太网
|
||||
if (name.startsWith("rmnet") || name.startsWith("dummy") || name.startsWith("tun")) continue
|
||||
|
||||
for (address in nif.inetAddresses) {
|
||||
if (address !is Inet4Address || address.isLoopbackAddress) continue
|
||||
val ip = address.hostAddress ?: continue
|
||||
// 排除链路本地地址(169.254.x.x)
|
||||
if (ip.startsWith("169.254.")) continue
|
||||
candidates.add(ip)
|
||||
}
|
||||
}
|
||||
// 优先返回私有网段地址;否则退回第一个可用地址
|
||||
candidates.firstOrNull { isPrivateIpv4(it) } ?: candidates.firstOrNull()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "获取本机地址失败:${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断是否为 RFC1918 私有地址 */
|
||||
fun isPrivateIpv4(ip: String): Boolean {
|
||||
val parts = ip.split(".").mapNotNull { it.toIntOrNull() }
|
||||
if (parts.size != 4) return false
|
||||
val (a, b) = parts[0] to parts[1]
|
||||
return a == 10 ||
|
||||
(a == 172 && b in 16..31) ||
|
||||
(a == 192 && b == 168)
|
||||
}
|
||||
|
||||
/** 由本机地址推导 /24 前缀,如 192.168.1.23 → "192.168.1" */
|
||||
fun subnetPrefix(ip: String): String? {
|
||||
val parts = ip.split(".")
|
||||
if (parts.size != 4) return null
|
||||
return parts.take(3).joinToString(".")
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描网段。以 Flow 形式逐步产出候选,便于 UI 边扫边显示。
|
||||
*/
|
||||
fun scan(prefix: String, port: Int = DEFAULT_PORT): Flow<Candidate> = flow {
|
||||
val semaphore = Semaphore(MAX_CONCURRENCY)
|
||||
val candidates = coroutineScope {
|
||||
(1..254).map { last ->
|
||||
async(Dispatchers.IO) {
|
||||
semaphore.withPermit {
|
||||
probe("$prefix.$last", port)
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
// 按延迟排序,最快响应的最可能是目标机
|
||||
candidates.filterNotNull().sortedBy { it.latencyMs }.forEach { emit(it) }
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
/** 探测单个主机:能建立 TCP 连接即认为端口开放 */
|
||||
private fun probe(ip: String, port: Int): Candidate? {
|
||||
val started = System.currentTimeMillis()
|
||||
return try {
|
||||
Socket().use { socket ->
|
||||
socket.connect(InetSocketAddress(ip, port), CONNECT_TIMEOUT_MS)
|
||||
Candidate(ip, port, System.currentTimeMillis() - started)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** 单个主机连通性检查(连接页「测试连接」用) */
|
||||
suspend fun checkHost(ip: String, port: Int = DEFAULT_PORT): Candidate? = withContext(Dispatchers.IO) {
|
||||
probe(ip, port)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user