# contracts/websocket-keypoints.md - 关键点传输契约 v0.2.0 Android 辅助 App(发送端)-> Windows 客户端(接收端)。 服务端不参与实时链路,但 T-WEB-006 预处理产出的序列必须与此结构同构。 ## 1. 端点 ``` ws://:8765/pose ``` - Windows 客户端监听,Android App 主动连接。 - 子协议:`opendance.pose.v1`(`Sec-WebSocket-Protocol`) - 二进制帧传输关键点,文本帧传输控制消息(JSON)。 - 默认 30 FPS;发送端允许丢帧,接收端必须按 `frame_id` 检测丢包并插值补齐。 ### 1.1 保活(v0.2.0 新增,向后兼容) 接收端有**静默超时**:超过 `silentTimeoutMs` 没有收到任何数据帧或文本帧即断开 (`close(1001, "timeout")`)。为此: - 接收端必须在 `welcome`(以及需要变更时的 `config`)中声明 `silentTimeoutMs` 与期望的 `heartbeatIntervalMs`; - 发送端按 `heartbeatIntervalMs` 周期发送 `ping`(见 §3.6), 且 **`heartbeatIntervalMs` 必须小于 `silentTimeoutMs` 的一半**; - 发送端每帧关键点与每秒 `stats` 同样算作活动,`ping` 只是兜底, 保证「画面里没有人 / 相机暂停」时连接不会被误判掉线。 > 子协议名与二进制帧 `version` 均**不变**:本版本只**新增可选**控制字段与 > 一种新的控制类型,遵循 §5 的兼容规则;仅实现 v0.1.0 的一端仍可正常互通 > (它不发 `ping`、不读 `silentTimeoutMs`,但每帧与每秒 stats 已足够保活)。 ## 2. 二进制帧:239 字节(冻结) **小端序(little-endian)**。 ``` 偏移 长度 字段 类型 说明 0 2 magic u16 LE 固定 0x4F44(ASCII "OD") 2 1 version u8 固定 0x01 3 1 flags u8 bit0 = 镜像画面(mirrored) bit1 = 本帧含低置信度点(<0.5) bit2 = 上半身模式(仅 0..22 有效) bit3-7 保留,必须为 0 4 4 frame_id u32 LE 单调递增,从 0 开始 8 231 keypoints 33 x 7B 见下 -- 合计 239 ``` ### 单个关键点(7 字节,`i` 为 0..32) ``` 偏移 长度 字段 类型 换算 +0 2 x i16 LE x_norm = x / 32767.0 +2 2 y i16 LE y_norm = y / 32767.0 +4 2 z i16 LE z_norm = z / 32767.0 +6 1 conf u8 visibility = conf / 255.0 ``` - 点索引与坐标语义见 `skeleton-topology.md`。 - 编码:`x = clamp(round(x_norm * 32767), -32768, 32767)`, `y`、`z` 同理。 - 低置信度点(`conf < 128`)坐标无意义,接收端必须插值,不得直接使用。 - `i16` 为有符号数:`0x8000` 表示 `-32768`。 ### 参考实现(GDScript 风格伪码) ```gdscript func decode(buf: PackedByteArray) -> Dictionary: assert(buf.size() == 239) var magic := buf.decode_u16(0) assert(magic == 0x4F44 and buf.decode_u8(2) == 1) var flags := buf.decode_u8(3) var frame_id := buf.decode_u32(4) var pts := [] for i in 33: var o := 8 + i * 7 pts.append({ "x": float(buf.decode_s16(o)) / 32767.0, "y": float(buf.decode_s16(o + 2)) / 32767.0, "z": float(buf.decode_s16(o + 4)) / 32767.0, "conf": float(buf.decode_u8(o + 6)) / 255.0, }) return {"flags": flags, "frame_id": frame_id, "points": pts} ``` ## 3. 文本控制帧(JSON, UTF-8) ### 3.1 `hello` - Android -> Windows,连接后立即发送 ```json { "type": "hello", "protocol": "opendance.pose.v1", "deviceId": "a1b2c3d4e5f60718", "device": "Pixel 8", "appVersion": "0.2.0", "cameraFacing": "front", "targetFps": 30, "resolution": "640x480" } ``` - `deviceId`(v0.2.0 新增,**可选但强烈建议**):该安装实例的稳定标识 (建议 16 位十六进制,取自 `ANDROID_ID` 的哈希,不使用任何硬件序列号)。 接收端**必须按 `deviceId` 去重**:同一 `deviceId` 的新连接替换旧连接, 设备数按去重后计数(否则一次「反复重连」会被显示成多台设备)。 未提供 `deviceId` 时接收端退回按连接计数。 ### 3.2 `welcome` - Windows -> Android ```json { "type": "welcome", "protocol": "opendance.pose.v1", "sessionId": "uuid", "expectedFps": 30, "silentTimeoutMs": 15000, "heartbeatIntervalMs": 5000 } ``` - `silentTimeoutMs` / `heartbeatIntervalMs`(v0.2.0 新增):见 §1.1。 发送端若未读到这两个字段,应使用保守默认值(超时 5000、心跳 1000)。 ### 3.3 `config` - Windows -> Android,运行中调整 ```json { "type": "config", "mirror": true, "targetFps": 30, "upperBodyOnly": false, "resolution": "640x480", "silentTimeoutMs": 15000, "heartbeatIntervalMs": 5000 } ``` - `silentTimeoutMs` / `heartbeatIntervalMs` 为 v0.2.0 新增的可选字段, 接收端在运行中调整保活参数时一并下发。 ### 3.4 `stats` - Android -> Windows,每秒一次 ```json { "type": "stats", "fps": 29.6, "dropped": 3, "latencyMs": 42, "confidenceAvg": 0.93 } ``` ### 3.5 `bye` - 双向 ```json { "type": "bye", "reason": "user_stop" } ``` `bye` 表示**主动**结束:发出方在发送后立即关闭连接,接收方据此关闭且 **不得**自动重连(见 §6)。 ### 3.6 `ping` / `pong` - 保活(v0.2.0 新增) Android -> Windows: ```json { "type": "ping", "sentAtMs": 1730000000000 } ``` Windows -> Android(可选回复,用于 RTT): ```json { "type": "pong", "sentAtMs": 1730000000000, "serverAtMs": 1730000000042 } ``` - `sentAtMs` 为发送方的本地毫秒时间戳,原样回传;发送端用 `now - sentAtMs` 估算 RTT。 - 接收端收到任何一帧(二进制或文本)都算活动,`ping` 无需额外应答即可保活; `pong` 只用于诊断。 - **不要**依赖 WebSocket 协议层的 ping 帧(`Sec-WebSocket-Ping`):它由底层 库自动处理,**不会**暴露给应用层,因此不能被接收端的静默超时逻辑看到。 这也是必须定义应用层 `ping` 的原因。 ## 4. JSON 调试帧(可选,仅开发期) ```json { "type": "pose", "protocol": "opendance.pose.v1", "frameId": 1024, "timestampMs": 1730000000000, "flags": { "mirrored": false, "upperBodyOnly": false }, "points": [[0.51, 0.22, -0.03, 0.99], "...共 33 项,顺序固定 [x, y, z, conf]"] } ``` 生产链路必须使用二进制帧;JSON 帧仅用于联调,接收端可选择性支持。 ## 5. 设备去重(v0.2.0 新增) 接收端按 §3.1 的 `deviceId` 去重: | 情况 | 行为 | |--|--| | 新连接的 `deviceId` 已存在 | 关闭旧连接(`close(1000, "replaced by new connection")`),保留新连接;设备数不变 | | `deviceId` 缺失或为空 | 不做去重,按连接计数 | | 同一 `deviceId` 的旧连接已断开 | 正常替换,不影响设备数 | 这样「手机闪断后立刻重连」不会在接收端显示成两台设备,也不会让 「已连接 N 台」抖动。 ## 6. 兼容性规则 - `version` 不同的大版本接收端必须拒绝并在 `bye.reason` 中说明。 - `flags` 新增位不得改变既有位语义;旧接收端必须忽略未知位。 - 33 点顺序一旦冻结不得插入或重排;新增点只能追加新 `version`。 - 服务端 `motions.keypoint_format` 字段取值 `mediapipe33@0.1.0` 对应本契约。 ## 7. 关闭码与重连策略(v0.2.0 新增,两端必须一致) | 关闭码 / 情形 | 含义 | 发送端是否自动重连 | |--|--|--| | `1000` 正常关闭 | 任一方主动结束(含收到 `bye`) | **否** | | `1001` going away | 接收端主动关闭(服务重启、`stop()`、静默超时踢人) | **否**(见下方说明) | | `1002` 协议错误 | 子协议 / 协议版本不匹配 | **否**(重连也不会成功) | | 连接失败 / 超时 / `1006` 异常断开 | 网络抖动、对端进程退出、Wi-Fi 切换 | **是** | | 客户端主动 `disconnect()` | 用户点「结束推流」 | **否** | - 静默超时由接收端发 `close(1001, "timeout")`;正常情况下发送端按 §1.1 保活, **不应**触发它。若仍频繁触发,属于保活参数或实现缺陷,需按 §7 排查, 而不是靠重连掩盖。 - 接收端因「同一 `deviceId` 被新连接替换」关闭旧连接时用 `1000`, 旧连接不重连。 ### 7.1 退避参数(冻结) ``` 第 n 次重连等待 = min(500ms * 2^(n-1), 15000ms) * (1 ± 20% 抖动) 即 0.5s → 1s → 2s → 4s → 8s → 15s(封顶),每次叠加 ±20% 抖动 连接成功后计数清零 ``` ## 8. 连接状态机(v0.2.0 新增,两端同名,便于对照日志) ``` idle → resolving → connecting → handshaking → streaming → degraded ↑ │ └──────── reconnecting ←───────────┘ stopped ← 主动停止 / 1000 / 1001 / 1002(终态,不再重连) ``` | 状态 | 含义 | 进入条件(发送端) | |--|--|--| | `idle` | 尚未开始连接 | 初始状态 | | `resolving` | 解析主机地址(DNS / 局域网发现) | `connect()` 且主机名非 IP 字面量 | | `connecting` | TCP + WebSocket 升级中 | 已发起请求 | | `handshaking` | 已连接,等待 `welcome` | `onOpen`,已发送 `hello` | | `streaming` | 正常推流 | 收到 `welcome` 或已成功发送首帧 | | `degraded` | 仍连接但异常(丢弃帧增长 / 发送失败) | `send` 失败或持续丢帧 | | `reconnecting` | 退避等待中 | 非主动断开 | | `stopped` | 已停止(终态) | 主动停止 / 1000 / 1001 / 1002 | 接收端(Windows)在日志与状态面板中使用同名状态描述每个客户端连接。