Compare commits

3 Commits

Author SHA1 Message Date
hexone2086 e3ffb01485 docs: 拆分 TODO 到独立文件,AGENTS.md 引用 TODO.md
- 创建 TODO.md,包含待办和已完成列表
- AGENTS.md 中已完成/待办节改为引用 TODO.md

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-07-27 21:35:04 +08:00
hexone2086 8a36537994 feat: 摇杆手型 HandStyle 支持(标准/单摇杆左/单摇杆右/坦克式),设置界面持久化
- AppSettings.kt: 添加 handStyle 字段,默认 STANDARD
- SettingsRepository.kt: 添加 KEY_HAND_STYLE 持久化键,setHandStyle() 方法,setAll() 包含手型
- JoystickController.kt: 添加 HandStyle 枚举,dualStickToAxisCommand() 按手型映射轴
- MainViewModel.kt: 轴指令循环传入当前 handStyle
- SettingsScreen.kt: 添加 HandStyleDropdown 下拉选择组件
- AGENTS.md: 同步更新手型文档

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-07-27 21:32:16 +08:00
hexone2086 b4712a782c chore: 清理订阅请求死代码 + RTSP 自动重连指数退避
- ControlCommands.kt: 删除 subscribeStatus() 及 SUB_* 常量
- ProtocolClient.kt: 删除 sendSubscriptionRequests() 方法及调用
- RtspVideoPlayer.kt: 播放失败后自动重试,指数退避(1s~30s,倍率2),覆盖层显示重试次数和倒计时

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-07-27 21:32:15 +08:00
10 changed files with 295 additions and 84 deletions
+21 -9
View File
@@ -35,7 +35,7 @@ com.example.m20_gamepad/
│ └── StatusReports.kt # 下行状态结构(基础状态、运控状态、设备状态等) │ └── StatusReports.kt # 下行状态结构(基础状态、运控状态、设备状态等)
├── service/ ├── service/
│ ├── RobotConnection.kt # 连接状态机管理(连接/订阅/心跳/断线判定) │ ├── RobotConnection.kt # 连接状态机管理(连接/订阅/心跳/断线判定)
│ └── JoystickController.kt # 摇杆输入 → 协议指令映射 │ └── JoystickController.kt # 摇杆输入 → 协议指令映射,含 HandStyle 多手型支持
├── video/ ├── video/
│ └── RtspVideoPlayer.kt # RTSP 视频流播放(ExoPlayer + Composable 包装) │ └── RtspVideoPlayer.kt # RTSP 视频流播放(ExoPlayer + Composable 包装)
├── data/ ├── data/
@@ -91,12 +91,23 @@ Joystick(
- 角度 270° = 正下 → X 负方向(后退) - 角度 270° = 正下 → X 负方向(后退)
- 强度百分比 → [-1, 1] 比例值 - 强度百分比 → [-1, 1] 比例值
**映射公式(从左摇杆角度/强度到 X/Y**: **标准手型(STANDARD**
``` ```
val x = power / 100.0 * cos(angle) // 垂直分量 左摇杆: X = sin(angle) * power/100, Y = cos(angle) * power/100
val y = power / 100.0 * sin(angle) // 水平分量 右摇杆: 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
``` ```
右摇杆控制 Yaw(偏航角速度)。
--- ---
@@ -117,6 +128,7 @@ val y = power / 100.0 * sin(angle) // 水平分量
| `rtsp_url` | String | `rtsp://10.21.31.103:554/stream` | RTSP 视频流地址 | | `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_codec` | Enum | `AUTO` | `AUTO` / `FORCE_HW` / `FORCE_SW_H264` / `FORCE_SW_H265` |
| `video_resize_mode` | Enum | `ZOOM` | `FIT` / `ZOOM` / `FILL` | | `video_resize_mode` | Enum | `ZOOM` | `FIT` / `ZOOM` / `FILL` |
| `hand_style` | Enum | `STANDARD` | `STANDARD` / `SINGLE_LEFT` / `SINGLE_RIGHT` / `TANK` |
### 持久化 ### 持久化
@@ -168,10 +180,9 @@ gradlew installDebug
## 已完成 / 待办 ## 已完成 / 待办
### 已完成 ## 已完成 / 待办
- [x] Gradle 配置:版本目录、摇杆本地模块、全部依赖 见 [TODO.md](./TODO.md)
- [x] 协议层:Header.kt(16 字节小端编解码)
- [x] 协议层:ApduMessage.ktHeader + ASDU 封装) - [x] 协议层:ApduMessage.ktHeader + ASDU 封装)
- [x] 协议层:ControlCommands.kt(全部指令构建函数 + 常量) - [x] 协议层:ControlCommands.kt(全部指令构建函数 + 常量)
- [x] 协议层:StatusReports.kt(全部状态上报数据类 + JSON 解析器) - [x] 协议层:StatusReports.kt(全部状态上报数据类 + JSON 解析器)
@@ -219,7 +230,8 @@ gradlew installDebug
- [x] 休眠按钮状态约束:仅非站立状态(空闲/趴下/阻尼等)可切换休眠,站立/RL 控制时禁用 - [x] 休眠按钮状态约束:仅非站立状态(空闲/趴下/阻尼等)可切换休眠,站立/RL 控制时禁用
- [x] RTSP 自动重连:`RtspVideoPlayer` 播放失败后自动重试,指数退避(初始 1s,最大 30s,倍率 2),URL/解码器变化时重置退避状态;覆盖层显示重试次数和倒计时 - [x] RTSP 自动重连:`RtspVideoPlayer` 播放失败后自动重试,指数退避(初始 1s,最大 30s,倍率 2),URL/解码器变化时重置退避状态;覆盖层显示重试次数和倒计时
- [x] 订阅请求清理:删除 `ProtocolClient.sendSubscriptionRequests()` 方法及 `connect()` 中的注释调用;同步删除 `ControlCommands.subscribeStatus()``SUB_*` 常量,彻底清除死代码 - [x] 订阅请求清理:删除 `ProtocolClient.sendSubscriptionRequests()` 方法及 `connect()` 中的注释调用;同步删除 `ControlCommands.subscribeStatus()``SUB_*` 常量,彻底清除死代码
- [x] 摇杆手型支持:`HandStyle` 枚举(标准/单摇杆左/单摇杆右/坦克式),映射到 `JoystickController.dualStickToAxisCommand()`,设置界面持久化存储,实时生效
### 待办 ### 待办
- [ ] 视频进一步延迟优化:探索更激进的低延迟策略(跳过 Media3 ExoPlayer,直接使用 MediaCodec + SurfaceView 见 [TODO.md](./TODO.md)
+58
View File
@@ -0,0 +1,58 @@
# TODO
## 待办
- [ ] 视频进一步延迟优化:探索更激进的低延迟策略(跳过 Media3 ExoPlayer,直接使用 MediaCodec + SurfaceView
## 已完成
- [x] Gradle 配置:版本目录、摇杆本地模块、全部依赖
- [x] 协议层:Header.kt(16 字节小端编解码)
- [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()`,设置界面持久化存储,实时生效
@@ -1,5 +1,6 @@
package com.example.m20_gamepad.data package com.example.m20_gamepad.data
import com.example.m20_gamepad.service.HandStyle
import com.example.m20_gamepad.video.VideoCodec import com.example.m20_gamepad.video.VideoCodec
/** /**
@@ -25,7 +26,8 @@ data class AppSettings(
val robotPort: Int = DEFAULT_ROBOT_PORT, val robotPort: Int = DEFAULT_ROBOT_PORT,
val rtspUrl: String = DEFAULT_RTSP_URL, val rtspUrl: String = DEFAULT_RTSP_URL,
val videoCodec: VideoCodec = VideoCodec.AUTO, val videoCodec: VideoCodec = VideoCodec.AUTO,
val videoResizeMode: VideoResizeMode = VideoResizeMode.ZOOM val videoResizeMode: VideoResizeMode = VideoResizeMode.ZOOM,
val handStyle: HandStyle = HandStyle.STANDARD
) { ) {
companion object { companion object {
const val DEFAULT_ROBOT_HOST = "10.21.41.1" const val DEFAULT_ROBOT_HOST = "10.21.41.1"
@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.example.m20_gamepad.service.HandStyle
import com.example.m20_gamepad.video.VideoCodec import com.example.m20_gamepad.video.VideoCodec
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -35,7 +36,8 @@ class SettingsRepository(context: Context) {
robotPort = prefs[KEY_PORT] ?: AppSettings.DEFAULT_ROBOT_PORT, robotPort = prefs[KEY_PORT] ?: AppSettings.DEFAULT_ROBOT_PORT,
rtspUrl = prefs[KEY_RTSP_URL] ?: AppSettings.DEFAULT_RTSP_URL, rtspUrl = prefs[KEY_RTSP_URL] ?: AppSettings.DEFAULT_RTSP_URL,
videoCodec = VideoCodec.entries.getOrElse(prefs[KEY_CODEC] ?: 0) { VideoCodec.AUTO }, videoCodec = VideoCodec.entries.getOrElse(prefs[KEY_CODEC] ?: 0) { VideoCodec.AUTO },
videoResizeMode = VideoResizeMode.entries.getOrElse(prefs[KEY_RESIZE_MODE] ?: 1) { VideoResizeMode.ZOOM } videoResizeMode = VideoResizeMode.entries.getOrElse(prefs[KEY_RESIZE_MODE] ?: 1) { VideoResizeMode.ZOOM },
handStyle = HandStyle.entries.getOrElse(prefs[KEY_HAND_STYLE] ?: 0) { HandStyle.STANDARD }
) )
} }
@@ -55,6 +57,10 @@ class SettingsRepository(context: Context) {
dataStore.edit { it[KEY_CODEC] = codec.ordinal } dataStore.edit { it[KEY_CODEC] = codec.ordinal }
} }
suspend fun setHandStyle(handStyle: HandStyle) {
dataStore.edit { it[KEY_HAND_STYLE] = handStyle.ordinal }
}
/** 一次性写入全部设置(用于设置界面保存按钮) */ /** 一次性写入全部设置(用于设置界面保存按钮) */
suspend fun setAll(settings: AppSettings) { suspend fun setAll(settings: AppSettings) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
@@ -63,6 +69,7 @@ class SettingsRepository(context: Context) {
prefs[KEY_RTSP_URL] = settings.rtspUrl.trim() prefs[KEY_RTSP_URL] = settings.rtspUrl.trim()
prefs[KEY_CODEC] = settings.videoCodec.ordinal prefs[KEY_CODEC] = settings.videoCodec.ordinal
prefs[KEY_RESIZE_MODE] = settings.videoResizeMode.ordinal prefs[KEY_RESIZE_MODE] = settings.videoResizeMode.ordinal
prefs[KEY_HAND_STYLE] = settings.handStyle.ordinal
} }
} }
@@ -72,5 +79,6 @@ class SettingsRepository(context: Context) {
private val KEY_RTSP_URL = stringPreferencesKey("rtsp_url") private val KEY_RTSP_URL = stringPreferencesKey("rtsp_url")
private val KEY_CODEC = intPreferencesKey("video_codec") private val KEY_CODEC = intPreferencesKey("video_codec")
private val KEY_RESIZE_MODE = intPreferencesKey("video_resize_mode") private val KEY_RESIZE_MODE = intPreferencesKey("video_resize_mode")
private val KEY_HAND_STYLE = intPreferencesKey("hand_style")
} }
} }
@@ -130,9 +130,6 @@ class ProtocolClient(
} }
} }
// 发送订阅请求(触发服务端 UDP 推送)
sendSubscriptionRequests()
// 启动断线检测(立即开始,覆盖 CONNECTING 和 CONNECTED 状态) // 启动断线检测(立即开始,覆盖 CONNECTING 和 CONNECTED 状态)
timeoutJob = scope.launch { timeoutJob = scope.launch {
timeoutCheckLoop() timeoutCheckLoop()
@@ -195,25 +192,6 @@ class ProtocolClient(
socket = null socket = null
} }
/** 发送订阅请求 */
private fun sendSubscriptionRequests() {
val subscriptions: List<Pair<Int, Int>> = listOf(
ControlCommands.SUB_BASIC,
ControlCommands.SUB_MOTION_CONTROL,
ControlCommands.SUB_DEVICE,
ControlCommands.SUB_ERROR
)
val sock = socket ?: return
val addr = remoteAddress ?: return
for ((type, cmd) in subscriptions) {
try {
val json = ControlCommands.subscribeStatus(type, cmd)
val packet = encoder.encode(json)
sock.send(DatagramPacket(packet, packet.size, addr, port))
} catch (_: Exception) { }
}
}
/** 接收循环 */ /** 接收循环 */
private fun CoroutineScope.receiveLoop(sock: DatagramSocket) { private fun CoroutineScope.receiveLoop(sock: DatagramSocket) {
val buffer = ByteArray(65535) val buffer = ByteArray(65535)
@@ -122,10 +122,6 @@ object ControlCommands {
// ---- 2.10 休眠状态查询 ---- // ---- 2.10 休眠状态查询 ----
fun querySleepStatus(time: String = now()): String = buildAsdu(type = 1101, command = 7, time = time) fun querySleepStatus(time: String = now()): String = buildAsdu(type = 1101, command = 7, time = time)
// ---- 订阅状态上报 ----
fun subscribeStatus(type: Int, command: Int, time: String = now()): String =
buildAsdu(type = type, command = command, time = time)
// ========== 常量 ========== // ========== 常量 ==========
// 运动状态常量 // 运动状态常量
@@ -147,9 +143,4 @@ object ControlCommands {
const val MODE_NAVIGATION = 1 const val MODE_NAVIGATION = 1
const val MODE_ASSIST = 2 const val MODE_ASSIST = 2
// 订阅 Type/Command — 用于 subscribeStatus()
val SUB_ERROR = Pair(1002, 3)
val SUB_MOTION_CONTROL = Pair(1002, 4)
val SUB_DEVICE = Pair(1002, 5)
val SUB_BASIC = Pair(1002, 6)
} }
@@ -4,6 +4,20 @@ import com.example.m20_gamepad.network.models.ControlCommands
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.sin import kotlin.math.sin
/**
* 手型(摇杆-轴映射模式)。
*/
enum class HandStyle {
/** 标准:左摇杆 X/Y, 右摇杆 Yaw */
STANDARD,
/** 单摇杆左:左摇杆 X/Yaw(类车), 右摇杆 Y */
SINGLE_LEFT,
/** 单摇杆右:左摇杆 Y, 右摇杆 X/Yaw(类车) */
SINGLE_RIGHT,
/** 坦克式:两摇杆 up/down 差速 X/Yaw, 无 Y, cos 忽略 */
TANK
}
/** /**
* 摇杆输入 -> 协议轴指令映射。 * 摇杆输入 -> 协议轴指令映射。
* *
@@ -15,39 +29,59 @@ import kotlin.math.sin
* *
* 协议轴定义: * 协议轴定义:
* - X 正 = 前进,Y 正 = 左移,Yaw 正 = 逆时针(左转) * - X 正 = 前进,Y 正 = 左移,Yaw 正 = 逆时针(左转)
*
* 映射公式:
* - 左摇杆 X(前后) = sin(angle) * power/100
* - 左摇杆 Y(左右) = cos(angle) * power/100
* - 右摇杆 Yaw = cos(angle) * power/100
*/ */
object JoystickController { object JoystickController {
/** 左摇杆角度/强度 -> 轴指令 JSONX=前后, Y=左右) */ /**
fun leftStickToAxisCommand(angle: Double, power: Double): String { * 根据手型将双摇杆输入映射为轴指令 JSON。
val scale = power / 100.0 *
val x = sin(angle) * scale * @param handStyle 当前手型
val y = cos(angle) * scale * @param leftAngle 左摇杆角度(弧度)
return ControlCommands.axisCommand(x = x, y = y) * @param leftPower 左摇杆强度(0-100
} * @param rightAngle 右摇杆角度(弧度)
* @param rightPower 右摇杆强度(0-100
/** 右摇杆角度/强度 -> 轴指令 JSONYaw=偏航) */ */
fun rightStickToAxisCommand(angle: Double, power: Double): String {
val yaw = cos(angle) * power / 100.0
return ControlCommands.axisCommand(yaw = yaw)
}
/** 双摇杆合并 -> 轴指令 JSON(左摇杆控制 X/Y,右摇杆控制 Yaw) */
fun dualStickToAxisCommand( fun dualStickToAxisCommand(
handStyle: HandStyle,
leftAngle: Double, leftPower: Double, leftAngle: Double, leftPower: Double,
rightAngle: Double, rightPower: Double rightAngle: Double, rightPower: Double
): String { ): String {
val leftScale = leftPower / 100.0 val leftScale = leftPower / 100.0
val rightScale = rightPower / 100.0 val rightScale = rightPower / 100.0
return when (handStyle) {
HandStyle.STANDARD -> {
val x = sin(leftAngle) * leftScale val x = sin(leftAngle) * leftScale
val y = cos(leftAngle) * leftScale val y = cos(leftAngle) * leftScale
val yaw = cos(rightAngle) * rightScale val yaw = cos(rightAngle) * rightScale
return ControlCommands.axisCommand(x = x, y = y, yaw = yaw) ControlCommands.axisCommand(x = x, y = y, yaw = yaw)
}
HandStyle.SINGLE_LEFT -> {
// 左摇杆:X(前后) + Yaw(转向),类车操控
val x = sin(leftAngle) * leftScale
val yaw = cos(leftAngle) * leftScale
// 右摇杆:Y(横移),仅左右有效
val y = cos(rightAngle) * rightScale
ControlCommands.axisCommand(x = x, y = y, yaw = yaw)
}
HandStyle.SINGLE_RIGHT -> {
// 左摇杆:Y(横移),仅左右有效
val y = cos(leftAngle) * leftScale
// 右摇杆:X(前后) + Yaw(转向),类车操控
val x = sin(rightAngle) * rightScale
val yaw = cos(rightAngle) * rightScale
ControlCommands.axisCommand(x = x, y = y, yaw = yaw)
}
HandStyle.TANK -> {
// 左摇杆 up/down → 左履带,右摇杆 up/down → 右履带
// cos 方向忽略 → 仅 sin 参与
val leftTrack = sin(leftAngle) * leftScale
val rightTrack = sin(rightAngle) * rightScale
val x = (leftTrack + rightTrack) / 2.0
val yaw = (leftTrack - rightTrack) / 2.0
ControlCommands.axisCommand(x = x, yaw = yaw)
}
}
} }
/** 零速度指令(松手时发送,保持连接活跃) */ /** 零速度指令(松手时发送,保持连接活跃) */
@@ -348,6 +348,7 @@ class MainViewModel(
if (c != null && c.isConnected) { if (c != null && c.isConnected) {
val cmd = if (robot.canSendAxisCommand) { val cmd = if (robot.canSendAxisCommand) {
JoystickController.dualStickToAxisCommand( JoystickController.dualStickToAxisCommand(
settings.value.handStyle,
leftAngle, leftPower, leftAngle, leftPower,
rightAngle, rightPower rightAngle, rightPower
) )
@@ -38,6 +38,7 @@ import androidx.compose.ui.unit.dp
import com.example.m20_gamepad.data.AppSettings import com.example.m20_gamepad.data.AppSettings
import com.example.m20_gamepad.data.SettingsRepository import com.example.m20_gamepad.data.SettingsRepository
import com.example.m20_gamepad.data.VideoResizeMode import com.example.m20_gamepad.data.VideoResizeMode
import com.example.m20_gamepad.service.HandStyle
import com.example.m20_gamepad.video.VideoCodec import com.example.m20_gamepad.video.VideoCodec
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -67,6 +68,7 @@ fun SettingsScreen(
var rtspUrl by remember { mutableStateOf(AppSettings.DEFAULT_RTSP_URL) } var rtspUrl by remember { mutableStateOf(AppSettings.DEFAULT_RTSP_URL) }
var codec by remember { mutableStateOf(VideoCodec.AUTO) } var codec by remember { mutableStateOf(VideoCodec.AUTO) }
var resizeMode by remember { mutableStateOf(VideoResizeMode.ZOOM) } var resizeMode by remember { mutableStateOf(VideoResizeMode.ZOOM) }
var handStyle by remember { mutableStateOf(HandStyle.STANDARD) }
var hostError by remember { mutableStateOf<String?>(null) } var hostError by remember { mutableStateOf<String?>(null) }
var portError by remember { mutableStateOf<String?>(null) } var portError by remember { mutableStateOf<String?>(null) }
var initialized by remember { mutableStateOf(false) } var initialized by remember { mutableStateOf(false) }
@@ -79,6 +81,7 @@ fun SettingsScreen(
rtspUrl = s.rtspUrl rtspUrl = s.rtspUrl
codec = s.videoCodec codec = s.videoCodec
resizeMode = s.videoResizeMode resizeMode = s.videoResizeMode
handStyle = s.handStyle
initialized = true initialized = true
} }
} }
@@ -170,6 +173,12 @@ fun SettingsScreen(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
HandStyleDropdown(
selected = handStyle,
onSelect = { handStyle = it },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.padding(top = 8.dp)) Spacer(Modifier.padding(top = 8.dp))
// ---- 操作按钮 ---- // ---- 操作按钮 ----
@@ -193,7 +202,8 @@ fun SettingsScreen(
robotPort = port, robotPort = port,
rtspUrl = rtspUrl, rtspUrl = rtspUrl,
videoCodec = codec, videoCodec = codec,
videoResizeMode = resizeMode videoResizeMode = resizeMode,
handStyle = handStyle
) )
) )
onSaved() onSaved()
@@ -330,3 +340,60 @@ private fun ResizeModeDropdown(
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HandStyleDropdown(
selected: HandStyle,
onSelect: (HandStyle) -> Unit,
modifier: Modifier = Modifier
) {
var expanded by remember { mutableStateOf(false) }
val options = HandStyle.entries
val labels = mapOf(
HandStyle.STANDARD to "标准(左摇杆 X/Y,右摇杆 Yaw",
HandStyle.SINGLE_LEFT to "单摇杆左(左摇杆 X/Yaw,右摇杆 Y)",
HandStyle.SINGLE_RIGHT to "单摇杆右(左摇杆 Y,右摇杆 X/Yaw)",
HandStyle.TANK to "坦克式(两摇杆差速,无横移)"
)
Column(modifier = modifier) {
Text(
text = "摇杆手型",
style = androidx.compose.material3.MaterialTheme.typography.titleSmall,
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
)
Spacer(Modifier.padding(top = 4.dp))
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it }
) {
OutlinedTextField(
value = labels[selected] ?: selected.name,
onValueChange = {},
readOnly = true,
singleLine = true,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
options.forEach { h ->
DropdownMenuItem(
text = { Text(labels[h] ?: h.name) },
onClick = {
onSelect(h)
expanded = false
}
)
}
}
}
}
}
@@ -1,12 +1,15 @@
package com.example.m20_gamepad.video package com.example.m20_gamepad.video
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -14,6 +17,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
@@ -26,6 +32,7 @@ import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import com.example.m20_gamepad.data.VideoResizeMode import com.example.m20_gamepad.data.VideoResizeMode
import kotlinx.coroutines.delay
/** /**
* 视频解码器选择策略。 * 视频解码器选择策略。
@@ -44,13 +51,18 @@ enum class VideoCodec {
/** RTSP 播放状态 */ /** RTSP 播放状态 */
sealed class RtspState { sealed class RtspState {
/** 连接中 */ /** 连接中 */
object Connecting : RtspState() data class Connecting(val retryCount: Int = 0) : RtspState()
/** 播放中 */ /** 播放中 */
object Playing : RtspState() object Playing : RtspState()
/** 连接失败 */ /** 连接失败(自动重试中) */
data class Error(val message: String) : RtspState() data class Error(val message: String, val retryCount: Int = 0, val nextRetryMs: Long = 0) : RtspState()
} }
/** 指数退避参数 */
private const val RETRY_DELAY_INITIAL_MS = 1000L
private const val RETRY_DELAY_MAX_MS = 30_000L
private const val RETRY_DELAY_MULTIPLIER = 2
/** /**
* RTSP 视频流播放 Composable。 * RTSP 视频流播放 Composable。
* *
@@ -72,7 +84,10 @@ fun RtspVideoPlayer(
) { ) {
val context = LocalContext.current val context = LocalContext.current
var state by remember { mutableStateOf<RtspState>(RtspState.Connecting) } var state by remember { mutableStateOf<RtspState>(RtspState.Connecting()) }
var retryCount by remember { mutableIntStateOf(0) }
var retryDelayMs by remember { mutableStateOf(RETRY_DELAY_INITIAL_MS) }
var retrySignal by remember { mutableIntStateOf(0) }
// codec 变化时重建 ExoPlayer,新的 selector 在 RenderersFactory 中注入, // codec 变化时重建 ExoPlayer,新的 selector 在 RenderersFactory 中注入,
// 同时使用最小缓冲策略降低延迟 // 同时使用最小缓冲策略降低延迟
@@ -99,24 +114,42 @@ fun RtspVideoPlayer(
addListener(object : Player.Listener { addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackStateChanged(playbackState: Int) {
state = when (playbackState) { state = when (playbackState) {
Player.STATE_BUFFERING -> RtspState.Connecting Player.STATE_BUFFERING -> RtspState.Connecting(retryCount)
Player.STATE_READY -> RtspState.Playing Player.STATE_READY -> RtspState.Playing
Player.STATE_IDLE -> RtspState.Connecting Player.STATE_IDLE -> RtspState.Connecting(retryCount)
else -> state else -> state
} }
} }
override fun onPlayerError(error: PlaybackException) { override fun onPlayerError(error: PlaybackException) {
state = RtspState.Error(error.message ?: "连接失败") state = RtspState.Error(
message = error.message ?: "连接失败",
retryCount = retryCount,
nextRetryMs = retryDelayMs
)
// 递增 retrySignal 触发 LaunchedEffect 自动重试
retrySignal++
} }
}) })
} }
} }
// URL 或 codec 变化时重新加载媒体源 // 初始连接 + 自动重试:key 包含 url/codec 变化时自动取消旧重试
LaunchedEffect(url, codec) { LaunchedEffect(url, codec, retrySignal) {
if (url.isNotBlank()) { if (url.isNotBlank()) {
state = RtspState.Connecting if (retrySignal == 0) {
// 首次连接 / URL 或 codec 变更:重置退避
retryCount = 0
retryDelayMs = RETRY_DELAY_INITIAL_MS
} else {
// 重试:指数退避等待
delay(retryDelayMs)
retryDelayMs = (retryDelayMs * RETRY_DELAY_MULTIPLIER).coerceAtMost(RETRY_DELAY_MAX_MS)
retryCount++
}
state = RtspState.Connecting(retryCount)
player.stop()
player.clearMediaItems()
val mediaItem = MediaItem.fromUri(url) val mediaItem = MediaItem.fromUri(url)
player.setMediaItem(mediaItem) player.setMediaItem(mediaItem)
player.prepare() player.prepare()
@@ -152,23 +185,50 @@ fun RtspVideoPlayer(
// 连接中 / 错误时的提示覆盖层 // 连接中 / 错误时的提示覆盖层
when (val s = state) { when (val s = state) {
is RtspState.Connecting -> StatusOverlay("连接 RTSP 流中...") is RtspState.Connecting -> {
is RtspState.Error -> StatusOverlay("RTSP 连接失败: ${s.message}") val msg = if (s.retryCount > 0) {
"RTSP 重连中... (第 ${s.retryCount} 次)"
} else {
"连接 RTSP 流中..."
}
StatusOverlay(msg)
}
is RtspState.Error -> {
val retryHint = if (s.nextRetryMs > 0) {
"${s.nextRetryMs / 1000}s 后自动重试"
} else ""
StatusOverlay(
text = "RTSP 连接失败: ${s.message}",
subtitle = retryHint
)
}
is RtspState.Playing -> { /* 正常播放,不显示覆盖层 */ } is RtspState.Playing -> { /* 正常播放,不显示覆盖层 */ }
} }
} }
} }
@Composable @Composable
private fun StatusOverlay(text: String) { private fun StatusOverlay(text: String, subtitle: String = "") {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text( Text(
text = text, text = text,
color = Color.White color = Color.White,
fontSize = 16.sp
) )
if (subtitle.isNotBlank()) {
Text(
text = subtitle,
color = Color.White.copy(alpha = 0.7f),
fontSize = 13.sp,
fontWeight = FontWeight.Light,
modifier = Modifier.padding(top = 4.dp)
)
}
}
} }
} }