Compare commits

..

10 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
hexone2086 df35ff3b05 feat: 状态栏优化 — 背景吸附+连接按钮合并+隐藏系统状态栏
- StatusBar 重构为 Box + Row 双层结构,背景延伸到屏幕顶端
- 连接按钮合并到顶部 ConnectionIndicator,删除底部独立连接按钮
- ConnectionIndicator 改为按钮形态(Surface + 圆角边框)
- 隐藏系统状态栏(WindowInsetsControllerCompat),压缩 StatusBar 高度

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-07-18 00:54:13 +08:00
hexone2086 1b5bc582b9 feat: 状态栏增加 WiFi 信号强度图标 + ICMP 延迟显示
- 新增 PingMonitor.kt:系统 ping 命令测量 ICMP 延迟,每 3 秒一次
- 新增 WifiSignalMonitor(内联在 MainViewModel):WiFiManager 读取 RSSI,
  按阈值映射为 0-5 格信号强度
- MainViewModel 改用 Application 注入以访问系统服务;PingMonitor 和 WiFi
  监测均独立于 UDP 连接生命周期(init 启动,onCleared 停止)
- MainScreen StatusBar 重新布局:
  连接状态文字 | WiFi 信号柱图标 | Ping 延迟 ms | 电量% | 运动状态 | Info | Settings
- AGENTS.md 待办项移至已完成

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-07-18 00:13:42 +08:00
11 changed files with 784 additions and 266 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <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"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\evan\.android\avd\Medium_Phone.avd" /> <DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\evan\.android\avd\Medium_Phone.avd" />
+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>
+42 -19
View File
@@ -37,7 +37,7 @@ com.example.m20_gamepad/
│ ├── RobotConnection.kt # 连接状态机管理(连接/订阅/心跳/断线判定) │ ├── RobotConnection.kt # 连接状态机管理(连接/订阅/心跳/断线判定)
│ └── JoystickController.kt # 摇杆输入 → 协议指令映射 │ └── JoystickController.kt # 摇杆输入 → 协议指令映射
├── video/ ├── video/
│ └── RtspSurfaceView.kt # RTSP 视频流播放(SurfaceView 层 │ └── RtspVideoPlayer.kt # RTSP 视频流播放(ExoPlayer + Composable 包装
├── data/ ├── data/
│ ├── SettingsRepository.kt # 设置项读写(DataStore 封装) │ ├── SettingsRepository.kt # 设置项读写(DataStore 封装)
│ └── AppSettings.kt # 配置数据类定义 + 默认值 │ └── 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字节)` - **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": {}}}` - **ASDU 固定外层**: `{"PatrolDevice": {"Type": int, "Command": int, "Time": "YYYY-MM-DD HH:mm:ss", "Items": {}}}`
- **关键指令**: - **关键指令**:
- 心跳: Type=100, Cmd=100, ≥1 Hz - 心跳Type=100, Cmd=100, ≥1 Hz
- 轴指令: Type=2, Cmd=21, Items: {X,Y,Z,Roll,Pitch,Yaw}, [-1,1], ≥20 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=25, Items: {X,Y,Z,Roll,Pitch,Yaw}, 物理单位≥10 Hz
- 运动状态转换: Type=2, Cmd=22, Items: {MotionParam} - 运动状态转换Type=2, Cmd=22, Items: {MotionParam}
- 步态切换: Type=2, Cmd=23, Items: {GaitParam} - 步态切换Type=2, Cmd=23, Items: {GaitParam}
- 使用模式切换: Type=1101, Cmd=5, Items: {Mode} - 使用模式切换Type=1101, Cmd=5, Items: {Mode}
- **状态上报**: 上行响应含 ErrorCode/ErrorMessage; 主动订阅制(Type=1002, Cmd=3/4/5/6 - **状态上报**: 上行响应含 ErrorCode/ErrorMessage; 主动订阅制(Type=1002, Cmd=3/4/5/6
--- ---
@@ -133,12 +133,14 @@ val y = power / 100.0 * sin(angle) // 水平分量
## 构建与运行 ## 构建与运行
```bash > **注意**:项目源码位于 Windows 目录下,通过 WSL 访问和编辑。WSL 环境中没有 Android SDK / Gradle,无法直接运行构建命令。如需构建或安装,请在 Windows 端的 Android Studio 或终端中执行。
# 在项目根目录
./gradlew assembleDebug
# 安装到设备 ```bash
./gradlew installDebug # 在项目根目录(Windows 端)
gradlew assembleDebug
# 安装到设备(Windows 端)
gradlew installDebug
``` ```
### 依赖 ### 依赖
@@ -157,9 +159,10 @@ val y = power / 100.0 * sin(angle) // 水平分量
- **协程**: 网络 I/O 使用 `kotlinx.coroutines`,避免裸线程 - **协程**: 网络 I/O 使用 `kotlinx.coroutines`,避免裸线程
- **UDP**: 使用 `java.net.DatagramSocket`,绑定本地随机端口 - **UDP**: 使用 `java.net.DatagramSocket`,绑定本地随机端口
- **状态管理**: 使用 `StateFlow` 暴露连接状态、机器人状态 - **状态管理**: 使用 `StateFlow` 暴露连接状态、机器人状态
- **状态上报合并策略**: `handleStatusReport``_latestStatus` 采用合并(`prev.copy`)而非替换,避免 4 个独立订阅(Basic/Error/Motion/Device)的局部报告覆盖整个状态导致 UI 闪烁。各分项(`batteryStatus``motionStatus` 等)同时维护独立 StateFlow,供 UI 直接订阅
- **生命周期**: 网络连接生命周期绑定到 Activity/Service,前台时活跃 - **生命周期**: 网络连接生命周期绑定到 Activity/Service,前台时活跃
- **MsgId**: 从 0 递增 u16,循环回绕 - **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()`NavHoststartDestination=MAIN,主界面齿轮按钮跳转设置,返回/保存后 popBackStack 到 MAIN - [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] 连接参数从设置读取: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] 连接状态机: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] 更多控制按钮: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] 摇杆尺寸增大:左右摇杆从 140dp 增大到 180dp,提升操控精度
- [x] 视频缩放模式:`VideoResizeMode` 枚举(FIT/ZOOM/FILL),设置界面新增"视频缩放模式"下拉选项,持久化保存,实时生效 - [x] 视频缩放模式:`VideoResizeMode` 枚举(FIT/ZOOM/FILL),设置界面新增"视频缩放模式"下拉选项,持久化保存,实时生效
- [x] 状态机修复:趴下状态下允许切换站立状态(机器人站立后自动进入 RL 控制,无需 App 下发) - [x] 状态机修复:趴下状态下允许切换站立状态(机器人站立后自动进入 RL 控制,无需 App 下发)
- [x] UI 布局:增加 bg1.png 作为底图,无视频流时显示(视频流播放时覆盖底图) - [x] UI 布局:增加 bg1.png 作为底图,无视频流时显示(视频流播放时覆盖底图)
- [x] UI 布局:开关灯合并为前灯/后灯两个按键,通过颜色(琥珀色=开/暗色=关)表示置位状态,配合机器人回报状态同步 - [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 部分虽已避开状态栏,但底色与屏幕顶端视觉不吸附,需优化视觉传达效果 - [ ] 视频进一步延迟优化:探索更激进的低延迟策略(跳过 Media3 ExoPlayer,直接使用 MediaCodec + SurfaceView
- [ ] UI 布局:功能按钮均布在四周,充分利用屏幕空间,优化单手操作体验
- [ ] 状态栏:增加 ICMP 延迟测量显示,显示当前 WiFi 强度和延迟 ms
- [ ] 视频进一步延迟优化:探索更激进的低延迟策略(如降低帧率、缩小分辨率、调整 ExoPlayer 缓冲策略等)
+1
View File
@@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<application <application
android:name=".M20App" android:name=".M20App"
@@ -6,6 +6,8 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext 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.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
@@ -18,6 +20,11 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
// 隐藏系统状态栏,顶部区域完全由自定义 StatusBar 接管
WindowInsetsControllerCompat(window, window.decorView).apply {
hide(WindowInsetsCompat.Type.statusBars())
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
setContent { setContent {
M20_gamepadTheme { M20_gamepadTheme {
AppNavGraph() AppNavGraph()
@@ -89,9 +89,11 @@ class ProtocolClient(
/** /**
* 建立连接: * 建立连接:
* 1. 创建 UDP socket * 1. 创建 UDP socket
* 2. 启动接收协程 * 2. 启动接收/发送协程
* 3. 发送订阅请求 * 3. 发送订阅请求
* 4. 启动心跳和断线检测 * 4. 启动断线检测CONNECTING 5s / CONNECTED 3s 无数据 → TIMEOUT
* 5. 启动心跳 1Hz(立即发送,覆盖 CONNECTING 和 CONNECTED 状态)
* 6. 首次收到有效数据包后标记已连接
*/ */
fun connect() { fun connect() {
if (_state.value != State.DISCONNECTED && _state.value != State.ERROR) return if (_state.value != State.DISCONNECTED && _state.value != State.ERROR) return
@@ -131,17 +133,15 @@ class ProtocolClient(
// 发送订阅请求(触发服务端 UDP 推送) // 发送订阅请求(触发服务端 UDP 推送)
sendSubscriptionRequests() sendSubscriptionRequests()
_state.value = State.CONNECTED // 启动断线检测(立即开始,覆盖 CONNECTING 和 CONNECTED 状态)
// 启动心跳
heartbeatJob = scope.launch(Dispatchers.IO) {
heartbeatLoop(sock, address)
}
// 启动断线检测
timeoutJob = scope.launch { timeoutJob = scope.launch {
timeoutCheckLoop() timeoutCheckLoop()
} }
// 启动心跳(立即开始,覆盖 CONNECTING 和 CONNECTED 状态)
heartbeatJob = scope.launch(Dispatchers.IO) {
heartbeatLoop(sock, address)
}
} catch (e: Exception) { } catch (e: Exception) {
_state.value = State.ERROR _state.value = State.ERROR
closeSocket() closeSocket()
@@ -232,6 +232,11 @@ class ProtocolClient(
if (report != null) { if (report != null) {
_statusReport.tryEmit(report) _statusReport.tryEmit(report)
} }
// 首次收到有效数据包:标记已连接
if (_state.value == State.CONNECTING) {
_state.value = State.CONNECTED
}
} catch (_: SocketTimeoutException) { } catch (_: SocketTimeoutException) {
// 超时正常,继续循环 // 超时正常,继续循环
continue continue
@@ -244,9 +249,11 @@ class ProtocolClient(
} }
} }
/** 心跳循环(1 Hz */ /** 心跳循环(1 Hz—— 在 CONNECTING 和 CONNECTED 状态下运行 */
private suspend fun CoroutineScope.heartbeatLoop(sock: DatagramSocket, address: InetAddress) { 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 { try {
val json = ControlCommands.heartbeat() val json = ControlCommands.heartbeat()
val packet = encoder.encode(json) val packet = encoder.encode(json)
@@ -258,12 +265,22 @@ class ProtocolClient(
} }
} }
/** 断线检测:3 秒无数据 → 标记 TIMEOUT */ /** 断线检测:CONNECTED 状态 3 秒无数据 → TIMEOUTCONNECTING 状态 5 秒无数据 → TIMEOUT */
private suspend fun CoroutineScope.timeoutCheckLoop() { private suspend fun CoroutineScope.timeoutCheckLoop() {
while (isActive && _state.value != State.DISCONNECTED) { while (isActive && _state.value != State.DISCONNECTED) {
val elapsed = System.currentTimeMillis() - lastPacketTimeMs val elapsed = System.currentTimeMillis() - lastPacketTimeMs
if (elapsed > 3000 && _state.value == State.CONNECTED) { when (_state.value) {
_state.value = State.TIMEOUT State.CONNECTED -> {
if (elapsed > 3000) {
_state.value = State.TIMEOUT
}
}
State.CONNECTING -> {
if (elapsed > 5000) {
_state.value = State.TIMEOUT
}
}
else -> { /* DISCONNECTED/TIMEOUT/ERROR: 无需超时处理 */ }
} }
delay(500) delay(500)
} }
@@ -107,6 +107,9 @@ object ControlCommands {
put("Charge", JsonPrimitive(charge)) put("Charge", JsonPrimitive(charge))
} }
// ---- 2.8b 软急停 ----
fun softEmergencyStop(time: String = now()): String = motionControl(MOTION_DAMPING, time)
// ---- 2.9 休眠模式设置 ---- // ---- 2.9 休眠模式设置 ----
fun sleepSettings(sleep: Boolean, auto: Boolean, timeMinutes: Int, time: String = now()): String = buildAsdu( fun sleepSettings(sleep: Boolean, auto: Boolean, timeMinutes: Int, time: String = now()): String = buildAsdu(
type = 1101, command = 6, time = time 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 -> ProtocolClient.State.CONNECTING ->
to == ProtocolClient.State.CONNECTED || to == ProtocolClient.State.CONNECTED ||
to == ProtocolClient.State.ERROR || to == ProtocolClient.State.ERROR ||
to == ProtocolClient.State.TIMEOUT ||
to == ProtocolClient.State.DISCONNECTED to == ProtocolClient.State.DISCONNECTED
ProtocolClient.State.CONNECTED -> ProtocolClient.State.CONNECTED ->
to == ProtocolClient.State.TIMEOUT || to == ProtocolClient.State.TIMEOUT ||
@@ -2,6 +2,8 @@ package com.example.m20_gamepad.ui
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
@@ -40,11 +40,21 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color 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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -52,6 +62,7 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.erz.joysticklibrary.Joystick import com.erz.joysticklibrary.Joystick
import com.erz.joysticklibrary.JoystickType import com.erz.joysticklibrary.JoystickType
import com.example.m20_gamepad.M20App
import com.example.m20_gamepad.R import com.example.m20_gamepad.R
import com.example.m20_gamepad.data.SettingsRepository import com.example.m20_gamepad.data.SettingsRepository
import com.example.m20_gamepad.network.ProtocolClient 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 OverlayBg = Color(0x88000000)
private val OverlayText = Color.White 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 视频背景 + 双摇杆覆盖层 + 状态栏 + 控制按钮。 * 主界面:RTSP 视频背景 + 双摇杆覆盖层 + 状态栏 + 控制按钮。
* *
@@ -73,13 +116,14 @@ private val OverlayText = Color.White
@Composable @Composable
fun MainScreen( fun MainScreen(
repository: SettingsRepository, repository: SettingsRepository,
onSettingsClick: () -> Unit, onSettingsClick: () -> Unit
viewModel: MainViewModel = viewModel( ) {
val app = LocalContext.current.applicationContext as M20App
val viewModel: MainViewModel = viewModel(
factory = viewModelFactory { factory = viewModelFactory {
initializer { MainViewModel(repository) } initializer { MainViewModel(repository, app) }
} }
) )
) {
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val status by viewModel.latestStatus.collectAsStateWithLifecycle() val status by viewModel.latestStatus.collectAsStateWithLifecycle()
val settings by viewModel.settings.collectAsStateWithLifecycle() val settings by viewModel.settings.collectAsStateWithLifecycle()
@@ -91,6 +135,8 @@ fun MainScreen(
val ledStatus by viewModel.ledStatus.collectAsStateWithLifecycle() val ledStatus by viewModel.ledStatus.collectAsStateWithLifecycle()
val lastUpdate by viewModel.lastUpdate.collectAsStateWithLifecycle() val lastUpdate by viewModel.lastUpdate.collectAsStateWithLifecycle()
val actionFeedback by viewModel.actionFeedback.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) } var statusPanelVisible by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@@ -125,19 +171,114 @@ fun MainScreen(
StatusBar( StatusBar(
connectionState = connectionState, connectionState = connectionState,
status = status, status = status,
batteryStatus = batteryStatus,
pingLatencyMs = pingLatencyMs,
wifiSignalLevel = wifiSignalLevel,
onStatusClick = { statusPanelVisible = true }, onStatusClick = { statusPanelVisible = true },
onSettingsClick = onSettingsClick, onSettingsClick = onSettingsClick,
modifier = Modifier onConnectToggle = {
.align(Alignment.TopCenter) if (connectionState == ProtocolClient.State.CONNECTED ||
.windowInsetsPadding(WindowInsets.statusBars) 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( BottomControls(
viewModel = viewModel, viewModel = viewModel,
connectionState = connectionState, connectionState = connectionState,
motionState = motionState, motionState = motionState,
ledStatus = ledStatus,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.navigationBars) .windowInsetsPadding(WindowInsets.navigationBars)
@@ -169,27 +310,41 @@ fun MainScreen(
private fun StatusBar( private fun StatusBar(
connectionState: ProtocolClient.State, connectionState: ProtocolClient.State,
status: com.example.m20_gamepad.network.models.StatusReport?, status: com.example.m20_gamepad.network.models.StatusReport?,
batteryStatus: com.example.m20_gamepad.network.models.BatteryStatus?,
pingLatencyMs: Long?,
wifiSignalLevel: Int,
onStatusClick: () -> Unit, onStatusClick: () -> Unit,
onSettingsClick: () -> Unit, onSettingsClick: () -> Unit,
onConnectToggle: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Row( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.background(OverlayBg) .background(OverlayBg)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) { ) {
// 连接状态指示 Row(
ConnectionIndicator(connectionState) 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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp) horizontalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
status?.batteryStatus?.let { batteryStatus?.let {
val avgLevel = (it.batteryLevelLeft + it.batteryLevelRight) / 2 val avgLevel = (it.batteryLevelLeft + it.batteryLevelRight) / 2
StatusChip("电量 ${avgLevel.toInt()}%") StatusChip("电量 ${avgLevel.toInt()}%")
} }
@@ -216,10 +371,11 @@ private fun StatusBar(
} }
} }
} }
}
} }
@Composable @Composable
private fun ConnectionIndicator(state: ProtocolClient.State) { private fun ConnectionIndicator(state: ProtocolClient.State, onClick: () -> Unit) {
val (color, text) = when (state) { val (color, text) = when (state) {
ProtocolClient.State.CONNECTED -> Color(0xFF4CAF50) to "已连接" ProtocolClient.State.CONNECTED -> Color(0xFF4CAF50) to "已连接"
ProtocolClient.State.CONNECTING -> Color(0xFFFFC107) 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.ERROR -> Color(0xFFF44336) to "错误"
ProtocolClient.State.DISCONNECTED -> Color(0xFF9E9E9E) to "未连接" ProtocolClient.State.DISCONNECTED -> Color(0xFF9E9E9E) to "未连接"
} }
Row(verticalAlignment = Alignment.CenterVertically) { Surface(
Box( onClick = onClick,
modifier = Modifier shape = RoundedCornerShape(14.dp),
.size(10.dp) border = BorderStroke(1.dp, Color(0x55FFFFFF)),
.background(color, CircleShape) color = Color(0x22000000)
) ) {
Spacer(Modifier.width(6.dp)) Row(
Text(text, color = OverlayText, fontSize = 14.sp) 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, viewModel: MainViewModel,
connectionState: ProtocolClient.State, connectionState: ProtocolClient.State,
motionState: Int, motionState: Int,
ledStatus: LedStatus?,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Row( Row(
@@ -281,14 +486,6 @@ private fun BottomControls(
} }
) )
// 中间控制按钮组
ControlButtons(
viewModel = viewModel,
connectionState = connectionState,
motionState = motionState,
ledStatus = ledStatus
)
// 右摇杆(Yaw 旋转) // 右摇杆(Yaw 旋转)
Joystick( Joystick(
modifier = Modifier.size(180.dp), 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 @Composable
private fun LightToggleButton( private fun LightToggleButton(
text: String, text: String,
@@ -489,40 +509,309 @@ private fun LightToggleButton(
Button( Button(
onClick = onClick, onClick = onClick,
enabled = enabled, enabled = enabled,
shape = ParallelogramShape(skewLeft = false),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = if (isOn) Color(0xCCFFC107) else Color(0x66333333), containerColor = if (isOn) Color(0xCCFFC107) else Color(0x66333333),
disabledContainerColor = Color(0x33222222) disabledContainerColor = Color(0x33222222)
), ),
modifier = Modifier.width(110.dp) contentPadding = ButtonDefaults.TextButtonContentPadding,
modifier = Modifier.height(32.dp)
) { ) {
Text( Text(
"$text ${if (isOn) "开" else "关"}", "$text ${if (isOn) "开" else "关"}",
color = if (enabled) OverlayText else Color(0x88FFFFFF), color = if (enabled) OverlayText else Color(0x88FFFFFF),
fontSize = 12.sp,
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
} }
} }
@Composable @Composable
private fun SectionLabel(text: String) { private fun StandToggleButton(
Text( motionState: Int,
text, connected: Boolean,
color = Color(0xFFB0B0B0), onToggle: () -> Unit,
fontSize = 11.sp, modifier: Modifier = Modifier
modifier = Modifier.fillMaxWidth() ) {
) 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 @Composable
private fun Separator() { private fun ModeButton(
Spacer(Modifier.height(2.dp)) 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( Box(
modifier = Modifier modifier = modifier
.width(80.dp) .width(sliderWidth)
.height(1.dp) .height(sliderHeight)
.background(Color(0x44FFFFFF)) ) {
) // 滑轨 — 平行四边形外框
Spacer(Modifier.height(2.dp)) 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 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.example.m20_gamepad.data.AppSettings 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.network.models.StatusReport
import com.example.m20_gamepad.service.JoystickController import com.example.m20_gamepad.service.JoystickController
import com.example.m20_gamepad.service.MotionTransitionResult import com.example.m20_gamepad.service.MotionTransitionResult
import com.example.m20_gamepad.service.PingMonitor
import com.example.m20_gamepad.service.RobotConnection import com.example.m20_gamepad.service.RobotConnection
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -40,7 +44,8 @@ import kotlinx.coroutines.launch
* @param repository 应用级设置仓库 * @param repository 应用级设置仓库
*/ */
class MainViewModel( class MainViewModel(
private val repository: SettingsRepository private val repository: SettingsRepository,
private val application: Application
) : ViewModel() { ) : ViewModel() {
companion object { companion object {
@@ -81,6 +86,18 @@ class MainViewModel(
private val _actionFeedback = MutableStateFlow<String?>(null) private val _actionFeedback = MutableStateFlow<String?>(null)
val actionFeedback: StateFlow<String?> = _actionFeedback.asStateFlow() 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( val settings: StateFlow<AppSettings> = repository.settings.stateIn(
scope = viewModelScope, scope = viewModelScope,
@@ -101,6 +118,17 @@ class MainViewModel(
val isConnected: Boolean get() = robot.isConnected val isConnected: Boolean get() = robot.isConnected
init { init {
// 启动 ICMP 延迟监测(独立于连接生命周期)
restartPingMonitor(settings.value.robotHost)
// 设置中 host 变更时重启 ping
viewModelScope.launch {
settings
.map { it.robotHost }
.distinctUntilChanged()
.collect { host -> restartPingMonitor(host) }
}
// 启动 WiFi 信号强度监测(独立于连接生命周期)
startWifiMonitoring()
// 设置变更时断开当前连接,下次 connect 使用新参数 // 设置变更时断开当前连接,下次 connect 使用新参数
settingsWatchJob = viewModelScope.launch { settingsWatchJob = viewModelScope.launch {
settings 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) { 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 _lastUpdate.value = report.time
robot.updateFromReport(report) robot.updateFromReport(report)
report.errorList?.let { _errorList.value = it } report.errorList?.let { _errorList.value = it }
@@ -226,6 +315,15 @@ class MainViewModel(
client?.sendCommandBlocking(ControlCommands.chargeControl(charge)) 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) { fun sendSleepSettings(sleep: Boolean, auto: Boolean = false, timeMinutes: Int = 5) {
client?.sendCommandBlocking( client?.sendCommandBlocking(
ControlCommands.sleepSettings(sleep, auto, timeMinutes) ControlCommands.sleepSettings(sleep, auto, timeMinutes)
@@ -266,6 +364,9 @@ class MainViewModel(
override fun onCleared() { override fun onCleared() {
disconnect() disconnect()
wifiJob?.cancel()
pingMonitor?.stop()
pingMonitor = null
settingsWatchJob?.cancel() settingsWatchJob?.cancel()
super.onCleared() super.onCleared()
} }