From 9b8cd09929175cc97c8e22eba0ead3cb70606ae7 Mon Sep 17 00:00:00 2001 From: CaoWangrenbo Date: Sat, 18 Jul 2026 14:59:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8F=B3=E4=B8=8A=E8=A7=92=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E8=BD=AF=E6=80=A5=E5=81=9C=E6=BB=91=E5=9D=97=EF=BC=88?= =?UTF-8?q?=E5=B9=B3=E8=A1=8C=E5=9B=9B=E8=BE=B9=E5=BD=A2=E9=A3=8E=E6=A0=BC?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 EmergencyStopSlider 组件,水平滑到右侧触发软急停指令, 滑到左侧仅 UI 复位,等待机器人状态上报自动归位。非拖动时 跟随 motionState 状态同步滑块位置。 ControlCommands 新增 softEmergencyStop() 便捷函数。 Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- AGENTS.md | 1 + .../network/models/ControlCommands.kt | 3 + .../com/example/m20_gamepad/ui/MainScreen.kt | 116 +++++++++++++++++- .../example/m20_gamepad/ui/MainViewModel.kt | 9 ++ 4 files changed, 128 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index c802706..619e1cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -212,6 +212,7 @@ gradlew installDebug - [x] 连接按钮交互优化:`CONNECTING` 状态下点击连接按钮仅断开回到 `DISCONNECTED`,不再自动重连,由用户手动再次点击发起重试 - [x] 状态机补充:`RobotConnection` 允许 `CONNECTING → TIMEOUT` 合法转换 - [x] 心跳启动时机修复:`ProtocolClient.connect()` 立即启动心跳线程,不再等待首次收到数据包后才启动;心跳在 `CONNECTING` 和 `CONNECTED` 状态下均正常运行 +- [x] 软急停滑块:右上角新增平行四边形水平滑块,滑到右侧触发软急停(`MOTION_DAMPING`),跟随机器人状态上报自动归位 ### 待办 diff --git a/app/src/main/java/com/example/m20_gamepad/network/models/ControlCommands.kt b/app/src/main/java/com/example/m20_gamepad/network/models/ControlCommands.kt index 17f87c2..eb95cc6 100644 --- a/app/src/main/java/com/example/m20_gamepad/network/models/ControlCommands.kt +++ b/app/src/main/java/com/example/m20_gamepad/network/models/ControlCommands.kt @@ -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 diff --git a/app/src/main/java/com/example/m20_gamepad/ui/MainScreen.kt b/app/src/main/java/com/example/m20_gamepad/ui/MainScreen.kt index 6c58d81..c049325 100644 --- a/app/src/main/java/com/example/m20_gamepad/ui/MainScreen.kt +++ b/app/src/main/java/com/example/m20_gamepad/ui/MainScreen.kt @@ -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,6 +14,7 @@ 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 @@ -43,8 +45,10 @@ 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 @@ -218,7 +222,7 @@ fun MainScreen( ) } - // ---- 右上角控制区:照明 + 休眠 + 充电 ---- + // ---- 右上角控制区:软急停滑块 + 照明 + 休眠 + 充电 ---- Column( modifier = Modifier .align(Alignment.TopEnd) @@ -226,6 +230,12 @@ fun MainScreen( 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 @@ -696,6 +706,110 @@ private fun ChargeToggleButton( } } +// ==================== 软急停滑块 ==================== + +/** + * 水平滑块,平行四边形风格。用于触发软急停。 + * + * 滑到右侧 → 发送软急停指令;滑到左侧 → 仅 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(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 + ) {} + } +} + // ==================== 辅助函数 ==================== private fun motionStateText(state: Int): String = when (state) { diff --git a/app/src/main/java/com/example/m20_gamepad/ui/MainViewModel.kt b/app/src/main/java/com/example/m20_gamepad/ui/MainViewModel.kt index 440b908..d451080 100644 --- a/app/src/main/java/com/example/m20_gamepad/ui/MainViewModel.kt +++ b/app/src/main/java/com/example/m20_gamepad/ui/MainViewModel.kt @@ -292,6 +292,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)