Files
m20_gamepad/AGENTS.md
T
hexone2086 18b326d416 feat: 集成 rtsp-client-android 零缓冲 RTSP 渲染,替换 ExoPlayer
- 新增本地模块 rtspclientlibrary/(拷贝自 rtsp-client-android v5.6.5 library-client-rtsp)
- RtspVideoPlayer.kt 重写:RtspSurfaceView + AndroidView,VideoCodec 枚举映射
  DecoderType.HARDWARE/SOFTWARE;指数退避自动重连(1s→30s,URL/codec 变化重置)
- 延迟统计:每 2s 轮询 statistics 输出 videoDecoderLatencyMsec/networkLatencyMsec
- 本地修复两个已知坑:CSD 参数集顺序 sps+pps+vps -> vps+sps+pps(H.265 起播黑屏根因),
  codecType 硬编码 H264 -> 按实际 MIME 标记(H.265 宽高解析)
- FrameQueue 60 -> 20 压低积压延迟;剔除 camera 依赖(删除 bitmap 渲染路径)
- app 移除 media3-exoplayer-rtsp/ui 依赖(media3-exoplayer 由库模块提供)
2026-08-13 09:24:28 +08:00

269 lines
18 KiB
Markdown
Raw 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.
# AGENTS.md — m20_gamepad
## 项目概述
山猫 M20 四足机器人手柄控制 Android App。功能:
- 通过 UDP + JSON 协议(见 `proto.md`)控制机器人运动
- 使用虚拟摇杆(集成自 `controlwear/virtual-joystick-android`
- 拉取 RTSP 视频流作为底层显示
- 设置界面:配置机器人 IP/端口、RTSP 地址、视频编码器选项,持久化保存
---
## 架构与包结构
```
com.example.m20_gamepad/
├── MainActivity.kt # 入口 Activity,管理导航(主界面 ↔ 设置界面)
├── M20App.kt # Application 子类,初始化 DataStore
├── navigation/
│ └── NavRoutes.kt # 导航路由定义
├── ui/
│ ├── theme/ # Compose 主题(已有)
│ ├── MainScreen.kt # 主界面:视频层 + 摇杆覆盖层
│ ├── SettingsScreen.kt # 设置界面:配置机器人 IP/端口、RTSP 地址、编码器
│ ├── JoystickSurface.kt # Compose 对 JoystickView 的包装
│ └── components/ # 其他 UI 组件(状态指示器、按钮等)
├── network/
│ ├── ProtocolClient.kt # UDP 客户端:收发、心跳、线程管理
│ ├── PacketEncoder.kt # 16 字节 Header 封包 + JSON 序列化
│ ├── PacketDecoder.kt # 16 字节 Header 解包 + JSON 反序列化
│ └── models/
│ ├── Header.kt # 16 字节报文头(小端 LE)
│ ├── ApduMessage.kt # 完整 APDU = Header + ASDU(JSON)
│ ├── ControlCommands.kt # 上行指令结构(心跳、轴指令、步态切换等)
│ └── StatusReports.kt # 下行状态结构(基础状态、运控状态、设备状态等)
├── service/
│ ├── RobotConnection.kt # 连接状态机管理(连接/订阅/心跳/断线判定)
│ └── JoystickController.kt # 摇杆输入 → 协议指令映射,含 HandStyle 多手型支持
├── video/
│ └── RtspVideoPlayer.kt # RTSP 视频流播放(rtspclientlibrary + Composable 包装)
├── data/
│ ├── SettingsRepository.kt # 设置项读写(DataStore 封装)
│ └── AppSettings.kt # 配置数据类定义 + 默认值
```
---
## 协议要点(详见 proto.md
- **传输**: UDP, 目标 `10.21.31.103:30000`
- **报文**: 16 字节 Header(小端)+ JSON ASDU
- **Header 结构**: `Sync(0xEB 91 EB 90) | Length(u16 LE) | MsgId(u16 LE) | Format(0x01) | Reserved(7字节)`
- **ASDU 固定外层**: `{"PatrolDevice": {"Type": int, "Command": int, "Time": "YYYY-MM-DD HH:mm:ss", "Items": {}}}`
- **关键指令**:
- 心跳:Type=100, Cmd=100, ≥1 Hz
- 轴指令:Type=2, Cmd=21, Items: {X,Y,Z,Roll,Pitch,Yaw}, [-1,1], ≥20 Hz
- 速度指令:Type=2, Cmd=25, Items: {X,Y,Z,Roll,Pitch,Yaw}, 物理单位,≥10 Hz
- 运动状态转换:Type=2, Cmd=22, Items: {MotionParam}
- 步态切换:Type=2, Cmd=23, Items: {GaitParam}
- 使用模式切换:Type=1101, Cmd=5, Items: {Mode}
- **状态上报**: 上行响应含 ErrorCode/ErrorMessage; 主动订阅制(Type=1002, Cmd=3/4/5/6
---
## 虚拟摇杆集成
使用 Compose 原生摇杆库 `com.erz.joysticklibrary`(来自 [erz05/JoyStick](https://github.com/erz05/JoyStick)),以本地模块方式引入(`joysticklibrary/`)。
### 关键 API
`Joystick` Compose 可组合项:
```kotlin
Joystick(
type = JoystickType.EIGHT_AXIS,
onMove = { angle: Double, power: Double, direction: JoystickDirection ->
// angle: 弧度,atan2 范围 [-PI, PI]
// power: 0.0 ~ 100.0
// direction: 方向枚举
},
onTap = { /* 单击 */ },
onDoubleTap = { /* 双击 */ }
)
```
### 角度 → 轴映射
摇杆角度/强度映射到机器人 X/Y/Yaw 轴指令:
- 角度 0° = 正右 → Yaw 负方向
- 角度 90° = 正上 → X 正方向(前进)
- 角度 180° = 正左 → Yaw 正方向
- 角度 270° = 正下 → X 负方向(后退)
- 强度百分比 → [-1, 1] 比例值
**标准手型(STANDARD**
```
左摇杆: X = sin(angle) * power/100, Y = cos(angle) * power/100
右摇杆: Yaw = cos(angle) * power/100
```
**单摇杆左(SINGLE_LEFT**:左摇杆控制 X(前后) + Yaw(转向,类车操控),右摇杆仅控制 Y(横移)
**单摇杆右(SINGLE_RIGHT**:左摇杆仅控制 Y(横移),右摇杆控制 X(前后) + Yaw(转向,类车操控)
**坦克式(TANK**:两摇杆 up/down 差速计算 X/Yaw,无横移 Y
```
左履带 = sin(leftAngle) * leftScale
右履带 = sin(rightAngle) * rightScale
X = (左履带 + 右履带) / 2
Yaw = (左履带 - 右履带) / 2
```
---
## RTSP 视频流
视频层作为 UI 最底层背景,摇杆等控件覆盖其上(半透明)。
### 当前实现(已集成 rtsp-client-android
- 本地模块 **`rtspclientlibrary/`**(拷贝自 rtsp-client-android v5.6.5 的 `library-client-rtsp`),实现见 `video/RtspVideoPlayer.kt`Compose 包装 `RtspSurfaceView` + AndroidView
- 架构:RTSP **仅 TCP interleaved**(无 UDP)→ RTP 解析(H.264/H.265)→ `FrameQueue(20)` → MediaCodec 直解 → 立即渲染。**无播放时钟 pacing,帧到即解即渲染** = 零缓冲低延迟
- `VideoCodec` 枚举(AUTO/FORCE_HW/FORCE_SW_H264/FORCE_SW_H265)→ `DecoderType.HARDWARE/SOFTWARE`(库仅区分硬/软解,FORCE_SW_* 不支持按 MIME 分别指定)
- 自动重连:指数退避 1s→30s(URL/codec 变化重置);`RtspSurfaceView` 无内置重连,重连逻辑在 Composable 层
- 延迟统计:轮询 `rtspView.statistics` 每 2s 输出 `videoDecoderLatencyMsec`/`networkLatencyMsec` 到 Logcattag `RtspVideoPlayer`
- 本地修改(相对上游):
- **CSD 参数集顺序修正**`vps+sps+pps`(上游为 `sps+pps+vps`H.265 起播黑屏根因)
- **codecType 按实际 MIME 标记**(上游硬编码 H264,H.265 无法解析宽高)
- **`FrameQueue(60)``FrameQueue(20)`**(压低积压延迟)
- 剔除 camera 依赖(删除 `RtspImageView`/bitmap 渲染路径,仅保留 SurfaceView 路径)
### 延迟问题与决策(2026-08 调研结论)
- 现象:RTSP/TCP 延迟高、缓冲大;H.265 解码不高效
- **根因**:不是解码器也不是 TCP 协议本身,而是 ExoPlayer 播放器架构(缓冲水位 + presentationTime pacing 渲染同步)为"平滑播放"设计,非实时
- **决策**:弃用 ExoPlayer 的视频渲染路径,集成第三方库 **rtsp-client-android**alexeyvasilyevv5.6.5,纯 Kotlin 零缓冲直出架构)
- 上游源码备份:`/tmp/rtsp-client-android`(工作区外临时目录);本地拷贝见 `rtspclientlibrary/`
### rtsp-client-android 关键技术要点(集成时必读)
- 架构:RTSP **仅 TCP interleaved**(无 UDP)→ RTP 解析(H.264/H.265)→ `FrameQueue` → MediaCodec 直解 → 立即渲染。**无播放时钟 pacing,帧到即解即渲染** = 零缓冲低延迟(宣传 20ms 解码延迟)
- H.265`RtpH265Parser` 支持 single NAL + FU 分片重组;**AP 聚合包未实现**(有 TODO
- 硬解优先,`MediaCodecUtils.getLowLatencyDecoder` 挑专用低延迟解码器,失败自动回退软解;`MediaCodecHelper.setDecoderLowLatencyOptions``KEY_LOW_LATENCY`
- 实验性 SPS 改写(仅 H.264):`maxDecFrameBuffering=1, numReorderFrames=0`,可砍半部分硬解器延迟(已在 Composable 层开启)
- **内置延迟统计**`RtspSurfaceView.statistics``videoDecoderLatencyMsec`(解码渲染)+ `networkLatencyMsec`(网络),无需自写埋点
- **已知坑(均已本地修复)**`RtspProcessor` 拼 CSD 顺序为 `sps+pps+vps`H.265 标准应为 VPS→SPS→PPS,本地已改);`codecType` 硬编码 H264(本地已按 MIME 修正)
- 依赖:`androidx.media3:media3-exoplayer`(仅用工具类 NalUnitUtil/MediaCodecUtil)、`org.jcodec`SPS 改写);已剔除 `androidx.camera`(YUV→BMP 路径,本项目用 SurfaceView 直出无需)
- 库活跃:2026-06 更新(v5.6.5),全库约 6300 行,无 JNI/无预编译 so,全量可改
---
## 设置界面
### 配置项
| 配置项 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `robot_host` | String | `10.21.31.103` | 机器人 IP 地址 |
| `robot_port` | Int | `30000` | 机器人 UDP 端口 |
| `rtsp_url` | String | `rtsp://10.21.31.103:554/stream` | RTSP 视频流地址 |
| `video_codec` | Enum | `AUTO` | `AUTO` / `FORCE_HW` / `FORCE_SW_H264` / `FORCE_SW_H265` |
| `video_resize_mode` | Enum | `ZOOM` | `FIT` / `ZOOM` / `FILL` |
| `hand_style` | Enum | `STANDARD` | `STANDARD` / `SINGLE_LEFT` / `SINGLE_RIGHT` / `TANK` |
### 持久化
使用 Jetpack DataStore Preferences 存储,通过 `SettingsRepository` 封装:
- 写入后立即生效,下次启动自动恢复
- 暴露 `Flow<AppSettings>` 供 UI 层观察
- 修改设置后断开当前连接,下次连接时使用新参数
### 导航
主界面设设置按钮(齿轮图标),点击跳转设置界面。设置界面返回主界面时,若连接参数变更则自动断开旧连接。
---
## 构建与运行
> **注意**:项目源码位于 Windows 目录下,通过 WSL 访问和编辑。WSL 环境中没有 Android SDK / Gradle,无法直接运行构建命令。如需构建或安装,请在 Windows 端的 Android Studio 或终端中执行。
```bash
# 在项目根目录(Windows 端)
gradlew assembleDebug
# 安装到设备(Windows 端)
gradlew installDebug
```
### 依赖
- 虚拟摇杆库:本地模块 `joysticklibrary/`Compose 原生,Kotlin DSL
- JSON 序列化:`kotlinx-serialization-json:1.7.3`
- RTSP 播放:本地模块 `rtspclientlibrary/`rtsp-client-android,内部依赖 `media3-exoplayer:1.5.1` + `jcodec:0.2.5`
- 持久化:`androidx.datastore:datastore-preferences:1.1.3`
- 导航:`androidx.navigation:navigation-compose:2.8.6`
---
## 编码规范
- **语言**: Kotlin,使用 Jetpack Compose 构建 UI
- **协程**: 网络 I/O 使用 `kotlinx.coroutines`,避免裸线程
- **UDP**: 使用 `java.net.DatagramSocket`,绑定本地随机端口
- **状态管理**: 使用 `StateFlow` 暴露连接状态、机器人状态
- **状态上报合并策略**: `handleStatusReport``_latestStatus` 采用合并(`prev.copy`)而非替换,避免 4 个独立订阅(Basic/Error/Motion/Device)的局部报告覆盖整个状态导致 UI 闪烁。各分项(`batteryStatus``motionStatus` 等)同时维护独立 StateFlow,供 UI 直接订阅
- **生命周期**: 网络连接生命周期绑定到 Activity/Service,前台时活跃
- **MsgId**: 从 0 递增 u16,循环回绕
- **断线判定**: 收到首个有效数据包前为 `CONNECTING`5 秒无数据 → `TIMEOUT`);`CONNECTED` 后 3 秒无任何 UDP 包 → `TIMEOUT`
---
## 已完成 / 待办
## 已完成 / 待办
见 [TODO.md](./TODO.md)
- [x] 协议层:ApduMessage.ktHeader + ASDU 封装)
- [x] 协议层:ControlCommands.kt(全部指令构建函数 + 常量)
- [x] 协议层:StatusReports.kt(全部状态上报数据类 + JSON 解析器)
- [x] 协议层:PacketEncoder.ktMsgId 管理 + APDU 编码)
- [x] 协议层:PacketDecoder.ktAPDU 解码 + 缓冲区搜索)
- [x] 网络层:ProtocolClient.ktUDP socket、心跳 1Hz、指令发送通道、接收循环、断线检测 3s、订阅触发)
- [x] 摇杆映射:JoystickController.ktangle/power -> X/Y/Yaw
- [x] 状态管理:MainViewModel.ktProtocolClient 生命周期、20Hz 轴指令循环、状态收集)
- [x] 视频播放:RtspVideoPlayer.ktMedia3 ExoPlayer RTSPComposable 包装)
- [x] 主界面:MainScreen.kt(视频背景 + 双摇杆 + 状态栏 + 控制按钮)
- [x] 入口:MainActivity.kt + INTERNET 权限
- [x] 持久化层:AppSettings.kt(配置数据类 + 默认值 + 校验)、SettingsRepository.ktDataStore Preferences 封装,`settings: Flow<AppSettings>` + 单项 setter + `setAll()`)、M20App.ktApplication 子类,进程级 `settingsRepository` 单例)
- [x] 设置界面 UISettingsScreen.kt(主机/端口/RTSP 输入 + 编码器 ExposedDropdownMenu,保存按钮调用 `setAll` 后回调 `onSaved`
- [x] 导航框架:NavRoutes.kt`MAIN`/`SETTINGS` 路由常量)、MainActivity.kt 改写为 `AppNavGraph()`NavHoststartDestination=MAIN,主界面齿轮按钮跳转设置,返回/保存后 popBackStack 到 MAIN
- [x] 连接参数从设置读取:MainViewModel 构造注入 `SettingsRepository``settings: StateFlow<AppSettings>``stateIn(Eagerly)`),`connect()` 读取 `settings.value` 的 host/port`init {}` 监听 host/port 变化(`distinctUntilChanged`)后断开当前连接,下次连接使用新参数(符合“修改设置后断开当前连接”约束)。移除 DEFAULT_HOST/DEFAULT_PORT/DEFAULT_RTSP_URL 硬编码
- [x] 连接状态机:RobotConnection.kt(连接状态流转校验 `isValidConnectionTransition`,运动状态机 `requestMotionTransition` 按 proto.md 2.3 正向流程 `空闲->站立->RL控制` 校验;`canSwitchGait`/`canSendAxisCommand` 仅 RL 控制下放行;`MotionTransitionResult` 枚举反馈)。MainViewModel 经状态机校验后下发指令,轴指令循环非 RL 控制时发零速度
- [x] 状态上报 UI 展示:StatusPanel.kt(异常列表 + 错误码映射 ErrorCodes.kt、运控状态 Roll/Pitch/Yaw/速度/高度、设备温度电机/驱动器最高温、电池左右电压/电量/温度),顶部状态栏 Info 按钮触发底部弹出面板;指令失败/状态机拒绝通过 Snackbar 反馈
- [x] 更多控制按钮:Mode(常规/导航/辅助)、步态(基础/楼梯/平地敏捷/楼梯敏捷)、照明(前/后灯开/关)、充电(开始/结束)、休眠(休眠/唤醒)。底部控制区可纵向滚动,点击"更多"展开全部控制选项
- [x] RTSP 解码器策略实现:`VideoCodec` 枚举含 `AUTO`/`FORCE_HW`/`FORCE_SW_H264`/`FORCE_SW_H265``toMediaCodecSelector()` 映射到对应 `MediaCodecSelector``DefaultLoadControl` 最小缓冲(100ms 起播,总缓冲<500ms,无回退缓存,时间优先于大小阈值);`FORCE_HW` 模式对所有 MIME 仅保留硬件加速解码器
- [x] 摇杆尺寸增大:左右摇杆从 140dp 增大到 180dp,提升操控精度
- [x] 视频缩放模式:`VideoResizeMode` 枚举(FIT/ZOOM/FILL),设置界面新增"视频缩放模式"下拉选项,持久化保存,实时生效
- [x] 状态机修复:趴下状态下允许切换站立状态(机器人站立后自动进入 RL 控制,无需 App 下发)
- [x] UI 布局:增加 bg1.png 作为底图,无视频流时显示(视频流播放时覆盖底图)
- [x] UI 布局:开关灯合并为前灯/后灯两个按键,通过颜色(琥珀色=开/暗色=关)表示置位状态,配合机器人回报状态同步
- [x] 状态栏:ICMP 延迟测量 + WiFi 信号强度图标显示,放置于连接状态文字右侧,延迟独立于连接生命周期
- [x] UI 布局:StatusBar 背景延伸到屏幕顶端,视觉吸附优化(Box + Row 双层结构,背景填满状态栏区域)
- [x] UI 布局:连接按钮合并到顶部 StatusBar 的 ConnectionIndicator,删除底部独立连接按钮
- [x] UI 布局:ConnectionIndicator 改为按钮形态(Surface + 圆角边框),仅指示器区域可点击切换连接
- [x] UI 布局:隐藏系统状态栏(WindowInsetsControllerCompat),压缩自定义 StatusBar 高度,扩展可视区域
- [x] UI 布局:功能按钮均布在四周——左上角模式选择 + 步态切换 + 起立/趴下,右上角灯光 + 休眠 + 充电,充分利用屏幕空间,优化单手操作体验
- [x] UI 布局:起立/趴下合并为切换按钮(琥珀色=站立时显示"趴下",暗色=非站立时显示"起立"),移至左上角 StatusBar 下方
- [x] UI 布局:模式选择器(常规/导航/辅助,3 个互斥按钮,无缝隙,底色显示当前模式)移至左上角,位于起立/趴上方
- [x] UI 布局:步态切换(基础/楼梯/平地敏捷/楼梯敏捷,4 个互斥按钮,无缝隙)移至模式选择器下方,始终显示,仅普通模式+RL 控制时可点击
- [x] UI 布局:照明/休眠/充电移至右上角,紧凑排列(前灯后灯无缝隙一行,休眠/唤醒切换,充电/充电中切换)
- [x] UI 布局:底部控制区移除"更多"展开区域,仅保留左右摇杆
- [x] UI 布局:所有功能按钮统一使用平行四边形形状(左侧按钮斜边左下→右上,右侧按钮斜边左上→右下)
- [x] UI 布局:所有控制按钮在未连接时灰色不可点击
- [x] 连接状态准确性修复:`ProtocolClient` 收到首个有效数据包后才标记 `CONNECTED`,不再 socket 创建后立即标记;`CONNECTING` 状态 5 秒无响应自动超时回退 `TIMEOUT`
- [x] 连接按钮交互优化:`CONNECTING` 状态下点击连接按钮仅断开回到 `DISCONNECTED`,不再自动重连,由用户手动再次点击发起重试
- [x] 状态机补充:`RobotConnection` 允许 `CONNECTING → TIMEOUT` 合法转换
- [x] 心跳启动时机修复:`ProtocolClient.connect()` 立即启动心跳线程,不再等待首次收到数据包后才启动;心跳在 `CONNECTING``CONNECTED` 状态下均正常运行
- [x] 软急停滑块:右上角新增平行四边形水平滑块,滑到右侧触发软急停(`MOTION_DAMPING`),跟随机器人状态上报自动归位
- [x] 状态上报闪烁修复:`_latestStatus` 改用合并策略(`prev.copy`),保留上次报告的非 null 字段,避免 4 个独立订阅交替覆盖导致状态栏/步态按钮闪烁
- [x] 电量显示稳定性优化:`StatusBar` 电量改用独立 `batteryStatus` StateFlow,不依赖 `status?.batteryStatus`
- [x] 休眠按钮状态约束:仅非站立状态(空闲/趴下/阻尼等)可切换休眠,站立/RL 控制时禁用
- [x] RTSP 自动重连:`RtspVideoPlayer` 播放失败后自动重试,指数退避(初始 1s,最大 30s,倍率 2),URL/解码器变化时重置退避状态;覆盖层显示重试次数和倒计时
- [x] 订阅请求清理:删除 `ProtocolClient.sendSubscriptionRequests()` 方法及 `connect()` 中的注释调用;同步删除 `ControlCommands.subscribeStatus()``SUB_*` 常量,彻底清除死代码
- [x] 摇杆手型支持:`HandStyle` 枚举(标准/单摇杆左/单摇杆右/坦克式),映射到 `JoystickController.dualStickToAxisCommand()`,设置界面持久化存储,实时生效
### 待办
见 [TODO.md](./TODO.md)