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>
This commit is contained in:
2026-07-27 21:22:35 +08:00
parent b4712a782c
commit 8a36537994
6 changed files with 157 additions and 32 deletions
+18 -5
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` |
### 持久化 ### 持久化
@@ -219,6 +231,7 @@ 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()`,设置界面持久化存储,实时生效
### 待办 ### 待办
@@ -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")
} }
} }
@@ -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
}
)
}
}
}
}
}