Files
zijunchiang 7ebbaef631 feat(net,pose): 连接状态机与保活对齐 + MediaPipe 崩溃修复(T-AND-010/011/012)
契约 websocket-keypoints 0.1.0 -> 0.2.0(向后兼容,子协议名与帧 version 不变;
先在 opendance-web 修改并提升版本,再同步三端快照)。

T-AND-011:连接状态异常、频繁重连(查实 3 个根因)
1. 保活用错了层:Windows 端有 5s 静默超时(只看应用层收到的帧),而本端只有
   OkHttp 的协议层 ping —— 它由底层库自动应答、**不暴露给应用层**,根本不能续命。
   画面里没人时 5s 必被踢、再重连,循环往复。
   - 契约新增 §1.1(welcome/config 声明 silentTimeoutMs 与 heartbeatIntervalMs,
     且心跳必须 < 静默超时的一半)与 §3.6(应用层 ping/pong);
   - Windows 端超时 5s -> 15s、期望心跳 5s,并在 welcome/config 中下发;
   - 本端按声明发 ping,连续 3 个周期无 pong 才判定链路已死并主动重连。
2. 不该重连时也重连:契约新增 §7 关闭码策略,1000/1001/1002 与主动停止一律不重连。
3. frame_id 恒为 0:PoseDetector.detect() 算了 safeTimestamp 却忘了写回
   lastTimestampMs,接收端的丢包检测因此完全失效。已修。

其他对齐:
- 契约新增 §8 连接状态机 idle -> resolving -> connecting -> handshaking ->
  streaming -> degraded -> reconnecting -> stopped,两端同名便于对照日志;
  resolving 只对主机名生效,局域网直连 IP 直接进 connecting;
- 退避与抖动按 §7.1 冻结:0.5s -> 1 -> 2 -> 4 -> 8 -> 15s 封顶 + ±20% 抖动;
- 契约新增 §5 设备去重:hello 携带稳定 deviceId(ANDROID_ID 的 SHA-256 前 16 位,
  不含硬件序列号),Windows 端据此去重、旧连接以 1000 关闭;
- 新增 util/ConnectionLog(环形缓冲,最近 500 条:状态迁移 / close code /
  子协议协商 / 首帧时间 / 保活参数),运行页可一键导出(FileProvider)。

T-AND-012:MediaPipe 初始化崩溃(ExceptionInInitializerError: null)
- 诊断:新增 pose/PoseDiagnostics 展开 cause 链取真因(通常 UnsatisfiedLinkError)、
  区分 native 加载失败、比对设备 ABI 与 APK 内 ABI 并给出可执行结论;
- native 解压:packaging { jniLibs { useLegacyPackaging = true } }(AGP 8 release
  默认 extractNativeLibs=false,MediaPipe JNI 对它不兼容);
- R8:补 -keepclasseswithmembernames 的 native 方法保留规则,并 -printseeds 输出
  seeds 供发布前核对(实测保留 20169 条 MediaPipe 条目);
- 构建期新增 verifyReleaseAbi:release ABI 必须恰为 arm64-v8a/armeabi-v7a、
  每个 ABI 下必须有 MediaPipe native 库、模型资源必须存在;
- 未换库:诊断指向集成配置而非 MediaPipe 本身,保留 33 点契约零成本。

T-AND-010:版本号展示与单一来源
- 版本唯一来源 versionName(= project.yaml 的 version);versionCode 按
  10000*主+100*次+修订 递增(0.2.0 -> 200);
- 契约版本改为构建期解析 contracts/VERSION 后注入 BuildConfig(六项各自独立
  递增,原先的单个 CONTRACT_VERSION 常量已失真);界面不再手写任何版本号;
- 新增 checkReleaseConsistency 并挂到 preBuild,三者不一致直接 BUILD FAILED。

README/DSH/THIRD_PARTY/tasks 同步更新(含「导出连接日志」「保活参数」
「最近断开原因」等新增可观察项与排查表)。

测试:56 项全部通过(新增 ReconnectPolicyTest 11 项、PoseDiagnosticsTest 12 项);
build/verify.sh 全通过(单元测试 -> 构建 -> 签名/ABI/native/seeds/dex 校验)
2026-09-17 01:10:06 +08:00

260 lines
9.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# contracts/websocket-keypoints.md - 关键点传输契约 v0.2.0
Android 辅助 App(发送端)-> Windows 客户端(接收端)。
服务端不参与实时链路,但 T-WEB-006 预处理产出的序列必须与此结构同构。
## 1. 端点
```
ws://<windows-host>: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)在日志与状态面板中使用同名状态描述每个客户端连接。