Files
m20_gamepad/AGENTS.md
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

18 KiB
Raw Permalink Blame History

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),以本地模块方式引入(joysticklibrary/)。

关键 API

Joystick Compose 可组合项:

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.ktCompose 包装 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+vpsH.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-androidalexeyvasilyevv5.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.265RtpH265Parser 支持 single NAL + FU 分片重组;AP 聚合包未实现(有 TODO
  • 硬解优先,MediaCodecUtils.getLowLatencyDecoder 挑专用低延迟解码器,失败自动回退软解;MediaCodecHelper.setDecoderLowLatencyOptionsKEY_LOW_LATENCY
  • 实验性 SPS 改写(仅 H.264):maxDecFrameBuffering=1, numReorderFrames=0,可砍半部分硬解器延迟(已在 Composable 层开启)
  • 内置延迟统计RtspSurfaceView.statisticsvideoDecoderLatencyMsec(解码渲染)+ networkLatencyMsec(网络),无需自写埋点
  • 已知坑(均已本地修复)RtspProcessor 拼 CSD 顺序为 sps+pps+vpsH.265 标准应为 VPS→SPS→PPS,本地已改);codecType 硬编码 H264(本地已按 MIME 修正)
  • 依赖:androidx.media3:media3-exoplayer(仅用工具类 NalUnitUtil/MediaCodecUtil)、org.jcodecSPS 改写);已剔除 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 或终端中执行。

# 在项目根目录(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 闪烁。各分项(batteryStatusmotionStatus 等)同时维护独立 StateFlow,供 UI 直接订阅
  • 生命周期: 网络连接生命周期绑定到 Activity/Service,前台时活跃
  • MsgId: 从 0 递增 u16,循环回绕
  • 断线判定: 收到首个有效数据包前为 CONNECTING5 秒无数据 → TIMEOUT);CONNECTED 后 3 秒无任何 UDP 包 → TIMEOUT

已完成 / 待办

已完成 / 待办

TODO.md

  • 协议层:ApduMessage.ktHeader + ASDU 封装)
  • 协议层:ControlCommands.kt(全部指令构建函数 + 常量)
  • 协议层:StatusReports.kt(全部状态上报数据类 + JSON 解析器)
  • 协议层:PacketEncoder.ktMsgId 管理 + APDU 编码)
  • 协议层:PacketDecoder.ktAPDU 解码 + 缓冲区搜索)
  • 网络层:ProtocolClient.ktUDP socket、心跳 1Hz、指令发送通道、接收循环、断线检测 3s、订阅触发)
  • 摇杆映射:JoystickController.ktangle/power -> X/Y/Yaw
  • 状态管理:MainViewModel.ktProtocolClient 生命周期、20Hz 轴指令循环、状态收集)
  • 视频播放:RtspVideoPlayer.ktMedia3 ExoPlayer RTSPComposable 包装)
  • 主界面:MainScreen.kt(视频背景 + 双摇杆 + 状态栏 + 控制按钮)
  • 入口:MainActivity.kt + INTERNET 权限
  • 持久化层:AppSettings.kt(配置数据类 + 默认值 + 校验)、SettingsRepository.ktDataStore Preferences 封装,settings: Flow<AppSettings> + 单项 setter + setAll())、M20App.ktApplication 子类,进程级 settingsRepository 单例)
  • 设置界面 UISettingsScreen.kt(主机/端口/RTSP 输入 + 编码器 ExposedDropdownMenu,保存按钮调用 setAll 后回调 onSaved
  • 导航框架:NavRoutes.ktMAIN/SETTINGS 路由常量)、MainActivity.kt 改写为 AppNavGraph()NavHoststartDestination=MAIN,主界面齿轮按钮跳转设置,返回/保存后 popBackStack 到 MAIN
  • 连接参数从设置读取:MainViewModel 构造注入 SettingsRepositorysettings: StateFlow<AppSettings>stateIn(Eagerly)),connect() 读取 settings.value 的 host/portinit {} 监听 host/port 变化(distinctUntilChanged)后断开当前连接,下次连接使用新参数(符合“修改设置后断开当前连接”约束)。移除 DEFAULT_HOST/DEFAULT_PORT/DEFAULT_RTSP_URL 硬编码
  • 连接状态机:RobotConnection.kt(连接状态流转校验 isValidConnectionTransition,运动状态机 requestMotionTransition 按 proto.md 2.3 正向流程 空闲->站立->RL控制 校验;canSwitchGait/canSendAxisCommand 仅 RL 控制下放行;MotionTransitionResult 枚举反馈)。MainViewModel 经状态机校验后下发指令,轴指令循环非 RL 控制时发零速度
  • 状态上报 UI 展示:StatusPanel.kt(异常列表 + 错误码映射 ErrorCodes.kt、运控状态 Roll/Pitch/Yaw/速度/高度、设备温度电机/驱动器最高温、电池左右电压/电量/温度),顶部状态栏 Info 按钮触发底部弹出面板;指令失败/状态机拒绝通过 Snackbar 反馈
  • 更多控制按钮:Mode(常规/导航/辅助)、步态(基础/楼梯/平地敏捷/楼梯敏捷)、照明(前/后灯开/关)、充电(开始/结束)、休眠(休眠/唤醒)。底部控制区可纵向滚动,点击"更多"展开全部控制选项
  • RTSP 解码器策略实现:VideoCodec 枚举含 AUTO/FORCE_HW/FORCE_SW_H264/FORCE_SW_H265toMediaCodecSelector() 映射到对应 MediaCodecSelectorDefaultLoadControl 最小缓冲(100ms 起播,总缓冲<500ms,无回退缓存,时间优先于大小阈值);FORCE_HW 模式对所有 MIME 仅保留硬件加速解码器
  • 摇杆尺寸增大:左右摇杆从 140dp 增大到 180dp,提升操控精度
  • 视频缩放模式:VideoResizeMode 枚举(FIT/ZOOM/FILL),设置界面新增"视频缩放模式"下拉选项,持久化保存,实时生效
  • 状态机修复:趴下状态下允许切换站立状态(机器人站立后自动进入 RL 控制,无需 App 下发)
  • UI 布局:增加 bg1.png 作为底图,无视频流时显示(视频流播放时覆盖底图)
  • UI 布局:开关灯合并为前灯/后灯两个按键,通过颜色(琥珀色=开/暗色=关)表示置位状态,配合机器人回报状态同步
  • 状态栏:ICMP 延迟测量 + WiFi 信号强度图标显示,放置于连接状态文字右侧,延迟独立于连接生命周期
  • UI 布局:StatusBar 背景延伸到屏幕顶端,视觉吸附优化(Box + Row 双层结构,背景填满状态栏区域)
  • UI 布局:连接按钮合并到顶部 StatusBar 的 ConnectionIndicator,删除底部独立连接按钮
  • UI 布局:ConnectionIndicator 改为按钮形态(Surface + 圆角边框),仅指示器区域可点击切换连接
  • UI 布局:隐藏系统状态栏(WindowInsetsControllerCompat),压缩自定义 StatusBar 高度,扩展可视区域
  • UI 布局:功能按钮均布在四周——左上角模式选择 + 步态切换 + 起立/趴下,右上角灯光 + 休眠 + 充电,充分利用屏幕空间,优化单手操作体验
  • UI 布局:起立/趴下合并为切换按钮(琥珀色=站立时显示"趴下",暗色=非站立时显示"起立"),移至左上角 StatusBar 下方
  • UI 布局:模式选择器(常规/导航/辅助,3 个互斥按钮,无缝隙,底色显示当前模式)移至左上角,位于起立/趴上方
  • UI 布局:步态切换(基础/楼梯/平地敏捷/楼梯敏捷,4 个互斥按钮,无缝隙)移至模式选择器下方,始终显示,仅普通模式+RL 控制时可点击
  • UI 布局:照明/休眠/充电移至右上角,紧凑排列(前灯后灯无缝隙一行,休眠/唤醒切换,充电/充电中切换)
  • UI 布局:底部控制区移除"更多"展开区域,仅保留左右摇杆
  • UI 布局:所有功能按钮统一使用平行四边形形状(左侧按钮斜边左下→右上,右侧按钮斜边左上→右下)
  • UI 布局:所有控制按钮在未连接时灰色不可点击
  • 连接状态准确性修复:ProtocolClient 收到首个有效数据包后才标记 CONNECTED,不再 socket 创建后立即标记;CONNECTING 状态 5 秒无响应自动超时回退 TIMEOUT
  • 连接按钮交互优化:CONNECTING 状态下点击连接按钮仅断开回到 DISCONNECTED,不再自动重连,由用户手动再次点击发起重试
  • 状态机补充:RobotConnection 允许 CONNECTING → TIMEOUT 合法转换
  • 心跳启动时机修复:ProtocolClient.connect() 立即启动心跳线程,不再等待首次收到数据包后才启动;心跳在 CONNECTINGCONNECTED 状态下均正常运行
  • 软急停滑块:右上角新增平行四边形水平滑块,滑到右侧触发软急停(MOTION_DAMPING),跟随机器人状态上报自动归位
  • 状态上报闪烁修复:_latestStatus 改用合并策略(prev.copy),保留上次报告的非 null 字段,避免 4 个独立订阅交替覆盖导致状态栏/步态按钮闪烁
  • 电量显示稳定性优化:StatusBar 电量改用独立 batteryStatus StateFlow,不依赖 status?.batteryStatus
  • 休眠按钮状态约束:仅非站立状态(空闲/趴下/阻尼等)可切换休眠,站立/RL 控制时禁用
  • RTSP 自动重连:RtspVideoPlayer 播放失败后自动重试,指数退避(初始 1s,最大 30s,倍率 2),URL/解码器变化时重置退避状态;覆盖层显示重试次数和倒计时
  • 订阅请求清理:删除 ProtocolClient.sendSubscriptionRequests() 方法及 connect() 中的注释调用;同步删除 ControlCommands.subscribeStatus()SUB_* 常量,彻底清除死代码
  • 摇杆手型支持:HandStyle 枚举(标准/单摇杆左/单摇杆右/坦克式),映射到 JoystickController.dualStickToAxisCommand(),设置界面持久化存储,实时生效

待办

TODO.md