一、缺陷:config 被当成心跳
_maybe_push_config() 原来每 CONFIG_PUSH_INTERVAL_MS(2 秒)无条件广播一次 config。
契约 §3.3 的语义是「运行中**调整**时下发」,不是心跳 —— 一次 30 分钟推流就是 900 条
没有任何信息量的消息;更糟的是它让「收到 config」无法被当作「配置变了」的信号。
实测 v0.2.4 产物:17 秒内收到 9 条。
修复:
* 抽出 config_payload() 作为唯一载荷来源(同时用作「是否变化」的指纹);
* hello 应答顺序固定为「先 welcome、再 config」(若靠周期 tick 补发,
新对端第一条消息可能变成 config,只认握手应答的实现会困惑);
* _maybe_push_config() 仅在载荷真的变化时广播,并用 CONFIG_PUSH_INTERVAL_MS 去抖;
* 显式 broadcast_config()(UI 改设置)同步更新指纹,避免下一 tick 重发。
实测 v0.2.5:17 秒内 1 条。
二、契约「可选/兜底」语义的定向验收(沿用 Android 侧的教训:
契约里可选的字段必须用**不提供**它的对端来测,否则等于没测)
新增 3 个场景(客户端断言 30 → 40 项):
* v0.1.0 风格对端:只发二进制帧(每 500ms)、从不 ping/stats、hello 不带 deviceId,
持续 17 秒(> 静默超时 15 秒)→ 连接必须保持(契约 §1.1 的向后兼容承诺);
* 两条都不带 deviceId 的连接同时在线 → 不做去重、按连接计数(§5),
device_replaced 只应有那一次真正重复 deviceId 的事件;
* 完全不发 hello、只发 5 帧的对端 → 不断开、不崩、帧仍计入。
三、验收脚本自身修掉两处误判
* 「跨连接的 frame_id 回退」被当成缺陷:frame_id 是**每条连接各自从 0 递增**,
新连接重新开始完全合规 —— 指标用错了。已改名并标注「仅参考」,
真正相关的判据是逐连接的 frames_dropped == 3;
* 「静默超时次数」被当成固定值:套件里本来就有"完全不发数据"的对端,
空闲的替换连接也会被正常超时。已改为**定向断言**:由 hello 记录建立
client_id → device 映射(探针新增 hellos[]),只检查 v0.1.0 风格对端没被误踢。
四、测试客户端改为顺序无关
固定「welcome 之后紧跟 config」后,"第一条消息就是 welcome"的假设立刻暴露。
契约没规定消息顺序,客户端必须顺序无关 —— 已加 nextOfType(type) 按类型取消息
并跳过其它类型(同时计入 counts)。
实测(v0.2.5 发布包,四条验收全绿):
* accept-release.sh:pck 无开发残留 / PE 版本资源 0.2.5 / pck 启动 180 帧 / 运行时探针
* accept-ws.sh:客户端 40 通过 / 0 失败 + 服务端侧全部事实
(10 次连接、111 帧、frames_dropped=3、malformed=1、device_replaced 仅 1 次、
v0.1.0 风格对端未被静默超时踢掉)
* accept-frame-vectors.sh:解码 12/12、396 点 0 差异、编码器 0 差异
* verify.sh:785 通过 / 0 失败
520 lines
17 KiB
GDScript
520 lines
17 KiB
GDScript
class_name PoseServer
|
||
extends Node
|
||
|
||
## T-WIN-003 / T-AND-011:WebSocket 关键点接收服务
|
||
##
|
||
## 契约 contracts/websocket-keypoints.md v0.2.0 §1:
|
||
## 端点 ws://<windows-host>:8765/pose
|
||
## 子协议 opendance.pose.v1
|
||
## 二进制帧 = 239 字节关键点(见 KeypointFrame)
|
||
## 文本帧 = JSON 控制消息(hello / welcome / config / stats / ping / pong / bye)
|
||
##
|
||
## 本端为**服务端**,Android 辅助 App 主动连入。
|
||
##
|
||
## 说明:Godot 的 WebSocketPeer 不暴露握手请求路径,因此无法校验客户端
|
||
## 连接的 path 是否为 /pose;本实现接受任意 path,仅校验子协议。
|
||
##
|
||
## T-AND-011 对齐要点(契约 v0.2.0 §1.1 / §4.1 / §8):
|
||
## 1. 在 welcome / config 中**声明**静默超时与期望心跳间隔(silentTimeoutMs /
|
||
## heartbeatIntervalMs),且 heartbeatIntervalMs < silentTimeoutMs / 2;
|
||
## 2. 处理应用层 ping(协议层 Sec-WebSocket-Ping 在 Godot 里对应用层不可见,
|
||
## 不能用来续命),可选回 pong 供对端算 RTT;
|
||
## 3. 按 hello.deviceId **去重**:同一设备的重复连接替换旧连接(1000 关闭),
|
||
## 设备数按去重后计数,避免"反复重连"被显示成多台设备;
|
||
## 4. 日志与状态面板使用契约 §8 的同名连接状态描述每个客户端。
|
||
|
||
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)
|
||
## 同一 deviceId 的旧连接被新连接替换(契约 §4.1)
|
||
signal device_replaced(device_id: String)
|
||
|
||
const KeypointFrame = preload("res://src/net/keypoint_frame.gd")
|
||
|
||
const DEFAULT_PORT := 8765
|
||
const DEFAULT_SUBPROTOCOL := "opendance.pose.v1"
|
||
|
||
## 静默超时(毫秒):超过该时长没有收到**任何数据帧或文本帧**即断开。
|
||
## 契约 §1.1 要求本端在 welcome/config 中声明该值,客户端据此定心跳节奏。
|
||
## 取 15 秒:Android 每秒一次 stats、每帧关键点都算活动,15 秒足以容忍
|
||
## 相机暂停 / 画面中无人 / 推理短暂卡顿,又不会让死连接长期占用。
|
||
const CLIENT_TIMEOUT_MS := 15000
|
||
## 期望心跳间隔(毫秒)——必须 < CLIENT_TIMEOUT_MS / 2(契约 §1.1)
|
||
const HEARTBEAT_INTERVAL_MS := 5000
|
||
## 每秒向客户端推送一次 config 的间隔(0 表示不主动推送)
|
||
const CONFIG_PUSH_INTERVAL_MS := 2000
|
||
## degraded 状态的保持时长:这段时间内没有新的丢帧就回到 streaming。
|
||
## 做成可覆盖的实例字段,便于测试在不依赖真实等待的前提下核对状态回收。
|
||
const DEGRADED_HOLD_MS := 3000
|
||
## 契约 §8 的连接状态名(本端为接收端,逐客户端维护)
|
||
enum ClientState { IDLE, RESOLVING, CONNECTING, HANDSHAKING, STREAMING, DEGRADED, RECONNECTING, STOPPED }
|
||
|
||
const STATE_NAMES := {
|
||
ClientState.IDLE: "idle",
|
||
ClientState.RESOLVING: "resolving",
|
||
ClientState.CONNECTING: "connecting",
|
||
ClientState.HANDSHAKING: "handshaking",
|
||
ClientState.STREAMING: "streaming",
|
||
ClientState.DEGRADED: "degraded",
|
||
ClientState.RECONNECTING: "reconnecting",
|
||
ClientState.STOPPED: "stopped",
|
||
}
|
||
|
||
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 _last_config_signature: String = ""
|
||
## 见 DEGRADED_HOLD_MS;运行期可调(测试用)
|
||
var degraded_hold_ms: int = DEGRADED_HOLD_MS
|
||
|
||
## 累计统计
|
||
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"]
|
||
entry["state"] = ClientState.STOPPED
|
||
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": "",
|
||
"device_id": "",
|
||
"state": ClientState.CONNECTING,
|
||
"selected_protocol": "",
|
||
"connected_at_ms": Time.get_ticks_msec(),
|
||
"handshake_at_ms": 0,
|
||
"first_frame_at_ms": 0,
|
||
"last_seen_ms": Time.get_ticks_msec(),
|
||
"last_frame_id": -1,
|
||
"frames": 0,
|
||
"dropped": 0,
|
||
"degraded_until_ms": 0,
|
||
"close_code": 0,
|
||
"close_reason": "",
|
||
"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:
|
||
entry["close_code"] = peer.get_close_code()
|
||
entry["close_reason"] = peer.get_close_reason()
|
||
entry["state"] = ClientState.STOPPED
|
||
client_disconnected.emit(entry["id"], _close_reason_text(entry))
|
||
continue
|
||
|
||
if state == WebSocketPeer.STATE_OPEN and not entry["handshaken"]:
|
||
entry["handshaken"] = true
|
||
entry["handshake_at_ms"] = now
|
||
entry["state"] = ClientState.HANDSHAKING
|
||
var selected := peer.get_selected_protocol()
|
||
entry["selected_protocol"] = selected
|
||
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
|
||
|
||
# 降级状态到期后回到 streaming(契约 §8)
|
||
if entry["state"] == ClientState.DEGRADED and now >= int(entry["degraded_until_ms"]):
|
||
entry["state"] = ClientState.STREAMING if int(entry["first_frame_at_ms"]) > 0 else ClientState.HANDSHAKING
|
||
|
||
# 超时剔除:契约 §1.1,客户端应按 heartbeatIntervalMs 保活
|
||
if entry["handshaken"] and now - int(entry["last_seen_ms"]) > CLIENT_TIMEOUT_MS:
|
||
var silent_ms := now - int(entry["last_seen_ms"])
|
||
entry["state"] = ClientState.STOPPED
|
||
client_disconnected.emit(
|
||
entry["id"],
|
||
"静默超时 %d ms(阈值 %d ms,期望心跳 %d ms)" % [silent_ms, CLIENT_TIMEOUT_MS, HEARTBEAT_INTERVAL_MS],
|
||
)
|
||
peer.close(1001, "timeout")
|
||
continue
|
||
|
||
survivors.append(entry)
|
||
|
||
_clients = survivors
|
||
_dedup_devices()
|
||
|
||
|
||
## 契约 §4.1:同一 deviceId 只保留最新连接,旧连接以 1000 关闭(对端不重连)
|
||
func _dedup_devices() -> void:
|
||
if _clients.size() < 2:
|
||
return
|
||
var seen := {}
|
||
var keep: Array = []
|
||
# 倒序扫描:后加入的条目更新,先被保留
|
||
for i in range(_clients.size() - 1, -1, -1):
|
||
var entry: Dictionary = _clients[i]
|
||
var device := str(entry["device_id"])
|
||
if device == "" or not entry["handshaken"]:
|
||
keep.append(entry)
|
||
continue
|
||
if seen.has(device):
|
||
entry["state"] = ClientState.STOPPED
|
||
var peer: WebSocketPeer = entry["peer"]
|
||
if peer.get_ready_state() == WebSocketPeer.STATE_OPEN:
|
||
peer.close(1000, "replaced by new connection")
|
||
client_disconnected.emit(entry["id"], "被同一设备的新连接替换(deviceId=%s)" % device)
|
||
device_replaced.emit(device)
|
||
continue
|
||
seen[device] = entry
|
||
keep.append(entry)
|
||
keep.reverse()
|
||
_clients = keep
|
||
|
||
|
||
static func _close_reason_text(entry: Dictionary) -> String:
|
||
var code := int(entry["close_code"])
|
||
var reason := str(entry["close_reason"])
|
||
if code == 0:
|
||
return "连接已关闭(未收到关闭帧)"
|
||
if reason == "":
|
||
return "连接已关闭(code=%d)" % code
|
||
return "连接已关闭(code=%d,%s)" % [code, reason]
|
||
|
||
|
||
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:
|
||
if int(entry["first_frame_at_ms"]) == 0:
|
||
entry["first_frame_at_ms"] = Time.get_ticks_msec()
|
||
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
|
||
# 契约 §8:仍在连接但出现丢帧 -> degraded(3 秒无新丢帧后自动回到 streaming)
|
||
entry["state"] = ClientState.DEGRADED
|
||
entry["degraded_until_ms"] = Time.get_ticks_msec() + degraded_hold_ms
|
||
entry["last_frame_id"] = frame.frame_id
|
||
entry["frames"] = int(entry["frames"]) + 1
|
||
if entry["state"] != ClientState.DEGRADED:
|
||
entry["state"] = ClientState.STREAMING
|
||
|
||
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"],
|
||
"device_id": entry["device_id"],
|
||
"state": state_name(entry["state"]),
|
||
"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", ""))
|
||
entry["device_id"] = str(message.get("deviceId", ""))
|
||
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"])
|
||
entry["state"] = ClientState.STREAMING if int(entry["first_frame_at_ms"]) > 0 else ClientState.HANDSHAKING
|
||
# 顺序固定:先 welcome(对 hello 的应答),再 config(当前镜像/帧率/保活参数)。
|
||
# 也可以等周期 tick 广播,但那样"新连接的第一条消息"可能变成 config,
|
||
# 让只认"握手应答"的实现困惑 —— 契约没规定顺序,但顺序自然更好排障。
|
||
_send_json(entry, welcome_payload())
|
||
_send_json(entry, config_payload())
|
||
"ping":
|
||
# 契约 §3.6:应用层心跳(协议层 ping 对应用层不可见,无法续命)
|
||
_send_json(entry, {
|
||
"type": "pong",
|
||
"sentAtMs": int(message.get("sentAtMs", 0)),
|
||
"serverAtMs": Time.get_ticks_msec(),
|
||
})
|
||
"bye":
|
||
entry["state"] = ClientState.STOPPED
|
||
client_disconnected.emit(entry["id"], str(message.get("reason", "客户端主动断开")))
|
||
var peer2: WebSocketPeer = entry["peer"]
|
||
peer2.close(1000, "bye")
|
||
_:
|
||
pass
|
||
|
||
|
||
## 契约 §3.2:welcome 必须声明保活参数,客户端据此定心跳节奏
|
||
func welcome_payload() -> Dictionary:
|
||
return {
|
||
"type": "welcome",
|
||
"protocol": subprotocol,
|
||
"sessionId": _session_id(),
|
||
"expectedFps": expected_fps,
|
||
"silentTimeoutMs": CLIENT_TIMEOUT_MS,
|
||
"heartbeatIntervalMs": HEARTBEAT_INTERVAL_MS,
|
||
}
|
||
|
||
|
||
## 契约 §3.3 的 config 载荷(同时用作「配置是否变化」的指纹来源)
|
||
func config_payload() -> Dictionary:
|
||
return {
|
||
"type": "config",
|
||
"mirror": mirror,
|
||
"targetFps": expected_fps,
|
||
"upperBodyOnly": upper_body_only,
|
||
"resolution": resolution,
|
||
# 契约 §3.3:运行中可一并调整保活参数
|
||
"silentTimeoutMs": CLIENT_TIMEOUT_MS,
|
||
"heartbeatIntervalMs": HEARTBEAT_INTERVAL_MS,
|
||
}
|
||
|
||
|
||
## 契约 §3.3 的语义是「**运行中调整**时下发」,不是心跳。
|
||
##
|
||
## 原实现每 `CONFIG_PUSH_INTERVAL_MS`(2 秒)**无条件**广播一次 config ——
|
||
## 对一次 30 分钟的推流是 900 条没有任何信息量的消息,也让「收到 config」
|
||
## 无法被当成「配置变了」的信号(发送端想据此做点什么就无从判断)。
|
||
## 现在改为:
|
||
## 1. 有新客户端完成握手 → 立刻补发一次(它需要知道镜像/目标帧率);
|
||
## 2. 之后仅当**载荷真的变化**才发,并用 `CONFIG_PUSH_INTERVAL_MS` 做去抖
|
||
## (连续拖动滑块时不会每个中间值都发一条)。
|
||
func _maybe_push_config() -> void:
|
||
if CONFIG_PUSH_INTERVAL_MS <= 0:
|
||
return
|
||
var now := Time.get_ticks_msec()
|
||
var signature := JSON.stringify(config_payload())
|
||
|
||
if signature == _last_config_signature:
|
||
return
|
||
if now - _last_config_push_ms < CONFIG_PUSH_INTERVAL_MS:
|
||
return
|
||
_last_config_signature = signature
|
||
_last_config_push_ms = now
|
||
broadcast_config()
|
||
|
||
|
||
func broadcast_config() -> void:
|
||
var payload := config_payload()
|
||
# 显式广播(UI 改了镜像/帧率等)也要更新指纹,否则下一个 tick 会再发一遍同样的内容
|
||
_last_config_signature = JSON.stringify(payload)
|
||
_last_config_push_ms = Time.get_ticks_msec()
|
||
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]
|
||
|
||
|
||
## 状态名(契约 §8,两端同名,便于对照日志)
|
||
static func state_name(state: int) -> String:
|
||
return str(STATE_NAMES.get(state, "unknown"))
|
||
|
||
|
||
## 逐客户端信息(界面 / 日志用)
|
||
func clients_info() -> Array:
|
||
var out: Array = []
|
||
for entry in _clients:
|
||
out.append({
|
||
"id": entry["id"],
|
||
"device": entry["device"],
|
||
"device_id": entry["device_id"],
|
||
"state": state_name(entry["state"]),
|
||
"protocol": entry["selected_protocol"],
|
||
"frames": entry["frames"],
|
||
"dropped": entry["dropped"],
|
||
"fps": entry["fps"],
|
||
"first_frame_at_ms": entry["first_frame_at_ms"],
|
||
})
|
||
return out
|
||
|
||
|
||
## 去重后的设备数(契约 §4.1:同一 deviceId 只算一台)
|
||
func device_count() -> int:
|
||
var ids := {}
|
||
var anonymous := 0
|
||
for entry in _clients:
|
||
if not entry["handshaken"]:
|
||
continue
|
||
var device := str(entry["device_id"])
|
||
if device == "":
|
||
anonymous += 1
|
||
else:
|
||
ids[device] = true
|
||
return ids.size() + anonymous
|
||
|
||
|
||
func snapshot() -> Dictionary:
|
||
return {
|
||
"running": _running,
|
||
"port": _port,
|
||
"clients": _clients.size(),
|
||
"connected": connected_count(),
|
||
"devices": device_count(),
|
||
"silent_timeout_ms": CLIENT_TIMEOUT_MS,
|
||
"heartbeat_interval_ms": HEARTBEAT_INTERVAL_MS,
|
||
"frames_total": frames_total,
|
||
"frames_dropped": frames_dropped,
|
||
"malformed_frames": malformed_frames,
|
||
}
|