Compare commits
10 Commits
f055777a6f
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 9883eb4b44 | |||
| 28438d05bb | |||
| dae40cc73c | |||
| 3225ab0922 | |||
| 9b8cd09929 | |||
| d4db91235b | |||
| fbc3f1529f | |||
| b2adf46957 | |||
| df35ff3b05 | |||
| 1b5bc582b9 |
Generated
+1
-1
@@ -4,7 +4,7 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-07-17T12:57:33.559741200Z">
|
||||
<DropdownSelection timestamp="2026-07-17T16:06:27.410601800Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\evan\.android\avd\Medium_Phone.avd" />
|
||||
|
||||
Generated
+8
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
@@ -133,12 +133,14 @@ val y = power / 100.0 * sin(angle) // 水平分量
|
||||
|
||||
## 构建与运行
|
||||
|
||||
```bash
|
||||
# 在项目根目录
|
||||
./gradlew assembleDebug
|
||||
> **注意**:项目源码位于 Windows 目录下,通过 WSL 访问和编辑。WSL 环境中没有 Android SDK / Gradle,无法直接运行构建命令。如需构建或安装,请在 Windows 端的 Android Studio 或终端中执行。
|
||||
|
||||
# 安装到设备
|
||||
./gradlew installDebug
|
||||
```bash
|
||||
# 在项目根目录(Windows 端)
|
||||
gradlew assembleDebug
|
||||
|
||||
# 安装到设备(Windows 端)
|
||||
gradlew installDebug
|
||||
```
|
||||
|
||||
### 依赖
|
||||
@@ -157,9 +159,10 @@ val y = power / 100.0 * sin(angle) // 水平分量
|
||||
- **协程**: 网络 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`
|
||||
|
||||
---
|
||||
|
||||
@@ -185,18 +188,38 @@ val y = power / 100.0 * sin(angle) // 水平分量
|
||||
- [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] 状态上报 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 下发)
|
||||
- [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_*` 常量,彻底清除死代码
|
||||
|
||||
### 待办
|
||||
|
||||
- [ ] UI 布局:header 部分虽已避开状态栏,但底色与屏幕顶端视觉不吸附,需优化视觉传达效果
|
||||
- [ ] UI 布局:功能按钮均布在四周,充分利用屏幕空间,优化单手操作体验
|
||||
- [ ] 状态栏:增加 ICMP 延迟测量显示,显示当前 WiFi 强度和延迟 ms
|
||||
- [ ] 视频进一步延迟优化:探索更激进的低延迟策略(如降低帧率、缩小分辨率、调整 ExoPlayer 缓冲策略等)
|
||||
- [ ] 视频进一步延迟优化:探索更激进的低延迟策略(跳过 Media3 ExoPlayer,直接使用 MediaCodec + SurfaceView)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".M20App"
|
||||
|
||||
@@ -6,6 +6,8 @@ import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
@@ -18,6 +20,11 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
// 隐藏系统状态栏,顶部区域完全由自定义 StatusBar 接管
|
||||
WindowInsetsControllerCompat(window, window.decorView).apply {
|
||||
hide(WindowInsetsCompat.Type.statusBars())
|
||||
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
}
|
||||
setContent {
|
||||
M20_gamepadTheme {
|
||||
AppNavGraph()
|
||||
|
||||
@@ -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 秒无数据 → TIMEOUT;CONNECTING 状态 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
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.example.m20_gamepad.service
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
|
||||
/**
|
||||
* ICMP 延迟监测器。使用系统 ping 命令测量到目标主机的往返延迟。
|
||||
*
|
||||
* @param host 目标主机 IP
|
||||
* @param intervalMs 测量间隔(毫秒)
|
||||
* @param scope 协程作用域
|
||||
*/
|
||||
class PingMonitor(
|
||||
private val host: String,
|
||||
private val intervalMs: Long = 3000L,
|
||||
private val scope: CoroutineScope
|
||||
) {
|
||||
private val _latencyMs = MutableStateFlow<Long?>(null)
|
||||
val latencyMs: StateFlow<Long?> = _latencyMs.asStateFlow()
|
||||
|
||||
private var job: Job? = null
|
||||
|
||||
fun start() {
|
||||
stop()
|
||||
job = scope.launch {
|
||||
while (isActive) {
|
||||
_latencyMs.value = ping()
|
||||
delay(intervalMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
_latencyMs.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一次 ICMP ping,返回延迟毫秒数;失败返回 null。
|
||||
*/
|
||||
private fun ping(): Long? {
|
||||
return try {
|
||||
val process = ProcessBuilder(
|
||||
"ping", "-c", "1", "-W", "2", host
|
||||
).redirectErrorStream(true).start()
|
||||
|
||||
val reader = BufferedReader(InputStreamReader(process.inputStream))
|
||||
val output = reader.readText()
|
||||
process.waitFor()
|
||||
|
||||
// 解析 "time=12.3 ms" 格式
|
||||
val regex = Regex("time=(\\d+(?:\\.\\d+)?)\\s*ms")
|
||||
val match = regex.find(output)
|
||||
match?.groupValues?.get(1)?.toDouble()?.toLong()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ||
|
||||
|
||||
@@ -2,6 +2,8 @@ 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
|
||||
@@ -12,15 +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.statusBars
|
||||
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,11 +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
|
||||
@@ -52,6 +62,7 @@ import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.erz.joysticklibrary.Joystick
|
||||
import com.erz.joysticklibrary.JoystickType
|
||||
import com.example.m20_gamepad.M20App
|
||||
import com.example.m20_gamepad.R
|
||||
import com.example.m20_gamepad.data.SettingsRepository
|
||||
import com.example.m20_gamepad.network.ProtocolClient
|
||||
@@ -64,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 视频背景 + 双摇杆覆盖层 + 状态栏 + 控制按钮。
|
||||
*
|
||||
@@ -73,13 +116,14 @@ private val OverlayText = Color.White
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
repository: SettingsRepository,
|
||||
onSettingsClick: () -> Unit,
|
||||
viewModel: MainViewModel = viewModel(
|
||||
onSettingsClick: () -> Unit
|
||||
) {
|
||||
val app = LocalContext.current.applicationContext as M20App
|
||||
val viewModel: MainViewModel = viewModel(
|
||||
factory = viewModelFactory {
|
||||
initializer { MainViewModel(repository) }
|
||||
initializer { MainViewModel(repository, app) }
|
||||
}
|
||||
)
|
||||
) {
|
||||
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val status by viewModel.latestStatus.collectAsStateWithLifecycle()
|
||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||
@@ -91,6 +135,8 @@ fun MainScreen(
|
||||
val ledStatus by viewModel.ledStatus.collectAsStateWithLifecycle()
|
||||
val lastUpdate by viewModel.lastUpdate.collectAsStateWithLifecycle()
|
||||
val actionFeedback by viewModel.actionFeedback.collectAsStateWithLifecycle()
|
||||
val pingLatencyMs by viewModel.pingLatencyMs.collectAsStateWithLifecycle()
|
||||
val wifiSignalLevel by viewModel.wifiSignalLevel.collectAsStateWithLifecycle()
|
||||
|
||||
var statusPanelVisible by remember { mutableStateOf(false) }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
@@ -125,19 +171,114 @@ fun MainScreen(
|
||||
StatusBar(
|
||||
connectionState = connectionState,
|
||||
status = status,
|
||||
batteryStatus = batteryStatus,
|
||||
pingLatencyMs = pingLatencyMs,
|
||||
wifiSignalLevel = wifiSignalLevel,
|
||||
onStatusClick = { statusPanelVisible = true },
|
||||
onSettingsClick = onSettingsClick,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
onConnectToggle = {
|
||||
if (connectionState == ProtocolClient.State.CONNECTED ||
|
||||
connectionState == ProtocolClient.State.CONNECTING
|
||||
) {
|
||||
viewModel.disconnect()
|
||||
} else {
|
||||
viewModel.connect()
|
||||
}
|
||||
},
|
||||
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)
|
||||
@@ -169,27 +310,41 @@ 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,
|
||||
onSettingsClick: () -> Unit,
|
||||
onConnectToggle: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(OverlayBg)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// 连接状态指示
|
||||
ConnectionIndicator(connectionState)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// 连接状态 + WiFi 信号 + Ping 延迟
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ConnectionIndicator(connectionState, onConnectToggle)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
WifiSignalIcon(level = wifiSignalLevel)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
PingIndicator(pingLatencyMs)
|
||||
}
|
||||
|
||||
// 电量 / 运动状态
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
status?.batteryStatus?.let {
|
||||
batteryStatus?.let {
|
||||
val avgLevel = (it.batteryLevelLeft + it.batteryLevelRight) / 2
|
||||
StatusChip("电量 ${avgLevel.toInt()}%")
|
||||
}
|
||||
@@ -216,10 +371,11 @@ private fun StatusBar(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectionIndicator(state: ProtocolClient.State) {
|
||||
private fun ConnectionIndicator(state: ProtocolClient.State, onClick: () -> Unit) {
|
||||
val (color, text) = when (state) {
|
||||
ProtocolClient.State.CONNECTED -> Color(0xFF4CAF50) to "已连接"
|
||||
ProtocolClient.State.CONNECTING -> Color(0xFFFFC107) to "连接中"
|
||||
@@ -227,14 +383,64 @@ private fun ConnectionIndicator(state: ProtocolClient.State) {
|
||||
ProtocolClient.State.ERROR -> Color(0xFFF44336) to "错误"
|
||||
ProtocolClient.State.DISCONNECTED -> Color(0xFF9E9E9E) to "未连接"
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.background(color, CircleShape)
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(text, color = OverlayText, fontSize = 14.sp)
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
border = BorderStroke(1.dp, Color(0x55FFFFFF)),
|
||||
color = Color(0x22000000)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.background(color, CircleShape)
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(text, color = OverlayText, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** WiFi 信号强度图标(0-5 格信号柱),白色填充、灰色未填充。 */
|
||||
@Composable
|
||||
private fun WifiSignalIcon(level: Int) {
|
||||
val activeColor = OverlayText
|
||||
val inactiveColor = Color(0x66FFFFFF)
|
||||
val clamped = level.coerceIn(0, 5)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
// 5 根信号柱,从低到高
|
||||
val barHeights = intArrayOf(6, 10, 14, 18, 22)
|
||||
for (i in 0 until 5) {
|
||||
val isActive = i < clamped
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(4.dp)
|
||||
.height(barHeights[i].dp)
|
||||
.background(if (isActive) activeColor else inactiveColor, RoundedCornerShape(1.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PingIndicator(latencyMs: Long?) {
|
||||
if (latencyMs == null) {
|
||||
Text("- -", color = Color(0xFF9E9E9E), fontSize = 13.sp)
|
||||
} else {
|
||||
val color = when {
|
||||
latencyMs < 50 -> Color(0xFF4CAF50) // 绿色 — 良好
|
||||
latencyMs < 150 -> Color(0xFFFFC107) // 黄色 — 一般
|
||||
latencyMs < 300 -> Color(0xFFFF9800) // 橙色 — 较差
|
||||
else -> Color(0xFFF44336) // 红色 — 很差
|
||||
}
|
||||
Text("${latencyMs}ms", color = color, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,7 +466,6 @@ private fun BottomControls(
|
||||
viewModel: MainViewModel,
|
||||
connectionState: ProtocolClient.State,
|
||||
motionState: Int,
|
||||
ledStatus: LedStatus?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
@@ -281,14 +486,6 @@ private fun BottomControls(
|
||||
}
|
||||
)
|
||||
|
||||
// 中间控制按钮组
|
||||
ControlButtons(
|
||||
viewModel = viewModel,
|
||||
connectionState = connectionState,
|
||||
motionState = motionState,
|
||||
ledStatus = ledStatus
|
||||
)
|
||||
|
||||
// 右摇杆(Yaw 旋转)
|
||||
Joystick(
|
||||
modifier = Modifier.size(180.dp),
|
||||
@@ -302,183 +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)
|
||||
) {
|
||||
// 连接/断开
|
||||
Button(
|
||||
onClick = {
|
||||
if (connected) viewModel.disconnect() else viewModel.connect()
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (connected) Color(0xFFF44336) else Color(0xFF4CAF50)
|
||||
),
|
||||
modifier = Modifier.width(110.dp)
|
||||
) {
|
||||
Text(if (connected) "断开" else "连接")
|
||||
}
|
||||
|
||||
// 运动状态转换
|
||||
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,
|
||||
@@ -489,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
|
||||
) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.example.m20_gamepad.ui
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.net.wifi.WifiManager
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.m20_gamepad.data.AppSettings
|
||||
@@ -14,6 +17,7 @@ import com.example.m20_gamepad.network.models.MotionStatus
|
||||
import com.example.m20_gamepad.network.models.StatusReport
|
||||
import com.example.m20_gamepad.service.JoystickController
|
||||
import com.example.m20_gamepad.service.MotionTransitionResult
|
||||
import com.example.m20_gamepad.service.PingMonitor
|
||||
import com.example.m20_gamepad.service.RobotConnection
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -40,7 +44,8 @@ import kotlinx.coroutines.launch
|
||||
* @param repository 应用级设置仓库
|
||||
*/
|
||||
class MainViewModel(
|
||||
private val repository: SettingsRepository
|
||||
private val repository: SettingsRepository,
|
||||
private val application: Application
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
@@ -81,6 +86,18 @@ class MainViewModel(
|
||||
private val _actionFeedback = MutableStateFlow<String?>(null)
|
||||
val actionFeedback: StateFlow<String?> = _actionFeedback.asStateFlow()
|
||||
|
||||
/** ICMP 延迟(毫秒),null 表示无数据或未连接。 */
|
||||
private val _pingLatencyMs = MutableStateFlow<Long?>(null)
|
||||
val pingLatencyMs: StateFlow<Long?> = _pingLatencyMs.asStateFlow()
|
||||
|
||||
private var pingMonitor: PingMonitor? = null
|
||||
|
||||
/** WiFi 信号强度级别(0-5 格),0=无WiFi/无信号。 */
|
||||
private val _wifiSignalLevel = MutableStateFlow(0)
|
||||
val wifiSignalLevel: StateFlow<Int> = _wifiSignalLevel.asStateFlow()
|
||||
|
||||
private var wifiJob: Job? = null
|
||||
|
||||
/** 当前设置快照(主界面用于驱动视频播放器地址/编码器) */
|
||||
val settings: StateFlow<AppSettings> = repository.settings.stateIn(
|
||||
scope = viewModelScope,
|
||||
@@ -101,6 +118,17 @@ class MainViewModel(
|
||||
val isConnected: Boolean get() = robot.isConnected
|
||||
|
||||
init {
|
||||
// 启动 ICMP 延迟监测(独立于连接生命周期)
|
||||
restartPingMonitor(settings.value.robotHost)
|
||||
// 设置中 host 变更时重启 ping
|
||||
viewModelScope.launch {
|
||||
settings
|
||||
.map { it.robotHost }
|
||||
.distinctUntilChanged()
|
||||
.collect { host -> restartPingMonitor(host) }
|
||||
}
|
||||
// 启动 WiFi 信号强度监测(独立于连接生命周期)
|
||||
startWifiMonitoring()
|
||||
// 设置变更时断开当前连接,下次 connect 使用新参数
|
||||
settingsWatchJob = viewModelScope.launch {
|
||||
settings
|
||||
@@ -110,6 +138,44 @@ class MainViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动 WiFi 信号强度周期性监测。 */
|
||||
private fun startWifiMonitoring() {
|
||||
wifiJob?.cancel()
|
||||
wifiJob = viewModelScope.launch {
|
||||
val wifiManager = application.getSystemService(Context.WIFI_SERVICE) as? WifiManager
|
||||
if (wifiManager == null) {
|
||||
_wifiSignalLevel.value = 0
|
||||
return@launch
|
||||
}
|
||||
while (isActive) {
|
||||
val info = wifiManager.connectionInfo
|
||||
val rssi = info?.rssi ?: -127
|
||||
_wifiSignalLevel.value = if (rssi == -127 || info == null) {
|
||||
0
|
||||
} else {
|
||||
when {
|
||||
rssi >= -50 -> 5
|
||||
rssi >= -60 -> 4
|
||||
rssi >= -70 -> 3
|
||||
rssi >= -80 -> 2
|
||||
rssi >= -90 -> 1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
delay(3000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 重启 PingMonitor(目标 host 变更时调用)。 */
|
||||
private fun restartPingMonitor(host: String) {
|
||||
pingMonitor?.stop()
|
||||
pingMonitor = PingMonitor(host, scope = viewModelScope).also { it.start() }
|
||||
viewModelScope.launch {
|
||||
pingMonitor?.latencyMs?.collect { _pingLatencyMs.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立连接并启动轴指令循环。参数取自当前设置。
|
||||
*
|
||||
@@ -163,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 }
|
||||
@@ -226,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)
|
||||
@@ -266,6 +364,9 @@ class MainViewModel(
|
||||
|
||||
override fun onCleared() {
|
||||
disconnect()
|
||||
wifiJob?.cancel()
|
||||
pingMonitor?.stop()
|
||||
pingMonitor = null
|
||||
settingsWatchJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user