feat(win): T-WIN-003 WebSocket 关键点接收服务

This commit is contained in:
2026-09-14 20:04:54 +08:00
parent 787f1c0cd8
commit 2e539c7fa6
2 changed files with 619 additions and 0 deletions
+324
View File
@@ -0,0 +1,324 @@
class_name PoseServer
extends Node
## T-WIN-003:WebSocket 关键点接收服务
##
## 契约 contracts/websocket-keypoints.md §1:
## 端点 ws://<windows-host>:8765/pose
## 子协议 opendance.pose.v1
## 二进制帧 = 239 字节关键点(见 KeypointFrame)
## 文本帧 = JSON 控制消息(hello / welcome / config / stats / bye)
##
## 本端为**服务端**,Android 辅助 App 主动连入。
##
## 说明:Godot 的 WebSocketPeer 不暴露握手请求路径,因此无法校验客户端
## 连接的 path 是否为 /pose;本实现接受任意 path,仅校验子协议。
signal listening(port: int)
signal listen_failed(port: int, reason: String)
signal client_connected(client_id: int, device: String)
signal client_disconnected(client_id: int, reason: String)
signal frame_received(frame: KeypointFrame.PoseFrame)
signal control_received(client_id: int, message: Dictionary)
signal stats_updated(stats: Dictionary)
const KeypointFrame = preload("res://src/net/keypoint_frame.gd")
const DEFAULT_PORT := 8765
const DEFAULT_SUBPROTOCOL := "opendance.pose.v1"
## 超过该时长(毫秒)没有收到任何帧,判定为超时断开
const CLIENT_TIMEOUT_MS := 5000
## 每秒向客户端推送一次 config 的间隔(0 表示不主动推送)
const CONFIG_PUSH_INTERVAL_MS := 2000
var subprotocol: String = DEFAULT_SUBPROTOCOL
var expected_fps: int = 30
var mirror: bool = false
var upper_body_only: bool = false
var resolution: String = "640x480"
var _tcp := TCPServer.new()
var _clients: Array = []
var _next_client_id: int = 1
var _port: int = 0
var _running: bool = false
var _last_config_push_ms: int = 0
## 累计统计
var frames_total: int = 0
var frames_dropped: int = 0
var malformed_frames: int = 0
var last_frame: KeypointFrame.PoseFrame = null
var last_frame_at_ms: int = 0
func is_running() -> bool:
return _running
func port() -> int:
return _port
func client_count() -> int:
return _clients.size()
## 已握手完成的连接数
func connected_count() -> int:
var n := 0
for entry in _clients:
if entry["handshaken"]:
n += 1
return n
func listen(host: String = "0.0.0.0", listen_port: int = DEFAULT_PORT) -> Error:
stop()
var bind_host := host
if bind_host == "" or bind_host == "0.0.0.0":
bind_host = "*"
var err := _tcp.listen(listen_port, bind_host)
if err != OK:
listen_failed.emit(listen_port, error_string(err))
push_error("[PoseServer] 监听 %s:%d 失败:%s" % [host, listen_port, error_string(err)])
return err
_port = listen_port
_running = true
set_process(true)
listening.emit(listen_port)
return OK
func stop() -> void:
_running = false
for entry in _clients:
var peer: WebSocketPeer = entry["peer"]
if peer.get_ready_state() == WebSocketPeer.STATE_OPEN:
peer.close(1001, "server shutdown")
_tcp.stop()
_clients.clear()
set_process(false)
func _ready() -> void:
set_process(false)
func _process(_delta: float) -> void:
if not _running:
return
_accept_new_connections()
_poll_clients()
_maybe_push_config()
func _accept_new_connections() -> void:
while _tcp.is_connection_available():
var conn := _tcp.take_connection()
if conn == null:
continue
var peer := WebSocketPeer.new()
peer.supported_protocols = PackedStringArray([subprotocol])
var err := peer.accept_stream(conn)
if err != OK:
push_warning("[PoseServer] accept_stream 失败:%s" % error_string(err))
continue
_clients.append({
"id": _next_client_id,
"peer": peer,
"handshaken": false,
"device": "",
"last_seen_ms": Time.get_ticks_msec(),
"last_frame_id": -1,
"frames": 0,
"dropped": 0,
"fps_window_start_ms": Time.get_ticks_msec(),
"fps_window_frames": 0,
"fps": 0.0,
})
_next_client_id += 1
func _poll_clients() -> void:
var now := Time.get_ticks_msec()
var survivors: Array = []
for entry in _clients:
var peer: WebSocketPeer = entry["peer"]
peer.poll()
var state := peer.get_ready_state()
if state == WebSocketPeer.STATE_CLOSED:
client_disconnected.emit(entry["id"], "连接已关闭")
continue
if state == WebSocketPeer.STATE_OPEN and not entry["handshaken"]:
entry["handshaken"] = true
var selected := peer.get_selected_protocol()
if selected != "" and selected != subprotocol:
push_warning("[PoseServer] 客户端选择子协议 %s,与期望 %s 不符" % [selected, subprotocol])
client_connected.emit(entry["id"], entry["device"])
while peer.get_available_packet_count() > 0:
_handle_packet(entry, peer)
entry["last_seen_ms"] = now
# 超时剔除
if entry["handshaken"] and now - int(entry["last_seen_ms"]) > CLIENT_TIMEOUT_MS:
client_disconnected.emit(entry["id"], "超时未收到数据")
peer.close(1001, "timeout")
continue
survivors.append(entry)
_clients = survivors
func _handle_packet(entry: Dictionary, peer: WebSocketPeer) -> void:
var packet := peer.get_packet()
var is_text := peer.was_string_packet()
if is_text:
_handle_control(entry, packet.get_string_from_utf8())
return
# 二进制帧:契约要求恰好 239 字节
var problem := KeypointFrame.validate(packet)
if problem != "":
malformed_frames += 1
push_warning("[PoseServer] 客户端 %d 发来非法帧:%s" % [entry["id"], problem])
return
var frame := KeypointFrame.decode(packet)
if frame == null:
malformed_frames += 1
return
_update_frame_stats(entry, frame)
frames_total += 1
last_frame = frame
last_frame_at_ms = Time.get_ticks_msec()
frame_received.emit(frame)
func _update_frame_stats(entry: Dictionary, frame: KeypointFrame.PoseFrame) -> void:
var previous := int(entry["last_frame_id"])
if previous >= 0 and frame.frame_id > previous + 1:
# u32 回绕保护:差距过大视为计数器重置而非丢包
var gap := frame.frame_id - previous - 1
if gap < 100000:
entry["dropped"] = int(entry["dropped"]) + gap
frames_dropped += gap
entry["last_frame_id"] = frame.frame_id
entry["frames"] = int(entry["frames"]) + 1
entry["fps_window_frames"] = int(entry["fps_window_frames"]) + 1
var now := Time.get_ticks_msec()
var elapsed := now - int(entry["fps_window_start_ms"])
if elapsed >= 1000:
entry["fps"] = float(entry["fps_window_frames"]) * 1000.0 / float(elapsed)
entry["fps_window_frames"] = 0
entry["fps_window_start_ms"] = now
var stats := {
"client_id": entry["id"],
"fps": entry["fps"],
"frames": entry["frames"],
"dropped": entry["dropped"],
"device": entry["device"],
"total_frames": frames_total,
"total_dropped": frames_dropped,
"malformed": malformed_frames,
}
stats_updated.emit(stats)
func _handle_control(entry: Dictionary, text: String) -> void:
var parsed: Variant = JSON.parse_string(text)
if typeof(parsed) != TYPE_DICTIONARY:
push_warning("[PoseServer] 客户端 %d 发来非法控制帧" % entry["id"])
return
var message: Dictionary = parsed
var type := str(message.get("type", ""))
control_received.emit(entry["id"], message)
match type:
"hello":
entry["device"] = str(message.get("device", ""))
var protocol := str(message.get("protocol", subprotocol))
if protocol != subprotocol:
push_warning("[PoseServer] 协议版本不匹配:客户端 %s,本端 %s" % [protocol, subprotocol])
_send_json(entry, {
"type": "bye",
"reason": "protocol_mismatch: 本端要求 %s" % subprotocol,
})
var peer: WebSocketPeer = entry["peer"]
peer.close(1002, "protocol mismatch")
return
if message.has("targetFps"):
expected_fps = int(message["targetFps"])
_send_json(entry, {
"type": "welcome",
"protocol": subprotocol,
"sessionId": _session_id(),
"expectedFps": expected_fps,
})
"bye":
client_disconnected.emit(entry["id"], str(message.get("reason", "客户端主动断开")))
var peer2: WebSocketPeer = entry["peer"]
peer2.close(1000, "bye")
_:
pass
func _maybe_push_config() -> void:
if CONFIG_PUSH_INTERVAL_MS <= 0:
return
var now := Time.get_ticks_msec()
if now - _last_config_push_ms < CONFIG_PUSH_INTERVAL_MS:
return
_last_config_push_ms = now
broadcast_config()
func broadcast_config() -> void:
var payload := {
"type": "config",
"mirror": mirror,
"targetFps": expected_fps,
"upperBodyOnly": upper_body_only,
"resolution": resolution,
}
for entry in _clients:
if entry["handshaken"]:
_send_json(entry, payload)
func _send_json(entry: Dictionary, payload: Dictionary) -> void:
var peer: WebSocketPeer = entry["peer"]
if peer.get_ready_state() != WebSocketPeer.STATE_OPEN:
return
peer.send_text(JSON.stringify(payload))
func _session_id() -> String:
return "%d-%d" % [Time.get_unix_time_from_system(), _port]
func snapshot() -> Dictionary:
return {
"running": _running,
"port": _port,
"clients": _clients.size(),
"connected": connected_count(),
"frames_total": frames_total,
"frames_dropped": frames_dropped,
"malformed_frames": malformed_frames,
}