226 lines
14 KiB
Markdown
226 lines
14 KiB
Markdown
# 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 # 摇杆输入 → 协议指令映射
|
||
├── video/
|
||
│ └── RtspVideoPlayer.kt # RTSP 视频流播放(ExoPlayer + 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] 比例值
|
||
|
||
**映射公式(从左摇杆角度/强度到 X/Y)**:
|
||
```
|
||
val x = power / 100.0 * cos(angle) // 垂直分量
|
||
val y = power / 100.0 * sin(angle) // 水平分量
|
||
```
|
||
右摇杆控制 Yaw(偏航角速度)。
|
||
|
||
---
|
||
|
||
## RTSP 视频流
|
||
|
||
使用 `MediaPlayer` + `SurfaceView` 或 `ExoPlayer` 拉取 RTSP 流。视频层作为 UI 最底层背景,摇杆等控件覆盖其上(半透明)。
|
||
|
||
---
|
||
|
||
## 设置界面
|
||
|
||
### 配置项
|
||
|
||
| 配置项 | 类型 | 默认值 | 说明 |
|
||
| --- | --- | --- | --- |
|
||
| `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` |
|
||
|
||
### 持久化
|
||
|
||
使用 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 播放:`androidx.media3:media3-exoplayer-rtsp:1.5.1`
|
||
- 持久化:`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`
|
||
|
||
---
|
||
|
||
## 已完成 / 待办
|
||
|
||
### 已完成
|
||
|
||
- [x] Gradle 配置:版本目录、摇杆本地模块、全部依赖
|
||
- [x] 协议层:Header.kt(16 字节小端编解码)
|
||
- [x] 协议层:ApduMessage.kt(Header + ASDU 封装)
|
||
- [x] 协议层:ControlCommands.kt(全部指令构建函数 + 常量)
|
||
- [x] 协议层:StatusReports.kt(全部状态上报数据类 + JSON 解析器)
|
||
- [x] 协议层:PacketEncoder.kt(MsgId 管理 + APDU 编码)
|
||
- [x] 协议层:PacketDecoder.kt(APDU 解码 + 缓冲区搜索)
|
||
- [x] 网络层:ProtocolClient.kt(UDP socket、心跳 1Hz、指令发送通道、接收循环、断线检测 3s、订阅触发)
|
||
- [x] 摇杆映射:JoystickController.kt(angle/power -> X/Y/Yaw)
|
||
- [x] 状态管理:MainViewModel.kt(ProtocolClient 生命周期、20Hz 轴指令循环、状态收集)
|
||
- [x] 视频播放:RtspVideoPlayer.kt(Media3 ExoPlayer RTSP,Composable 包装)
|
||
- [x] 主界面:MainScreen.kt(视频背景 + 双摇杆 + 状态栏 + 控制按钮)
|
||
- [x] 入口:MainActivity.kt + INTERNET 权限
|
||
- [x] 持久化层:AppSettings.kt(配置数据类 + 默认值 + 校验)、SettingsRepository.kt(DataStore Preferences 封装,`settings: Flow<AppSettings>` + 单项 setter + `setAll()`)、M20App.kt(Application 子类,进程级 `settingsRepository` 单例)
|
||
- [x] 设置界面 UI:SettingsScreen.kt(主机/端口/RTSP 输入 + 编码器 ExposedDropdownMenu,保存按钮调用 `setAll` 后回调 `onSaved`)
|
||
- [x] 导航框架:NavRoutes.kt(`MAIN`/`SETTINGS` 路由常量)、MainActivity.kt 改写为 `AppNavGraph()`(NavHost,startDestination=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_*` 常量,彻底清除死代码
|
||
|
||
### 待办
|
||
|
||
- [ ] 视频进一步延迟优化:探索更激进的低延迟策略(跳过 Media3 ExoPlayer,直接使用 MediaCodec + SurfaceView)
|