Compare commits

..

8 Commits

Author SHA1 Message Date
hexone2086 9883eb4b44 docs: 同步 AGENTS.md — 修改解码器优化方向 2026-07-20 01:48:13 +08:00
hexone2086 28438d05bb docs: 同步 AGENTS.md — 订阅请求清理措辞更新
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-07-18 15:58:30 +08:00
hexone2086 dae40cc73c docs: 同步 AGENTS.md — RTSP 自动重连 + 订阅请求移除
- 修正 video 包路径文件名为 RtspVideoPlayer.kt
- 新增 RTSP 自动重连(指数退避)条目
- 新增订阅请求移除条目

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-07-18 15:50:15 +08:00
hexone2086 3225ab0922 fix: 状态上报闪烁修复 + 休眠按钮状态约束
- _latestStatus 改用合并策略 (prev.copy),保留上次报告的非 null 字段,
  避免 4 个独立订阅 (Basic/Error/Motion/Device) 交替覆盖导致闪烁
- StatusBar 电量改用独立 batteryStatus StateFlow,不依赖 status?.batteryStatus
- 休眠按钮仅非站立状态 (空闲/趴下/阻尼) 可切换,站立/RL 控制时禁用

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-07-18 15:19:39 +08:00
hexone2086 9b8cd09929 feat: 右上角添加软急停滑块(平行四边形风格)
新增 EmergencyStopSlider 组件,水平滑到右侧触发软急停指令,
滑到左侧仅 UI 复位,等待机器人状态上报自动归位。非拖动时
跟随 motionState 状态同步滑块位置。

ControlCommands 新增 softEmergencyStop() 便捷函数。

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-07-18 14:59:22 +08:00
hexone2086 d4db91235b fix: 点击连接按钮后立即启动心跳线程,不再等待数据包接收
ProtocolClient.connect() 现在立即启动心跳循环(1Hz),而不是
等到 receiveLoop 首次收到数据包后才启动。心跳在 CONNECTING 和
CONNECTED 状态下均正常运行,确保连接建立过程中机器人能持续收到
心跳包。

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-07-18 14:58:19 +08:00
hexone2086 fbc3f1529f fix: 连接状态判定修复 — 收到首个数据包后才标记已连接,CONNECTING 5秒超时回退
- ProtocolClient: 仅收到首个有效数据包后设置 CONNECTED,不再 socket 创建后立即标记
- ProtocolClient: timeoutCheckLoop 在 connect() 中立即启动,覆盖 CONNECTING(5s) 和 CONNECTED(3s)
- RobotConnection: 允许 CONNECTING → TIMEOUT 合法转换
- MainScreen: CONNECTING 时点击连接按钮仅断开回到 DISCONNECTED,不再自动重连

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-07-18 12:15:43 +08:00
hexone2086 b2adf46957 refactor: 功能按钮全面布局优化 — 均布四周+平行四边形样式+互斥合并
- 起立/趴下合并为切换按钮,移至左上角
- 模式选择器(3个互斥无缝隙)移至左上角最上方
- 步态切换(4个互斥无缝隙)移至模式选择器下方,仅普通模式可点击
- 照明/休眠/充电移至右上角,紧凑排列
- 所有功能按钮使用平行四边形形状(左侧向右倾斜,右侧向左倾斜)
- 底部控制区移除更多展开区域,仅保留摇杆
- 所有控制按钮未连接时灰色不可点击

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-07-18 01:58:11 +08:00
7 changed files with 527 additions and 222 deletions
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<option name="previewPanelProviderInfo">
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
</option>
</component>
</project>
+30 -12
View File
@@ -37,7 +37,7 @@ com.example.m20_gamepad/
│ ├── RobotConnection.kt # 连接状态机管理(连接/订阅/心跳/断线判定)
│ └── JoystickController.kt # 摇杆输入 → 协议指令映射
├── video/
│ └── RtspSurfaceView.kt # RTSP 视频流播放(SurfaceView 层
│ └── RtspVideoPlayer.kt # RTSP 视频流播放(ExoPlayer + Composable 包装
├── data/
│ ├── SettingsRepository.kt # 设置项读写(DataStore 封装)
│ └── AppSettings.kt # 配置数据类定义 + 默认值
@@ -52,12 +52,12 @@ com.example.m20_gamepad/
- **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}
- 心跳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
---
@@ -159,9 +159,10 @@ gradlew installDebug
- **协程**: 网络 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,循环回绕
- **断线判定**: 3 秒无任何 UDP 包 → 标记离线
- **断线判定**: 收到首个有效数据包前为 `CONNECTING`5 秒无数据 → `TIMEOUT`);`CONNECTED` 3 秒无任何 UDP 包 → `TIMEOUT`
---
@@ -187,9 +188,9 @@ gradlew installDebug
- [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] 状态上报 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] 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 下发)
@@ -200,8 +201,25 @@ gradlew installDebug
- [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_*` 常量,彻底清除死代码
### 待办
- [ ] UI 布局:功能按钮均布在四周,充分利用屏幕空间,优化单手操作体验
- [ ] 视频进一步延迟优化:探索更激进的低延迟策略(如降低帧率、缩小分辨率、调整 ExoPlayer 缓冲策略等)
- [ ] 视频进一步延迟优化:探索更激进的低延迟策略(跳过 Media3 ExoPlayer,直接使用 MediaCodec + SurfaceView
@@ -89,9 +89,11 @@ class ProtocolClient(
/**
* 建立连接:
* 1. 创建 UDP socket
* 2. 启动接收协程
* 2. 启动接收/发送协程
* 3. 发送订阅请求
* 4. 启动心跳和断线检测
* 4. 启动断线检测CONNECTING 5s / CONNECTED 3s 无数据 → TIMEOUT
* 5. 启动心跳 1Hz(立即发送,覆盖 CONNECTING 和 CONNECTED 状态)
* 6. 首次收到有效数据包后标记已连接
*/
fun connect() {
if (_state.value != State.DISCONNECTED && _state.value != State.ERROR) return
@@ -131,17 +133,15 @@ class ProtocolClient(
// 发送订阅请求(触发服务端 UDP 推送)
sendSubscriptionRequests()
_state.value = State.CONNECTED
// 启动心跳
heartbeatJob = scope.launch(Dispatchers.IO) {
heartbeatLoop(sock, address)
}
// 启动断线检测
// 启动断线检测(立即开始,覆盖 CONNECTING 和 CONNECTED 状态)
timeoutJob = scope.launch {
timeoutCheckLoop()
}
// 启动心跳(立即开始,覆盖 CONNECTING 和 CONNECTED 状态)
heartbeatJob = scope.launch(Dispatchers.IO) {
heartbeatLoop(sock, address)
}
} catch (e: Exception) {
_state.value = State.ERROR
closeSocket()
@@ -232,6 +232,11 @@ class ProtocolClient(
if (report != null) {
_statusReport.tryEmit(report)
}
// 首次收到有效数据包:标记已连接
if (_state.value == State.CONNECTING) {
_state.value = State.CONNECTED
}
} catch (_: SocketTimeoutException) {
// 超时正常,继续循环
continue
@@ -244,9 +249,11 @@ class ProtocolClient(
}
}
/** 心跳循环(1 Hz */
/** 心跳循环(1 Hz—— 在 CONNECTING 和 CONNECTED 状态下运行 */
private suspend fun CoroutineScope.heartbeatLoop(sock: DatagramSocket, address: InetAddress) {
while (isActive && _state.value == State.CONNECTED) {
while (isActive &&
(_state.value == State.CONNECTING || _state.value == State.CONNECTED)
) {
try {
val json = ControlCommands.heartbeat()
val packet = encoder.encode(json)
@@ -258,12 +265,22 @@ class ProtocolClient(
}
}
/** 断线检测:3 秒无数据 → 标记 TIMEOUT */
/** 断线检测:CONNECTED 状态 3 秒无数据 → TIMEOUTCONNECTING 状态 5 秒无数据 → TIMEOUT */
private suspend fun CoroutineScope.timeoutCheckLoop() {
while (isActive && _state.value != State.DISCONNECTED) {
val elapsed = System.currentTimeMillis() - lastPacketTimeMs
if (elapsed > 3000 && _state.value == State.CONNECTED) {
_state.value = State.TIMEOUT
when (_state.value) {
State.CONNECTED -> {
if (elapsed > 3000) {
_state.value = State.TIMEOUT
}
}
State.CONNECTING -> {
if (elapsed > 5000) {
_state.value = State.TIMEOUT
}
}
else -> { /* DISCONNECTED/TIMEOUT/ERROR: 无需超时处理 */ }
}
delay(500)
}
@@ -107,6 +107,9 @@ object ControlCommands {
put("Charge", JsonPrimitive(charge))
}
// ---- 2.8b 软急停 ----
fun softEmergencyStop(time: String = now()): String = motionControl(MOTION_DAMPING, time)
// ---- 2.9 休眠模式设置 ----
fun sleepSettings(sleep: Boolean, auto: Boolean, timeMinutes: Int, time: String = now()): String = buildAsdu(
type = 1101, command = 6, time = time
@@ -50,6 +50,7 @@ class RobotConnection {
ProtocolClient.State.CONNECTING ->
to == ProtocolClient.State.CONNECTED ||
to == ProtocolClient.State.ERROR ||
to == ProtocolClient.State.TIMEOUT ||
to == ProtocolClient.State.DISCONNECTED
ProtocolClient.State.CONNECTED ->
to == ProtocolClient.State.TIMEOUT ||
@@ -3,6 +3,7 @@ package com.example.m20_gamepad.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -13,14 +14,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Settings
@@ -40,12 +40,21 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -66,6 +75,38 @@ import com.example.m20_gamepad.video.RtspVideoPlayer
private val OverlayBg = Color(0x88000000)
private val OverlayText = Color.White
/** 平行四边形形状,用于功能按钮。skewLeft=true → 左下-右上斜边(左侧按钮);
* skewLeft=false → 左上-右下斜边(右侧按钮)。 */
private class ParallelogramShape(
private val skewDp: Dp = 10.dp,
private val skewLeft: Boolean = true
) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
val skewPx = with(density) { skewDp.toPx() }
val path = Path().apply {
if (skewLeft) {
// 左下 → 右上 斜边(平行四边形向右倾斜)
moveTo(skewPx, 0f)
lineTo(size.width, 0f)
lineTo(size.width - skewPx, size.height)
lineTo(0f, size.height)
} else {
// 左上 → 右下 斜边(平行四边形向左倾斜)
moveTo(0f, 0f)
lineTo(size.width - skewPx, 0f)
lineTo(size.width, size.height)
lineTo(skewPx, size.height)
}
close()
}
return Outline.Generic(path)
}
}
/**
* 主界面:RTSP 视频背景 + 双摇杆覆盖层 + 状态栏 + 控制按钮。
*
@@ -130,12 +171,15 @@ fun MainScreen(
StatusBar(
connectionState = connectionState,
status = status,
batteryStatus = batteryStatus,
pingLatencyMs = pingLatencyMs,
wifiSignalLevel = wifiSignalLevel,
onStatusClick = { statusPanelVisible = true },
onSettingsClick = onSettingsClick,
onConnectToggle = {
if (connectionState == ProtocolClient.State.CONNECTED) {
if (connectionState == ProtocolClient.State.CONNECTED ||
connectionState == ProtocolClient.State.CONNECTING
) {
viewModel.disconnect()
} else {
viewModel.connect()
@@ -144,12 +188,97 @@ fun MainScreen(
modifier = Modifier.align(Alignment.TopCenter)
)
// ---- 左上角控制区:模式选择 + 步态切换 + 起立/趴下 ----
Column(
modifier = Modifier
.align(Alignment.TopStart)
.padding(top = 56.dp, start = 12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
ModeSelector(
currentMode = status?.basicStatus?.controlUsageMode ?: 0,
connected = connectionState == ProtocolClient.State.CONNECTED,
onSelectMode = { mode -> viewModel.sendSwitchMode(mode) }
)
// 步态切换——始终显示,仅普通模式 + RL 控制时可点击
GaitSelector(
currentGait = status?.basicStatus?.gait ?: 0,
enabled = connectionState == ProtocolClient.State.CONNECTED &&
motionState == ControlCommands.MOTION_RL_CONTROL &&
status?.basicStatus?.controlUsageMode == ControlCommands.MODE_NORMAL,
onSelectGait = { gait -> viewModel.sendSwitchGait(gait) }
)
StandToggleButton(
motionState = motionState,
connected = connectionState == ProtocolClient.State.CONNECTED,
onToggle = {
if (motionState == ControlCommands.MOTION_IDLE ||
motionState == ControlCommands.MOTION_LIE_DOWN
) {
viewModel.sendMotionControl(ControlCommands.MOTION_STAND)
} else {
viewModel.sendMotionControl(ControlCommands.MOTION_LIE_DOWN)
}
}
)
}
// ---- 右上角控制区:软急停滑块 + 照明 + 休眠 + 充电 ----
Column(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 56.dp, end = 12.dp),
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
// 软急停滑块
EmergencyStopSlider(
isEmergencyStop = motionState == ControlCommands.MOTION_DAMPING,
connected = connectionState == ProtocolClient.State.CONNECTED,
onEmergencyStop = { viewModel.sendEmergencyStop() }
)
// 照明:前灯+后灯,无缝隙
val frontOn = ledStatus?.front == 1
val backOn = ledStatus?.back == 1
Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) {
LightToggleButton(
text = "前灯",
isOn = frontOn,
enabled = connectionState == ProtocolClient.State.CONNECTED
) {
viewModel.sendLightControl(if (frontOn) 0 else 1, ledStatus?.back ?: 0)
}
LightToggleButton(
text = "后灯",
isOn = backOn,
enabled = connectionState == ProtocolClient.State.CONNECTED
) {
viewModel.sendLightControl(ledStatus?.front ?: 0, if (backOn) 0 else 1)
}
}
// 休眠/唤醒 — 仅趴下/空闲等非站立状态可切换
val isSleeping = status?.basicStatus?.sleep == 1
val canSleep = motionState != ControlCommands.MOTION_STAND &&
motionState != ControlCommands.MOTION_RL_CONTROL
SleepToggleButton(
isSleeping = isSleeping,
connected = connectionState == ProtocolClient.State.CONNECTED && canSleep,
onToggle = { viewModel.sendSleepSettings(!isSleeping) }
)
// 充电/结束充电
val isCharging = status?.basicStatus?.charge == 1
ChargeToggleButton(
isCharging = isCharging,
connected = connectionState == ProtocolClient.State.CONNECTED,
onToggle = { viewModel.sendChargeControl(if (isCharging) 0 else 1) }
)
}
// ---- 底部控制区 ----
BottomControls(
viewModel = viewModel,
connectionState = connectionState,
motionState = motionState,
ledStatus = ledStatus,
modifier = Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.navigationBars)
@@ -181,6 +310,7 @@ fun MainScreen(
private fun StatusBar(
connectionState: ProtocolClient.State,
status: com.example.m20_gamepad.network.models.StatusReport?,
batteryStatus: com.example.m20_gamepad.network.models.BatteryStatus?,
pingLatencyMs: Long?,
wifiSignalLevel: Int,
onStatusClick: () -> Unit,
@@ -214,7 +344,7 @@ private fun StatusBar(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
status?.batteryStatus?.let {
batteryStatus?.let {
val avgLevel = (it.batteryLevelLeft + it.batteryLevelRight) / 2
StatusChip("电量 ${avgLevel.toInt()}%")
}
@@ -336,7 +466,6 @@ private fun BottomControls(
viewModel: MainViewModel,
connectionState: ProtocolClient.State,
motionState: Int,
ledStatus: LedStatus?,
modifier: Modifier = Modifier
) {
Row(
@@ -357,14 +486,6 @@ private fun BottomControls(
}
)
// 中间控制按钮组
ControlButtons(
viewModel = viewModel,
connectionState = connectionState,
motionState = motionState,
ledStatus = ledStatus
)
// 右摇杆(Yaw 旋转)
Joystick(
modifier = Modifier.size(180.dp),
@@ -378,170 +499,6 @@ private fun BottomControls(
}
}
@Composable
private fun ControlButtons(
viewModel: MainViewModel,
connectionState: ProtocolClient.State,
motionState: Int,
ledStatus: LedStatus?
) {
val connected = connectionState == ProtocolClient.State.CONNECTED
val isRlControl = motionState == ControlCommands.MOTION_RL_CONTROL
var showMore by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.verticalScroll(scrollState)
.padding(top = 4.dp, bottom = 4.dp)
) {
// 运动状态转换
ControlButton(
"起立",
enabled = connected && (motionState == ControlCommands.MOTION_IDLE ||
motionState == ControlCommands.MOTION_LIE_DOWN)
) {
viewModel.sendMotionControl(ControlCommands.MOTION_STAND)
}
ControlButton(
"RL控制",
enabled = connected && motionState == ControlCommands.MOTION_STAND
) {
viewModel.sendMotionControl(ControlCommands.MOTION_RL_CONTROL)
}
ControlButton(
"趴下",
enabled = connected && motionState != ControlCommands.MOTION_LIE_DOWN
) {
viewModel.sendMotionControl(ControlCommands.MOTION_LIE_DOWN)
}
// 步态切换——仅 RL 控制
if (isRlControl) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
MiniButton("基础", enabled = isRlControl) {
viewModel.sendSwitchGait(ControlCommands.GAIT_BASIC)
}
MiniButton("楼梯", enabled = isRlControl) {
viewModel.sendSwitchGait(ControlCommands.GAIT_STAIRS)
}
MiniButton("平地敏捷", enabled = isRlControl) {
viewModel.sendSwitchGait(ControlCommands.GAIT_FLAT_AGILE)
}
}
MiniButton("楼梯敏捷", enabled = isRlControl) {
viewModel.sendSwitchGait(ControlCommands.GAIT_STAIRS_AGILE)
}
}
// 更多/收起切换
if (connected) {
ControlButton(if (showMore) "收起" else "更多", enabled = connected) {
showMore = !showMore
}
}
// ---- 更多控制(可展开) ----
if (showMore) {
Separator()
// 使用模式
SectionLabel("使用模式")
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
MiniButton("常规") {
viewModel.sendSwitchMode(ControlCommands.MODE_NORMAL)
}
MiniButton("导航") {
viewModel.sendSwitchMode(ControlCommands.MODE_NAVIGATION)
}
MiniButton("辅助") {
viewModel.sendSwitchMode(ControlCommands.MODE_ASSIST)
}
}
Spacer(Modifier.height(4.dp))
// 照明
SectionLabel("照明")
val frontOn = ledStatus?.front == 1
val backOn = ledStatus?.back == 1
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
LightToggleButton(
text = "前灯",
isOn = frontOn,
enabled = connected
) {
viewModel.sendLightControl(if (frontOn) 0 else 1, ledStatus?.back ?: 0)
}
LightToggleButton(
text = "后灯",
isOn = backOn,
enabled = connected
) {
viewModel.sendLightControl(ledStatus?.front ?: 0, if (backOn) 0 else 1)
}
}
Spacer(Modifier.height(4.dp))
// 充电
SectionLabel("充电")
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
MiniButton("开始充电") { viewModel.sendChargeControl(1) }
MiniButton("结束充电") { viewModel.sendChargeControl(0) }
}
Spacer(Modifier.height(4.dp))
// 休眠
SectionLabel("休眠")
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
MiniButton("进入休眠") { viewModel.sendSleepSettings(true) }
MiniButton("唤醒") { viewModel.sendSleepSettings(false) }
}
}
}
}
@Composable
private fun ControlButton(
text: String,
enabled: Boolean = true,
onClick: () -> Unit
) {
Button(
onClick = onClick,
enabled = enabled,
colors = ButtonDefaults.buttonColors(
containerColor = Color(0x66333333),
disabledContainerColor = Color(0x33222222)
),
modifier = Modifier.width(110.dp)
) {
Text(text, color = if (enabled) OverlayText else Color(0x88FFFFFF), fontWeight = FontWeight.Medium)
}
}
@Composable
private fun MiniButton(text: String, enabled: Boolean = true, onClick: () -> Unit) {
Button(
onClick = onClick,
enabled = enabled,
colors = ButtonDefaults.buttonColors(
containerColor = Color(0x66333333),
disabledContainerColor = Color(0x33222222)
),
contentPadding = ButtonDefaults.TextButtonContentPadding,
modifier = Modifier.height(32.dp)
) {
Text(
text,
color = if (enabled) OverlayText else Color(0x88FFFFFF),
fontSize = 12.sp,
fontWeight = FontWeight.Normal
)
}
}
@Composable
private fun LightToggleButton(
text: String,
@@ -552,40 +509,309 @@ private fun LightToggleButton(
Button(
onClick = onClick,
enabled = enabled,
shape = ParallelogramShape(skewLeft = false),
colors = ButtonDefaults.buttonColors(
containerColor = if (isOn) Color(0xCCFFC107) else Color(0x66333333),
disabledContainerColor = Color(0x33222222)
),
modifier = Modifier.width(110.dp)
contentPadding = ButtonDefaults.TextButtonContentPadding,
modifier = Modifier.height(32.dp)
) {
Text(
"$text ${if (isOn) "开" else "关"}",
color = if (enabled) OverlayText else Color(0x88FFFFFF),
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
}
}
@Composable
private fun SectionLabel(text: String) {
Text(
text,
color = Color(0xFFB0B0B0),
fontSize = 11.sp,
modifier = Modifier.fillMaxWidth()
)
private fun StandToggleButton(
motionState: Int,
connected: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier
) {
val isStanding = motionState == ControlCommands.MOTION_STAND ||
motionState == ControlCommands.MOTION_RL_CONTROL
Button(
onClick = onToggle,
enabled = connected,
shape = ParallelogramShape(skewLeft = true),
colors = ButtonDefaults.buttonColors(
containerColor = if (isStanding) Color(0xCCFFC107) else Color(0x66333333),
disabledContainerColor = Color(0x33222222)
),
contentPadding = ButtonDefaults.TextButtonContentPadding,
modifier = modifier.height(32.dp)
) {
Text(
text = if (isStanding) "趴下" else "起立",
color = if (connected) OverlayText else Color(0x88FFFFFF),
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
}
}
// ==================== 模式选择器(3个互斥无缝隙) ====================
@Composable
private fun ModeSelector(
currentMode: Int,
connected: Boolean,
onSelectMode: (Int) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
ModeButton("常规", ControlCommands.MODE_NORMAL, currentMode, connected, onSelectMode)
ModeButton("导航", ControlCommands.MODE_NAVIGATION, currentMode, connected, onSelectMode)
ModeButton("辅助", ControlCommands.MODE_ASSIST, currentMode, connected, onSelectMode)
}
}
@Composable
private fun Separator() {
Spacer(Modifier.height(2.dp))
private fun ModeButton(
text: String,
mode: Int,
currentMode: Int,
connected: Boolean,
onClick: (Int) -> Unit
) {
val isActive = currentMode == mode
Button(
onClick = { onClick(mode) },
enabled = connected,
shape = ParallelogramShape(skewLeft = true),
colors = ButtonDefaults.buttonColors(
containerColor = if (isActive) Color(0xCCFFC107) else Color(0x66333333),
disabledContainerColor = Color(0x33222222)
),
contentPadding = ButtonDefaults.TextButtonContentPadding,
modifier = Modifier.height(32.dp)
) {
Text(
text,
color = if (connected) OverlayText else Color(0x88FFFFFF),
fontSize = 12.sp,
fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal
)
}
}
// ==================== 步态切换(4个互斥无缝隙,仅普通模式可用) ====================
@Composable
private fun GaitSelector(
currentGait: Int,
enabled: Boolean,
onSelectGait: (Int) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
GaitButton("基础", ControlCommands.GAIT_BASIC, currentGait, enabled, onSelectGait)
GaitButton("楼梯", ControlCommands.GAIT_STAIRS, currentGait, enabled, onSelectGait)
GaitButton("平地敏捷", ControlCommands.GAIT_FLAT_AGILE, currentGait, enabled, onSelectGait)
GaitButton("楼梯敏捷", ControlCommands.GAIT_STAIRS_AGILE, currentGait, enabled, onSelectGait)
}
}
@Composable
private fun GaitButton(
text: String,
gait: Int,
currentGait: Int,
enabled: Boolean,
onClick: (Int) -> Unit
) {
val isActive = currentGait == gait
Button(
onClick = { onClick(gait) },
enabled = enabled,
shape = ParallelogramShape(skewLeft = true),
colors = ButtonDefaults.buttonColors(
containerColor = if (isActive) Color(0xCCFFC107) else Color(0x66333333),
disabledContainerColor = Color(0x33222222)
),
contentPadding = ButtonDefaults.TextButtonContentPadding,
modifier = Modifier.height(32.dp)
) {
Text(
text,
color = if (enabled) OverlayText else Color(0x88FFFFFF),
fontSize = 12.sp,
fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal
)
}
}
// ==================== 休眠/唤醒切换 ====================
@Composable
private fun SleepToggleButton(
isSleeping: Boolean,
connected: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier
) {
Button(
onClick = onToggle,
enabled = connected,
shape = ParallelogramShape(skewLeft = false),
colors = ButtonDefaults.buttonColors(
containerColor = if (isSleeping) Color(0xCCFFC107) else Color(0x66333333),
disabledContainerColor = Color(0x33222222)
),
contentPadding = ButtonDefaults.TextButtonContentPadding,
modifier = modifier.height(32.dp)
) {
Text(
text = if (isSleeping) "唤醒" else "休眠",
color = if (connected) OverlayText else Color(0x88FFFFFF),
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
}
}
// ==================== 充电/结束充电切换 ====================
@Composable
private fun ChargeToggleButton(
isCharging: Boolean,
connected: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier
) {
Button(
onClick = onToggle,
enabled = connected,
shape = ParallelogramShape(skewLeft = false),
colors = ButtonDefaults.buttonColors(
containerColor = if (isCharging) Color(0xCCFFC107) else Color(0x66333333),
disabledContainerColor = Color(0x33222222)
),
contentPadding = ButtonDefaults.TextButtonContentPadding,
modifier = modifier.height(32.dp)
) {
Text(
text = if (isCharging) "充电中" else "充电",
color = if (connected) OverlayText else Color(0x88FFFFFF),
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
}
}
// ==================== 软急停滑块 ====================
/**
* 水平滑块,平行四边形风格。用于触发软急停。
*
* 滑到右侧 → 发送软急停指令;滑到左侧 → 仅 UI 复位,不发指令。
* 机器人收到软急停后 3 秒自动切回空闲,状态上报会驱动滑块自动归位。
*
* @param isEmergencyStop 当前是否处于软急停状态(motionState == MOTION_DAMPING
* @param connected 是否已连接(未连接时禁用交互)
* @param onEmergencyStop 滑块滑到右侧触发
*/
@Composable
private fun EmergencyStopSlider(
isEmergencyStop: Boolean,
connected: Boolean,
onEmergencyStop: () -> Unit,
modifier: Modifier = Modifier
) {
val sliderWidth = 150.dp
val sliderHeight = 32.dp
val thumbSize = 28.dp
// 0f = 左侧(正常),1f = 右侧(急停触发)
var fraction by remember { mutableStateOf(if (isEmergencyStop) 1f else 0f) }
var isDragging by remember { mutableStateOf(false) }
// 非拖动时跟随外部状态回调
LaunchedEffect(isEmergencyStop) {
if (!isDragging) {
fraction = if (isEmergencyStop) 1f else 0f
}
}
// 拖动结束后吸附:滑到右侧触发急停,滑到左侧仅复位 UI
fun snapAndFire() {
isDragging = false
if (fraction > 0.5f) {
fraction = 1f
onEmergencyStop()
} else {
fraction = 0f
// 不发送复位指令,等机器人状态上报自动同步
}
}
// 滑轨:遵循其他按钮的 containerColor 逻辑
val trackColor = if (connected) Color(0x66333333) else Color(0x33222222)
// 拇指:75% 红色
val thumbColor = Color(0xBFFF4444)
val skew = 3.dp
Box(
modifier = Modifier
.width(80.dp)
.height(1.dp)
.background(Color(0x44FFFFFF))
)
Spacer(Modifier.height(2.dp))
modifier = modifier
.width(sliderWidth)
.height(sliderHeight)
) {
// 滑轨 — 平行四边形外框
Surface(
modifier = Modifier
.fillMaxSize(),
shape = ParallelogramShape(skewDp = skew, skewLeft = false),
color = trackColor
) {}
// 文字显示在滑轨上
Text(
text = "急停",
modifier = Modifier.align(Alignment.Center),
color = Color(0xAAFFFFFF),
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
// 滑块拇指
val maxOffsetDp = sliderWidth - thumbSize
val density = LocalDensity.current
val maxOffsetPx = with(density) { maxOffsetDp.toPx() }
val thumbOffset = maxOffsetDp * fraction
// fraction=0 → offset=0 (左侧)
// fraction=1 → offset=maxOffsetDp (右侧)
Surface(
modifier = Modifier
.size(thumbSize, sliderHeight)
.offset(x = thumbOffset)
.pointerInput(connected) {
if (!connected) return@pointerInput
detectHorizontalDragGestures(
onDragStart = { isDragging = true },
onDragEnd = { snapAndFire() },
onDragCancel = { snapAndFire() },
onHorizontalDrag = { change, dragAmount ->
change.consume()
fraction = (fraction + dragAmount / maxOffsetPx).coerceIn(0f, 1f)
}
)
},
shape = ParallelogramShape(skewDp = skew, skewLeft = false),
color = thumbColor
) {}
}
}
// ==================== 辅助函数 ====================
@@ -229,7 +229,30 @@ class MainViewModel(
/** 状态上报分发:更新机器人运动状态机 + 各分项缓存。 */
private fun handleStatusReport(report: StatusReport) {
_latestStatus.value = report
// 合并:保留上次报告中非 null 的字段,避免部分报告覆盖导致 UI 闪烁
val prev = _latestStatus.value
_latestStatus.value = if (prev != null) {
prev.copy(
type = report.type,
command = report.command,
time = report.time,
errorCode = report.errorCode ?: prev.errorCode,
errorMessage = report.errorMessage ?: prev.errorMessage,
basicStatus = report.basicStatus ?: prev.basicStatus,
motionStatus = report.motionStatus ?: prev.motionStatus,
motorStatus = report.motorStatus ?: prev.motorStatus,
batteryList = report.batteryList ?: prev.batteryList,
batteryStatus = report.batteryStatus ?: prev.batteryStatus,
deviceTemperature = report.deviceTemperature ?: prev.deviceTemperature,
led = report.led ?: prev.led,
gps = report.gps ?: prev.gps,
devEnable = report.devEnable ?: prev.devEnable,
cpu = report.cpu ?: prev.cpu,
errorList = report.errorList ?: prev.errorList
)
} else {
report
}
_lastUpdate.value = report.time
robot.updateFromReport(report)
report.errorList?.let { _errorList.value = it }
@@ -292,6 +315,15 @@ class MainViewModel(
client?.sendCommandBlocking(ControlCommands.chargeControl(charge))
}
/** 软急停:进入关节阻尼状态。 */
fun sendEmergencyStop() {
if (robot.canSendMotionControl) {
client?.sendCommandBlocking(ControlCommands.softEmergencyStop())
} else {
_actionFeedback.value = "未连接,无法发送软急停"
}
}
fun sendSleepSettings(sleep: Boolean, auto: Boolean = false, timeMinutes: Int = 5) {
client?.sendCommandBlocking(
ControlCommands.sleepSettings(sleep, auto, timeMinutes)