feat(android): T-AND-005/006 WebSocket 推流 + 主机发现

This commit is contained in:
2026-09-14 20:40:16 +08:00
parent 30c316a1cc
commit d27845ef43
2 changed files with 444 additions and 0 deletions
@@ -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"
}
}