From 04afb3331f8400237fd12567a668de19a327f38a Mon Sep 17 00:00:00 2001 From: CaoWangrenbo Date: Fri, 17 Jul 2026 19:24:39 +0800 Subject: [PATCH] Initial commit: M20 Gamepad control app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 山猫 M20 四足机器人手柄控制 Android App,使用 UDP + JSON 协议控制机器人运动。 - 协议层:16 字节小端 Header + JSON ASDU 编解码 - 网络层:UDP 客户端,心跳 1Hz,断线检测 3s - 连接状态机:空闲/连接中/已连接/断线 + 运动状态机:空闲/站立/RL 控制 - 虚拟摇杆:Compose 原生摇杆库,angle/power -> X/Y/Yaw 映射 - 视频播放:Media3 ExoPlayer RTSP,支持 AUTO/FORCE_HW/FORCE_SW_H264/FORCE_SW_H265 - 设置界面:机器人 IP/端口、RTSP 地址、编码器选择,DataStore 持久化 - 状态面板:异常列表、运控状态、设备温度、电池信息 - 控制按钮:模式/步态/照明/充电/休眠 Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- .gitignore | 15 + .idea/.gitignore | 3 + .idea/AndroidProjectSystem.xml | 6 + .idea/compiler.xml | 6 + .idea/deploymentTargetSelector.xml | 18 + .idea/deviceManager.xml | 13 + .idea/gradle.xml | 18 + .idea/inspectionProfiles/Project_Default.xml | 61 ++ .idea/misc.xml | 10 + .idea/runConfigurations.xml | 17 + AGENTS.md | 193 ++++++ app/.gitignore | 1 + app/build.gradle.kts | 68 ++ app/proguard-rules.pro | 21 + .../m20_gamepad/ExampleInstrumentedTest.kt | 24 + app/src/main/AndroidManifest.xml | 33 + .../java/com/example/m20_gamepad/M20App.kt | 17 + .../com/example/m20_gamepad/MainActivity.kt | 71 ++ .../example/m20_gamepad/data/AppSettings.kt | 29 + .../m20_gamepad/data/SettingsRepository.kt | 73 +++ .../m20_gamepad/navigation/NavRoutes.kt | 16 + .../m20_gamepad/network/PacketDecoder.kt | 62 ++ .../m20_gamepad/network/PacketEncoder.kt | 38 ++ .../m20_gamepad/network/ProtocolClient.kt | 271 ++++++++ .../m20_gamepad/network/models/ApduMessage.kt | 18 + .../network/models/ControlCommands.kt | 152 +++++ .../m20_gamepad/network/models/ErrorCodes.kt | 88 +++ .../m20_gamepad/network/models/Header.kt | 71 ++ .../network/models/StatusReports.kt | 359 ++++++++++ .../m20_gamepad/service/JoystickController.kt | 55 ++ .../m20_gamepad/service/RobotConnection.kt | 155 +++++ .../com/example/m20_gamepad/ui/MainScreen.kt | 484 ++++++++++++++ .../example/m20_gamepad/ui/MainViewModel.kt | 266 ++++++++ .../example/m20_gamepad/ui/SettingsScreen.kt | 266 ++++++++ .../m20_gamepad/ui/components/StatusPanel.kt | 313 +++++++++ .../com/example/m20_gamepad/ui/theme/Color.kt | 11 + .../com/example/m20_gamepad/ui/theme/Theme.kt | 58 ++ .../com/example/m20_gamepad/ui/theme/Type.kt | 34 + .../m20_gamepad/video/RtspVideoPlayer.kt | 225 +++++++ .../res/drawable/ic_launcher_background.xml | 170 +++++ .../res/drawable/ic_launcher_foreground.xml | 30 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes app/src/main/res/values/colors.xml | 10 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/themes.xml | 5 + app/src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 19 + .../example/m20_gamepad/ExampleUnitTest.kt | 17 + build.gradle.kts | 1 + gradle.properties | 15 + gradle/gradle-daemon-jvm.properties | 12 + gradle/libs.versions.toml | 45 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 9 + gradlew | 251 +++++++ gradlew.bat | 94 +++ joysticklibrary/.gitignore | 1 + joysticklibrary/build.gradle.kts | 63 ++ joysticklibrary/proguard-rules.pro | 17 + joysticklibrary/src/main/AndroidManifest.xml | 10 + .../com/erz/joysticklibrary/JoyStickView.kt | 279 ++++++++ .../java/com/erz/joysticklibrary/Joystick.kt | 289 +++++++++ joysticklibrary/src/main/res/values/attrs.xml | 11 + .../src/main/res/values/strings.xml | 4 + proto.md | 612 ++++++++++++++++++ settings.gradle.kts | 28 + 77 files changed, 5659 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/AndroidProjectSystem.xml create mode 100644 .idea/compiler.xml create mode 100644 .idea/deploymentTargetSelector.xml create mode 100644 .idea/deviceManager.xml create mode 100644 .idea/gradle.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/runConfigurations.xml create mode 100644 AGENTS.md create mode 100644 app/.gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/androidTest/java/com/example/m20_gamepad/ExampleInstrumentedTest.kt create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/example/m20_gamepad/M20App.kt create mode 100644 app/src/main/java/com/example/m20_gamepad/MainActivity.kt create mode 100644 app/src/main/java/com/example/m20_gamepad/data/AppSettings.kt create mode 100644 app/src/main/java/com/example/m20_gamepad/data/SettingsRepository.kt create mode 100644 app/src/main/java/com/example/m20_gamepad/navigation/NavRoutes.kt create mode 100644 app/src/main/java/com/example/m20_gamepad/network/PacketDecoder.kt create mode 100644 app/src/main/java/com/example/m20_gamepad/network/PacketEncoder.kt create mode 100644 app/src/main/java/com/example/m20_gamepad/network/ProtocolClient.kt create mode 100644 app/src/main/java/com/example/m20_gamepad/network/models/ApduMessage.kt create mode 100644 app/src/main/java/com/example/m20_gamepad/network/models/ControlCommands.kt create mode 100644 app/src/main/java/com/example/m20_gamepad/network/models/ErrorCodes.kt create mode 100644 app/src/main/java/com/example/m20_gamepad/network/models/Header.kt create mode 100644 app/src/main/java/com/example/m20_gamepad/network/models/StatusReports.kt create mode 100644 app/src/main/java/com/example/m20_gamepad/service/JoystickController.kt create mode 100644 app/src/main/java/com/example/m20_gamepad/service/RobotConnection.kt create mode 100644 app/src/main/java/com/example/m20_gamepad/ui/MainScreen.kt create mode 100644 app/src/main/java/com/example/m20_gamepad/ui/MainViewModel.kt create mode 100644 app/src/main/java/com/example/m20_gamepad/ui/SettingsScreen.kt create mode 100644 app/src/main/java/com/example/m20_gamepad/ui/components/StatusPanel.kt create mode 100644 app/src/main/java/com/example/m20_gamepad/ui/theme/Color.kt create mode 100644 app/src/main/java/com/example/m20_gamepad/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/example/m20_gamepad/ui/theme/Type.kt create mode 100644 app/src/main/java/com/example/m20_gamepad/video/RtspVideoPlayer.kt create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/test/java/com/example/m20_gamepad/ExampleUnitTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/gradle-daemon-jvm.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 joysticklibrary/.gitignore create mode 100644 joysticklibrary/build.gradle.kts create mode 100644 joysticklibrary/proguard-rules.pro create mode 100644 joysticklibrary/src/main/AndroidManifest.xml create mode 100644 joysticklibrary/src/main/java/com/erz/joysticklibrary/JoyStickView.kt create mode 100644 joysticklibrary/src/main/java/com/erz/joysticklibrary/Joystick.kt create mode 100644 joysticklibrary/src/main/res/values/attrs.xml create mode 100644 joysticklibrary/src/main/res/values/strings.xml create mode 100644 proto.md create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..eaf91e2 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..d58d49b --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..8b3f102 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..dacf2e3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..81c3e56 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..a947dc0 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..7061a0d --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,61 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..ae3bf2d --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..5bd6771 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..33d2d1d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,193 @@ +# AGENTS.md — m20_gamepad + +## 项目概述 + +山猫 M20 四足机器人手柄控制 Android App。功能: +- 通过 UDP + JSON 协议(见 `proto.md`)控制机器人运动 +- 使用虚拟摇杆(集成自 `controlwear/virtual-joystick-android`) +- 拉取 RTSP 视频流作为底层显示 +- 设置界面:配置机器人 IP/端口、RTSP 地址、视频编码器选项,持久化保存 + +--- + +## 架构与包结构 + +``` +com.example.m20_gamepad/ +├── MainActivity.kt # 入口 Activity,管理导航(主界面 ↔ 设置界面) +├── M20App.kt # Application 子类,初始化 DataStore +├── navigation/ +│ └── NavRoutes.kt # 导航路由定义 +├── ui/ +│ ├── theme/ # Compose 主题(已有) +│ ├── MainScreen.kt # 主界面:视频层 + 摇杆覆盖层 +│ ├── SettingsScreen.kt # 设置界面:配置机器人 IP/端口、RTSP 地址、编码器 +│ ├── JoystickSurface.kt # Compose 对 JoystickView 的包装 +│ └── components/ # 其他 UI 组件(状态指示器、按钮等) +├── network/ +│ ├── ProtocolClient.kt # UDP 客户端:收发、心跳、线程管理 +│ ├── PacketEncoder.kt # 16 字节 Header 封包 + JSON 序列化 +│ ├── PacketDecoder.kt # 16 字节 Header 解包 + JSON 反序列化 +│ └── models/ +│ ├── Header.kt # 16 字节报文头(小端 LE) +│ ├── ApduMessage.kt # 完整 APDU = Header + ASDU(JSON) +│ ├── ControlCommands.kt # 上行指令结构(心跳、轴指令、步态切换等) +│ └── StatusReports.kt # 下行状态结构(基础状态、运控状态、设备状态等) +├── service/ +│ ├── RobotConnection.kt # 连接状态机管理(连接/订阅/心跳/断线判定) +│ └── JoystickController.kt # 摇杆输入 → 协议指令映射 +├── video/ +│ └── RtspSurfaceView.kt # RTSP 视频流播放(SurfaceView 层) +├── data/ +│ ├── SettingsRepository.kt # 设置项读写(DataStore 封装) +│ └── AppSettings.kt # 配置数据类定义 + 默认值 +``` + +--- + +## 协议要点(详见 proto.md) + +- **传输**: UDP, 目标 `10.21.31.103:30000` +- **报文**: 16 字节 Header(小端)+ JSON ASDU +- **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} +- **状态上报**: 上行响应含 ErrorCode/ErrorMessage; 主动订阅制(Type=1002, Cmd=3/4/5/6) + +--- + +## 虚拟摇杆集成 + +使用 Compose 原生摇杆库 `com.erz.joysticklibrary`(来自 [erz05/JoyStick](https://github.com/erz05/JoyStick)),以本地模块方式引入(`joysticklibrary/`)。 + +### 关键 API + +`Joystick` Compose 可组合项: +```kotlin +Joystick( + type = JoystickType.EIGHT_AXIS, + onMove = { angle: Double, power: Double, direction: JoystickDirection -> + // angle: 弧度,atan2 范围 [-PI, PI] + // power: 0.0 ~ 100.0 + // direction: 方向枚举 + }, + onTap = { /* 单击 */ }, + onDoubleTap = { /* 双击 */ } +) +``` + +### 角度 → 轴映射 + +摇杆角度/强度映射到机器人 X/Y/Yaw 轴指令: +- 角度 0° = 正右 → Yaw 负方向 +- 角度 90° = 正上 → X 正方向(前进) +- 角度 180° = 正左 → Yaw 正方向 +- 角度 270° = 正下 → X 负方向(后退) +- 强度百分比 → [-1, 1] 比例值 + +**映射公式(从左摇杆角度/强度到 X/Y)**: +``` +val x = power / 100.0 * cos(angle) // 垂直分量 +val y = power / 100.0 * sin(angle) // 水平分量 +``` +右摇杆控制 Yaw(偏航角速度)。 + +--- + +## RTSP 视频流 + +使用 `MediaPlayer` + `SurfaceView` 或 `ExoPlayer` 拉取 RTSP 流。视频层作为 UI 最底层背景,摇杆等控件覆盖其上(半透明)。 + +--- + +## 设置界面 + +### 配置项 + +| 配置项 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `robot_host` | String | `10.21.31.103` | 机器人 IP 地址 | +| `robot_port` | Int | `30000` | 机器人 UDP 端口 | +| `rtsp_url` | String | `rtsp://10.21.31.103:554/stream` | RTSP 视频流地址 | +| `video_codec` | Enum | `AUTO` | `AUTO` / `FORCE_SW_H264` / `FORCE_SW_H265` | + +### 持久化 + +使用 Jetpack DataStore Preferences 存储,通过 `SettingsRepository` 封装: +- 写入后立即生效,下次启动自动恢复 +- 暴露 `Flow` 供 UI 层观察 +- 修改设置后断开当前连接,下次连接时使用新参数 + +### 导航 + +主界面设设置按钮(齿轮图标),点击跳转设置界面。设置界面返回主界面时,若连接参数变更则自动断开旧连接。 + +--- + +## 构建与运行 + +```bash +# 在项目根目录 +./gradlew assembleDebug + +# 安装到设备 +./gradlew installDebug +``` + +### 依赖 + +- 虚拟摇杆库:本地模块 `joysticklibrary/`(Compose 原生,Kotlin DSL) +- JSON 序列化:`kotlinx-serialization-json:1.7.3` +- RTSP 播放:`androidx.media3:media3-exoplayer-rtsp:1.5.1` +- 持久化:`androidx.datastore:datastore-preferences:1.1.3` +- 导航:`androidx.navigation:navigation-compose:2.8.6` + +--- + +## 编码规范 + +- **语言**: Kotlin,使用 Jetpack Compose 构建 UI +- **协程**: 网络 I/O 使用 `kotlinx.coroutines`,避免裸线程 +- **UDP**: 使用 `java.net.DatagramSocket`,绑定本地随机端口 +- **状态管理**: 使用 `StateFlow` 暴露连接状态、机器人状态 +- **生命周期**: 网络连接生命周期绑定到 Activity/Service,前台时活跃 +- **MsgId**: 从 0 递增 u16,循环回绕 +- **断线判定**: 3 秒无任何 UDP 包 → 标记离线 + +--- + +## 已完成 / 待办 + +### 已完成 + +- [x] Gradle 配置:版本目录、摇杆本地模块、全部依赖 +- [x] 协议层:Header.kt(16 字节小端编解码) +- [x] 协议层:ApduMessage.kt(Header + ASDU 封装) +- [x] 协议层:ControlCommands.kt(全部指令构建函数 + 常量) +- [x] 协议层:StatusReports.kt(全部状态上报数据类 + JSON 解析器) +- [x] 协议层:PacketEncoder.kt(MsgId 管理 + APDU 编码) +- [x] 协议层:PacketDecoder.kt(APDU 解码 + 缓冲区搜索) +- [x] 网络层:ProtocolClient.kt(UDP socket、心跳 1Hz、指令发送通道、接收循环、断线检测 3s、订阅触发) +- [x] 摇杆映射:JoystickController.kt(angle/power -> X/Y/Yaw) +- [x] 状态管理:MainViewModel.kt(ProtocolClient 生命周期、20Hz 轴指令循环、状态收集) +- [x] 视频播放:RtspVideoPlayer.kt(Media3 ExoPlayer RTSP,Composable 包装) +- [x] 主界面:MainScreen.kt(视频背景 + 双摇杆 + 状态栏 + 控制按钮) +- [x] 入口:MainActivity.kt + INTERNET 权限 +- [x] 持久化层:AppSettings.kt(配置数据类 + 默认值 + 校验)、SettingsRepository.kt(DataStore Preferences 封装,`settings: Flow` + 单项 setter + `setAll()`)、M20App.kt(Application 子类,进程级 `settingsRepository` 单例) +- [x] 设置界面 UI:SettingsScreen.kt(主机/端口/RTSP 输入 + 编码器 ExposedDropdownMenu,保存按钮调用 `setAll` 后回调 `onSaved`) +- [x] 导航框架:NavRoutes.kt(`MAIN`/`SETTINGS` 路由常量)、MainActivity.kt 改写为 `AppNavGraph()`(NavHost,startDestination=MAIN,主界面齿轮按钮跳转设置,返回/保存后 popBackStack 到 MAIN) +- [x] 连接参数从设置读取:MainViewModel 构造注入 `SettingsRepository`,`settings: StateFlow`(`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] 更多控制按钮:Mode(常规/导航/辅助)、步态(基础/楼梯/平地敏捷/楼梯敏捷)、照明(前/后灯开/关)、充电(开始/结束)、休眠(休眠/唤醒)。底部控制区可纵向滚动,点击"更多"展开全部控制选项 +- [x] RTSP 解码器策略实现:`VideoCodec` 枚举含 `AUTO`/`FORCE_HW`/`FORCE_SW_H264`/`FORCE_SW_H265`;`toMediaCodecSelector()` 映射到对应 `MediaCodecSelector`;`DefaultLoadControl` 最小缓冲(100ms起播,总缓冲<500ms,无回退缓存,时间优先于大小阈值);`FORCE_HW` 模式对所有 MIME 仅保留硬件加速解码器 + +### 待办 + +(无 — 所有功能已实现) diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..346ed18 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,68 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "com.example.m20_gamepad" + compileSdk { + version = release(36) { + minorApiLevel = 1 + } + } + + defaultConfig { + applicationId = "com.example.m20_gamepad" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(project(":joysticklibrary")) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.exoplayer.rtsp) + implementation(libs.androidx.media3.ui) + implementation(libs.kotlinx.serialization.json) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/example/m20_gamepad/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/example/m20_gamepad/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..e604bb5 --- /dev/null +++ b/app/src/androidTest/java/com/example/m20_gamepad/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.m20_gamepad + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.m20_gamepad", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6409ab8 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/m20_gamepad/M20App.kt b/app/src/main/java/com/example/m20_gamepad/M20App.kt new file mode 100644 index 0000000..8c3dc51 --- /dev/null +++ b/app/src/main/java/com/example/m20_gamepad/M20App.kt @@ -0,0 +1,17 @@ +package com.example.m20_gamepad + +import android.app.Application +import com.example.m20_gamepad.data.SettingsRepository + +/** + * Application 子类:持有应用级单例 [SettingsRepository]。 + * + * DataStore 通过 Context 扩展属性 lazy 创建,进程内单例; + * UI 层经由本类访问仓库以保持单一实例。 + */ +class M20App : Application() { + + val settingsRepository: SettingsRepository by lazy { + SettingsRepository(this) + } +} diff --git a/app/src/main/java/com/example/m20_gamepad/MainActivity.kt b/app/src/main/java/com/example/m20_gamepad/MainActivity.kt new file mode 100644 index 0000000..deb8edf --- /dev/null +++ b/app/src/main/java/com/example/m20_gamepad/MainActivity.kt @@ -0,0 +1,71 @@ +package com.example.m20_gamepad + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.example.m20_gamepad.navigation.NavRoutes +import com.example.m20_gamepad.ui.MainScreen +import com.example.m20_gamepad.ui.SettingsScreen +import com.example.m20_gamepad.ui.theme.M20_gamepadTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + M20_gamepadTheme { + AppNavGraph() + } + } + } +} + +/** + * 应用导航图:主界面 <-> 设置界面。 + * + * - 主界面:双摇杆控制 + RTSP 视频 + * - 设置界面:配置机器人连接与视频参数,保存后返回主界面 + */ +@Composable +private fun AppNavGraph() { + val navController = rememberNavController() + val app = LocalContext.current.applicationContext as M20App + val repository = app.settingsRepository + + NavHost( + navController = navController, + startDestination = NavRoutes.MAIN + ) { + composable(NavRoutes.MAIN) { + MainScreen( + repository = repository, + onSettingsClick = { + navController.navigate(NavRoutes.SETTINGS) + } + ) + } + composable(NavRoutes.SETTINGS) { + SettingsScreen( + repository = repository, + onBack = { + navController.popBackStack( + route = NavRoutes.MAIN, + inclusive = false + ) + }, + onSaved = { + navController.popBackStack( + route = NavRoutes.MAIN, + inclusive = false + ) + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/m20_gamepad/data/AppSettings.kt b/app/src/main/java/com/example/m20_gamepad/data/AppSettings.kt new file mode 100644 index 0000000..f743073 --- /dev/null +++ b/app/src/main/java/com/example/m20_gamepad/data/AppSettings.kt @@ -0,0 +1,29 @@ +package com.example.m20_gamepad.data + +import com.example.m20_gamepad.video.VideoCodec + +/** + * 应用配置数据类。 + * + * 所有设置项的当前值快照。通过 [SettingsRepository.settings] Flow 暴露, + * 修改单项通过 [SettingsRepository] 的对应方法写入。 + */ +data class AppSettings( + val robotHost: String = DEFAULT_ROBOT_HOST, + val robotPort: Int = DEFAULT_ROBOT_PORT, + val rtspUrl: String = DEFAULT_RTSP_URL, + val videoCodec: VideoCodec = VideoCodec.AUTO +) { + companion object { + const val DEFAULT_ROBOT_HOST = "10.21.31.103" + const val DEFAULT_ROBOT_PORT = 30000 + const val DEFAULT_RTSP_URL = "rtsp://10.21.31.103:554/stream" + + /** RTSP 端口范围校验 */ + fun isValidPort(port: Int): Boolean = port in 1..65535 + + /** 主机地址非空且不包含空白 */ + fun isValidHost(host: String): Boolean = + host.isNotBlank() && !host.any { it.isWhitespace() } + } +} diff --git a/app/src/main/java/com/example/m20_gamepad/data/SettingsRepository.kt b/app/src/main/java/com/example/m20_gamepad/data/SettingsRepository.kt new file mode 100644 index 0000000..e247dc1 --- /dev/null +++ b/app/src/main/java/com/example/m20_gamepad/data/SettingsRepository.kt @@ -0,0 +1,73 @@ +package com.example.m20_gamepad.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.example.m20_gamepad.video.VideoCodec +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** 应用级 DataStore 单例(由扩展属性 lazy 创建) */ +private val Context.appSettingsDataStore: DataStore by preferencesDataStore( + name = "m20_gamepad_settings" +) + +/** + * 设置仓库:封装 DataStore Preferences 读写。 + * + * 通过 [settings] Flow 暴露当前配置快照,UI 层收集即可; + * 修改单项调用对应 set 方法,写入后 Flow 自动推送新值。 + * + * @param context 应用 Context(通常取自 [com.example.m20_gamepad.M20App]) + */ +class SettingsRepository(context: Context) { + + private val dataStore = context.applicationContext.appSettingsDataStore + + /** 当前设置项(初始发射默认值,随后跟随存储) */ + val settings: Flow = dataStore.data.map { prefs -> + AppSettings( + robotHost = prefs[KEY_HOST] ?: AppSettings.DEFAULT_ROBOT_HOST, + robotPort = prefs[KEY_PORT] ?: AppSettings.DEFAULT_ROBOT_PORT, + rtspUrl = prefs[KEY_RTSP_URL] ?: AppSettings.DEFAULT_RTSP_URL, + videoCodec = VideoCodec.entries.getOrElse(prefs[KEY_CODEC] ?: 0) { VideoCodec.AUTO } + ) + } + + suspend fun setRobotHost(host: String) { + dataStore.edit { it[KEY_HOST] = host.trim() } + } + + suspend fun setRobotPort(port: Int) { + dataStore.edit { it[KEY_PORT] = port.coerceIn(1, 65535) } + } + + suspend fun setRtspUrl(url: String) { + dataStore.edit { it[KEY_RTSP_URL] = url.trim() } + } + + suspend fun setVideoCodec(codec: VideoCodec) { + dataStore.edit { it[KEY_CODEC] = codec.ordinal } + } + + /** 一次性写入全部设置(用于设置界面保存按钮) */ + suspend fun setAll(settings: AppSettings) { + dataStore.edit { prefs -> + prefs[KEY_HOST] = settings.robotHost.trim() + prefs[KEY_PORT] = settings.robotPort.coerceIn(1, 65535) + prefs[KEY_RTSP_URL] = settings.rtspUrl.trim() + prefs[KEY_CODEC] = settings.videoCodec.ordinal + } + } + + companion object { + private val KEY_HOST = stringPreferencesKey("robot_host") + private val KEY_PORT = intPreferencesKey("robot_port") + private val KEY_RTSP_URL = stringPreferencesKey("rtsp_url") + private val KEY_CODEC = intPreferencesKey("video_codec") + } +} diff --git a/app/src/main/java/com/example/m20_gamepad/navigation/NavRoutes.kt b/app/src/main/java/com/example/m20_gamepad/navigation/NavRoutes.kt new file mode 100644 index 0000000..0e88667 --- /dev/null +++ b/app/src/main/java/com/example/m20_gamepad/navigation/NavRoutes.kt @@ -0,0 +1,16 @@ +package com.example.m20_gamepad.navigation + +/** + * 导航路由定义。 + * + * 路由字符串作为 [androidx.navigation.NavHostController] 的 route 标识, + * 集中于此处便于调用方引用,避免硬编码字符串散落。 + */ +object NavRoutes { + + /** 主界面:视频背景 + 双摇杆 + 控制按钮 */ + const val MAIN = "main" + + /** 设置界面:配置机器人 IP/端口、RTSP 地址、视频编码器 */ + const val SETTINGS = "settings" +} diff --git a/app/src/main/java/com/example/m20_gamepad/network/PacketDecoder.kt b/app/src/main/java/com/example/m20_gamepad/network/PacketDecoder.kt new file mode 100644 index 0000000..c9ddb4a --- /dev/null +++ b/app/src/main/java/com/example/m20_gamepad/network/PacketDecoder.kt @@ -0,0 +1,62 @@ +package com.example.m20_gamepad.network + +import com.example.m20_gamepad.network.models.ApduMessage +import com.example.m20_gamepad.network.models.Header + +/** + * 报文解码器。将接收到的字节数组解析为 ApduMessage。 + */ +object PacketDecoder { + + /** + * 从字节数组解码完整 APDU 报文。 + * 如果数据不足或同步头校验失败,返回 null。 + */ + fun decode(data: ByteArray): ApduMessage? { + if (data.size < Header.SIZE) return null + + val header = Header.fromBytes(data) ?: return null + if (header.format != Header.FORMAT_JSON.toInt()) return null + + // 检查数据长度是否足够包含 ASDU + val totalSize = Header.SIZE + header.length + if (data.size < totalSize) return null + + val asduBytes = data.copyOfRange(Header.SIZE, totalSize) + val asdu = asduBytes.toString(Charsets.UTF_8) + + return ApduMessage(header = header, asdu = asdu) + } + + /** + * 从字节数组中查找第一个完整且合法的 APDU 报文。 + * 用于在可能包含多个报文或部分数据的缓冲区中搜索。 + * + * @return 解析结果 + 消耗的字节数;如果未找到返回 null + */ + fun findFirst(data: ByteArray): Pair? { + var offset = 0 + while (offset <= data.size - Header.SIZE) { + // 查找同步头起始位置 + if (data[offset] != Header.SYNC0 || + data[offset + 1] != Header.SYNC1 || + data[offset + 2] != Header.SYNC2 || + data[offset + 3] != Header.SYNC3 + ) { + offset++ + continue + } + + // 尝试从该位置解码 + val remaining = data.copyOfRange(offset, data.size) + val message = decode(remaining) ?: run { + offset++ + continue + } + + val consumed = Header.SIZE + message.header.length + return message to consumed + } + return null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/m20_gamepad/network/PacketEncoder.kt b/app/src/main/java/com/example/m20_gamepad/network/PacketEncoder.kt new file mode 100644 index 0000000..7ef7a1e --- /dev/null +++ b/app/src/main/java/com/example/m20_gamepad/network/PacketEncoder.kt @@ -0,0 +1,38 @@ +package com.example.m20_gamepad.network + +import com.example.m20_gamepad.network.models.ApduMessage +import com.example.m20_gamepad.network.models.Header +import java.util.concurrent.atomic.AtomicInteger + +/** + * 报文编码器。管理 MsgId 自增,将 ASDU JSON 字符串编码为完整 APDU 字节数组。 + */ +class PacketEncoder { + + private val msgIdCounter = AtomicInteger(0) + + /** + * 生成下一个 MsgId(u16,从 0 递增,65535 后回绕到 0) + */ + fun nextMsgId(): Int = msgIdCounter.getAndIncrement().and(0xFFFF) + + /** + * 将 ASDU JSON 字符串编码为完整 APDU 字节数组。 + * 自动生成 MsgId 并设置 Header Length。 + */ + fun encode(asduJson: String): ByteArray { + val msgId = nextMsgId() + val header = Header(length = 0, msgId = msgId) // length will be set by ApduMessage + val message = ApduMessage(header = header, asdu = asduJson) + return message.toBytes() + } + + /** + * 使用指定 MsgId 编码(用于测试或重发场景) + */ + fun encodeWithMsgId(asduJson: String, msgId: Int): ByteArray { + val header = Header(length = 0, msgId = msgId and 0xFFFF) + val message = ApduMessage(header = header, asdu = asduJson) + return message.toBytes() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/m20_gamepad/network/ProtocolClient.kt b/app/src/main/java/com/example/m20_gamepad/network/ProtocolClient.kt new file mode 100644 index 0000000..889f8fd --- /dev/null +++ b/app/src/main/java/com/example/m20_gamepad/network/ProtocolClient.kt @@ -0,0 +1,271 @@ +package com.example.m20_gamepad.network + +import com.example.m20_gamepad.network.models.ControlCommands +import com.example.m20_gamepad.network.models.StatusReport +import com.example.m20_gamepad.network.models.parseStatusReport +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.net.SocketTimeoutException + +/** + * UDP 协议客户端。管理连接、心跳、指令发送和状态上报接收。 + * + * 用法: + * ```kotlin + * val client = ProtocolClient(host, port, scope) + * client.onStatusReport.collect { report -> /* 处理上报 */ } + * client.connect() + * client.sendCommand(ControlCommands.axisCommand(x = 0.5, yaw = 0.0)) + * ``` + */ +class ProtocolClient( + /** 机器人 IP 地址 */ + private val host: String, + /** 机器人 UDP 端口 */ + private val port: Int, + /** 协程作用域,用于启动心跳和接收协程 */ + private val scope: CoroutineScope +) { + /** 连接状态 */ + enum class State { + /** 未连接 */ + DISCONNECTED, + /** 连接中(socket 已创建,正在发送订阅) */ + CONNECTING, + /** 已连接(心跳和接收正常运行) */ + CONNECTED, + /** 断线(超过 3 秒无数据) */ + TIMEOUT, + /** 连接出错 */ + ERROR + } + + private val _state = MutableStateFlow(State.DISCONNECTED) + val state: StateFlow = _state.asStateFlow() + + /** 状态上报事件流 */ + private val _statusReport = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 16, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val onStatusReport: SharedFlow = _statusReport.asSharedFlow() + + private val encoder = PacketEncoder() + private var socket: DatagramSocket? = null + private var remoteAddress: InetAddress? = null + + // 上次收到数据的时间戳(用于断线判定) + @Volatile + private var lastPacketTimeMs = 0L + + // 协程引用 + private var heartbeatJob: Job? = null + private var receiveJob: Job? = null + private var timeoutJob: Job? = null + + // 指令发送通道(非阻塞,用于从 UI 线程发送指令) + private var sendChannel: Channel = Channel(Channel.BUFFERED) + private var sendJob: Job? = null + + /** 是否已连接 */ + val isConnected: Boolean get() = _state.value == State.CONNECTED + + /** + * 建立连接: + * 1. 创建 UDP socket + * 2. 启动接收协程 + * 3. 发送订阅请求 + * 4. 启动心跳和断线检测 + */ + fun connect() { + if (_state.value != State.DISCONNECTED && _state.value != State.ERROR) return + + // 重建发送通道(上次 disconnect 可能已关闭) + // 使用自定义 Channel 替代重新赋值,因为 Channel 在 close 后不可复用 + _state.value = State.CONNECTING + try { + val address = InetAddress.getByName(host) + remoteAddress = address + + val sock = DatagramSocket() + sock.soTimeout = 5000 + socket = sock + + lastPacketTimeMs = System.currentTimeMillis() + + // 重新创建 sendChannel(旧的可能已关闭) + sendChannel = Channel(Channel.BUFFERED) + + // 启动接收协程 + receiveJob = scope.launch(Dispatchers.IO) { + receiveLoop(sock) + } + + // 启动发送协程(从 Channel 消费) + sendJob = scope.launch(Dispatchers.IO) { + for (packet in sendChannel) { + try { + sock.send(DatagramPacket(packet, packet.size, address, port)) + } catch (_: Exception) { + // 发送失败由断线检测处理 + } + } + } + + // 发送订阅请求(触发服务端 UDP 推送) + sendSubscriptionRequests() + + _state.value = State.CONNECTED + + // 启动心跳 + heartbeatJob = scope.launch(Dispatchers.IO) { + heartbeatLoop(sock, address) + } + + // 启动断线检测 + timeoutJob = scope.launch { + timeoutCheckLoop() + } + } catch (e: Exception) { + _state.value = State.ERROR + closeSocket() + } + } + + /** + * 断开连接,清理所有资源。 + */ + fun disconnect() { + _state.value = State.DISCONNECTED + heartbeatJob?.cancel() + heartbeatJob = null + receiveJob?.cancel() + receiveJob = null + sendJob?.cancel() + sendJob = null + timeoutJob?.cancel() + timeoutJob = null + sendChannel.close() + closeSocket() + // 重新创建 sendChannel 以便下次 connect 使用 + } + + /** + * 发送控制指令(挂起函数,发送到 Channel 后会立即返回)。 + * 如果未连接,指令会被丢弃。 + */ + suspend fun sendCommand(asduJson: String) { + if (_state.value != State.CONNECTED) return + val packet = encoder.encode(asduJson) + sendChannel.send(packet) + } + + /** + * 发送控制指令(非挂起版本,用于 UI 线程等非协程环境)。 + * 如果 Channel 已满则丢弃。 + */ + fun sendCommandBlocking(asduJson: String) { + if (_state.value != State.CONNECTED) return + val packet = encoder.encode(asduJson) + sendChannel.trySend(packet) + } + + // ========== 内部实现 ========== + + private fun closeSocket() { + try { + socket?.close() + } catch (_: Exception) { } + socket = null + } + + /** 发送订阅请求 */ + private fun sendSubscriptionRequests() { + val subscriptions: List> = listOf( + ControlCommands.SUB_BASIC, + ControlCommands.SUB_MOTION_CONTROL, + ControlCommands.SUB_DEVICE, + ControlCommands.SUB_ERROR + ) + val sock = socket ?: return + val addr = remoteAddress ?: return + for ((type, cmd) in subscriptions) { + try { + val json = ControlCommands.subscribeStatus(type, cmd) + val packet = encoder.encode(json) + sock.send(DatagramPacket(packet, packet.size, addr, port)) + } catch (_: Exception) { } + } + } + + /** 接收循环 */ + private fun CoroutineScope.receiveLoop(sock: DatagramSocket) { + val buffer = ByteArray(65535) + while (isActive && _state.value != State.DISCONNECTED) { + try { + val packet = DatagramPacket(buffer, buffer.size) + sock.receive(packet) + + lastPacketTimeMs = System.currentTimeMillis() + + val data = packet.data.copyOfRange(0, packet.length) + val message = PacketDecoder.decode(data) ?: continue + + // 解析 ASDU JSON + val report = parseStatusReport(message.asdu) + if (report != null) { + _statusReport.tryEmit(report) + } + } catch (_: SocketTimeoutException) { + // 超时正常,继续循环 + continue + } catch (_: Exception) { + if (_state.value != State.DISCONNECTED) { + _state.value = State.ERROR + } + break + } + } + } + + /** 心跳循环(1 Hz) */ + private suspend fun CoroutineScope.heartbeatLoop(sock: DatagramSocket, address: InetAddress) { + while (isActive && _state.value == State.CONNECTED) { + try { + val json = ControlCommands.heartbeat() + val packet = encoder.encode(json) + sock.send(DatagramPacket(packet, packet.size, address, port)) + } catch (_: Exception) { + break + } + delay(1000) // 1 Hz + } + } + + /** 断线检测:3 秒无数据 → 标记 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 + } + delay(500) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/m20_gamepad/network/models/ApduMessage.kt b/app/src/main/java/com/example/m20_gamepad/network/models/ApduMessage.kt new file mode 100644 index 0000000..36957a6 --- /dev/null +++ b/app/src/main/java/com/example/m20_gamepad/network/models/ApduMessage.kt @@ -0,0 +1,18 @@ +package com.example.m20_gamepad.network.models + +/** + * 完整 APDU 报文 = Header + ASDU (JSON 文本) + */ +data class ApduMessage( + val header: Header, + val asdu: String // JSON 格式的 ASDU 载荷 +) { + /** + * 序列化为完整报文字节数组(Header + ASDU) + */ + fun toBytes(): ByteArray { + val asduBytes = asdu.toByteArray(Charsets.UTF_8) + val headerWithLength = header.copy(length = asduBytes.size) + return headerWithLength.toBytes() + asduBytes + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/m20_gamepad/network/models/ControlCommands.kt b/app/src/main/java/com/example/m20_gamepad/network/models/ControlCommands.kt new file mode 100644 index 0000000..17f87c2 --- /dev/null +++ b/app/src/main/java/com/example/m20_gamepad/network/models/ControlCommands.kt @@ -0,0 +1,152 @@ +package com.example.m20_gamepad.network.models + +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.putJsonObject +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * 构建 ASDU JSON 字符串的工具函数。 + * + * 所有控制指令共用固定外层: + * ```json + * {"PatrolDevice": {"Type": int, "Command": int, "Time": "...", "Items": {}}} + * ``` + */ +object ControlCommands { + + private val timeFormatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + + /** 当前时间字符串 */ + private fun now(): String = timeFormatter.format(Date()) + + /** 构建 PatrolDevice 包裹的 JSON 字符串。Items 始终存在(可为空对象)。 */ + private fun buildAsdu( + type: Int, + command: Int, + time: String = now(), + itemsBuilder: JsonObjectBuilder.() -> Unit = {} + ): String = buildJsonObject { + putJsonObject("PatrolDevice") { + put("Type", JsonPrimitive(type)) + put("Command", JsonPrimitive(command)) + put("Time", JsonPrimitive(time)) + putJsonObject("Items") { + itemsBuilder() + } + } + }.toString() + + // ---- 2.1 心跳 ---- + fun heartbeat(time: String = now()): String = buildAsdu(type = 100, command = 100, time = time) + + // ---- 2.2 使用模式切换 ---- + fun switchMode(mode: Int, time: String = now()): String = buildAsdu( + type = 1101, command = 5, time = time + ) { + put("Mode", JsonPrimitive(mode)) + } + + // ---- 2.3 运动状态转换 ---- + fun motionControl(motionParam: Int, time: String = now()): String = buildAsdu( + type = 2, command = 22, time = time + ) { + put("MotionParam", JsonPrimitive(motionParam)) + } + + // ---- 2.4 步态切换 ---- + fun switchGait(gaitParam: Int, time: String = now()): String = buildAsdu( + type = 2, command = 23, time = time + ) { + put("GaitParam", JsonPrimitive(gaitParam)) + } + + // ---- 2.5 轴指令(常规模式) ---- + fun axisCommand( + x: Double = 0.0, y: Double = 0.0, z: Double = 0.0, + roll: Double = 0.0, pitch: Double = 0.0, yaw: Double = 0.0, + time: String = now() + ): String = buildAsdu(type = 2, command = 21, time = time) { + put("X", JsonPrimitive(x)) + put("Y", JsonPrimitive(y)) + put("Z", JsonPrimitive(z)) + put("Roll", JsonPrimitive(roll)) + put("Pitch", JsonPrimitive(pitch)) + put("Yaw", JsonPrimitive(yaw)) + } + + // ---- 2.6 速度指令(导航模式) ---- + fun speedCommand( + x: Double = 0.0, y: Double = 0.0, z: Double = 0.0, + roll: Double = 0.0, pitch: Double = 0.0, yaw: Double = 0.0, + time: String = now() + ): String = buildAsdu(type = 2, command = 25, time = time) { + put("X", JsonPrimitive(x)) + put("Y", JsonPrimitive(y)) + put("Z", JsonPrimitive(z)) + put("Roll", JsonPrimitive(roll)) + put("Pitch", JsonPrimitive(pitch)) + put("Yaw", JsonPrimitive(yaw)) + } + + // ---- 2.7 照明灯控制 ---- + fun lightControl(front: Int, back: Int, time: String = now()): String = buildAsdu( + type = 1101, command = 2, time = time + ) { + put("Front", JsonPrimitive(front)) + put("Back", JsonPrimitive(back)) + } + + // ---- 2.8 自主充电 ---- + fun chargeControl(charge: Int, time: String = now()): String = buildAsdu( + type = 2, command = 24, time = time + ) { + put("Charge", JsonPrimitive(charge)) + } + + // ---- 2.9 休眠模式设置 ---- + fun sleepSettings(sleep: Boolean, auto: Boolean, timeMinutes: Int, time: String = now()): String = buildAsdu( + type = 1101, command = 6, time = time + ) { + put("Sleep", JsonPrimitive(sleep)) + put("Auto", JsonPrimitive(auto)) + put("Time", JsonPrimitive(timeMinutes)) + } + + // ---- 2.10 休眠状态查询 ---- + fun querySleepStatus(time: String = now()): String = buildAsdu(type = 1101, command = 7, time = time) + + // ---- 订阅状态上报 ---- + fun subscribeStatus(type: Int, command: Int, time: String = now()): String = + buildAsdu(type = type, command = command, time = time) + + // ========== 常量 ========== + + // 运动状态常量 + const val MOTION_IDLE = 0 + const val MOTION_STAND = 1 + const val MOTION_DAMPING = 2 + const val MOTION_BOOT_DAMPING = 3 + const val MOTION_LIE_DOWN = 4 + const val MOTION_RL_CONTROL = 17 + + // 步态常量 + const val GAIT_BASIC = 0x1001 + const val GAIT_STAIRS = 0x1003 + const val GAIT_FLAT_AGILE = 0x3002 + const val GAIT_STAIRS_AGILE = 0x3003 + + // 使用模式常量 + const val MODE_NORMAL = 0 + const val MODE_NAVIGATION = 1 + const val MODE_ASSIST = 2 + + // 订阅 Type/Command — 用于 subscribeStatus() + val SUB_ERROR = Pair(1002, 3) + val SUB_MOTION_CONTROL = Pair(1002, 4) + val SUB_DEVICE = Pair(1002, 5) + val SUB_BASIC = Pair(1002, 6) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/m20_gamepad/network/models/ErrorCodes.kt b/app/src/main/java/com/example/m20_gamepad/network/models/ErrorCodes.kt new file mode 100644 index 0000000..feb4fd4 --- /dev/null +++ b/app/src/main/java/com/example/m20_gamepad/network/models/ErrorCodes.kt @@ -0,0 +1,88 @@ +package com.example.m20_gamepad.network.models + +/** + * 错误码 -> 含义映射。依据 proto.md 3.4(异常状态)与第 4 节(通用响应)。 + */ +object ErrorCodes { + + /** 返回错误码的人类可读描述;未知码返回 hex 形式。 */ + fun describe(errorCode: Int): String = KNOWN[errorCode] ?: "0x${errorCode.toString(16).uppercase()}" + + private val KNOWN: Map = mapOf( + // 通用响应错误码(0xE0xx) + 0x0000 to "成功", + 0xE001 to "不支持的数据格式", + 0xE002 to "数据解析失败", + 0xE003 to "不支持的协议类型", + 0xE004 to "缺少必要字段", + 0xE005 to "数据类型不匹配", + 0xE006 to "请求客户端不匹配", + 0xE007 to "无操作权限", + 0xE008 to "状态机不允许", + 0xE009 to "操作失败", + 0xE00A to "不支持的功能", + 0xE00B to "内部错误", + + // 异常状态错误码(0x8xxx) + 0x8001 to "电机温度预警", + 0x8002 to "电机温度过高保护", + 0x8003 to "电机温度致命截止", + 0x8007 to "关节驱动器过温", + 0x8008 to "驱动器欠压保护", + 0x8009 to "驱动器过压保护", + 0x8012 to "与关节驱动器通讯超时", + 0x8016 to "编码器无值", + 0x8020 to "驱动器过流保护", + 0x8022 to "关节角超限", + 0x8027 to "机身姿态错误", + 0x8029 to "运动姿态错误", + 0x8102 to "低电量预警", + 0x8103 to "保护电量", + 0x8107 to "电池放电过温保护", + 0x8115 to "电池充电过温保护", + 0x8117 to "电池单体过压保护", + 0x8201 to "CPU占用率过高预警", + 0x8202 to "CPU温度过高预警", + 0x8211 to "CPU占用率过高保护", + 0x8212 to "CPU温度过高保护", + 0x8501 to "自主充电定位超时", + 0x8503 to "自主充电定位异常", + 0x8506 to "充电桩无电流", + 0x8507 to "充电停障错误", + 0x8510 to "退桩超时" + ) + + /** 关节位编号 -> 名称(component bit 位,自低位起)。 */ + fun jointName(bit: Int): String = when (bit) { + 0 -> "左前 HipX" + 1 -> "左前 HipY" + 2 -> "左前 Knee" + 3 -> "左前 Wheel" + 4 -> "右前 HipX" + 5 -> "右前 HipY" + 6 -> "右前 Knee" + 7 -> "右前 Wheel" + 8 -> "左后 HipX" + 9 -> "左后 HipY" + 10 -> "左后 Knee" + 11 -> "左后 Wheel" + 12 -> "右后 HipX" + 13 -> "右后 HipY" + 14 -> "右后 Knee" + 15 -> "右后 Wheel" + else -> "未知部件($bit)" + } + + /** 解析 component 位掩码为部件名称列表。 */ + fun componentNames(component: Int): List { + if (component == 0) return emptyList() + val result = mutableListOf() + for (bit in 0 until 16) { + if ((component shr bit) and 1 == 1) { + result.add(jointName(bit)) + } + } + // bit0/1 在电池上下文表示电池,但关节上下文优先按关节解释 + return result + } +} diff --git a/app/src/main/java/com/example/m20_gamepad/network/models/Header.kt b/app/src/main/java/com/example/m20_gamepad/network/models/Header.kt new file mode 100644 index 0000000..b2d4061 --- /dev/null +++ b/app/src/main/java/com/example/m20_gamepad/network/models/Header.kt @@ -0,0 +1,71 @@ +package com.example.m20_gamepad.network.models + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * 16 字节报文头(小端 LE) + * + * | 偏移 | 长度 | 字段 | 值 | 说明 | + * | ---: | ---: | --- | --- | --- | + * | 0 | 1 | Sync0 | 0xEB | 固定 | + * | 1 | 1 | Sync1 | 0x91 | 固定 | + * | 2 | 1 | Sync2 | 0xEB | 固定 | + * | 3 | 1 | Sync3 | 0x90 | 固定 | + * | 4 | 2 | Length | u16 LE | ASDU 字节长度 | + * | 6 | 2 | MsgId | u16 LE | 请求/响应配对 | + * | 8 | 1 | Format | 0x01 | JSON | + * | 9 | 7 | Reserved | 0x00 * 7 | 预留 | + */ +data class Header( + val length: Int, // ASDU 字节长度 (u16) + val msgId: Int, // 消息 ID (u16), 0-65535 + val format: Int = 0x01 // 0x01 = JSON +) { + companion object { + const val SIZE = 16 + val SYNC0: Byte = 0xEB.toByte() + val SYNC1: Byte = 0x91.toByte() + val SYNC2: Byte = 0xEB.toByte() + val SYNC3: Byte = 0x90.toByte() + val FORMAT_JSON: Byte = 0x01.toByte() + + /** + * 从字节数组解码 Header + */ + fun fromBytes(data: ByteArray): Header? { + if (data.size < SIZE) return null + + val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) + + // 校验同步头 + if (buffer.get() != SYNC0) return null + if (buffer.get() != SYNC1) return null + if (buffer.get() != SYNC2) return null + if (buffer.get() != SYNC3) return null + + val length = buffer.short.toInt() and 0xFFFF + val msgId = buffer.short.toInt() and 0xFFFF + val format = buffer.get().toInt() and 0xFF + + return Header(length = length, msgId = msgId, format = format) + } + } + + /** + * 编码 Header 为 16 字节数组(小端 LE) + */ + fun toBytes(): ByteArray { + val buffer = ByteBuffer.allocate(SIZE).order(ByteOrder.LITTLE_ENDIAN) + buffer.put(SYNC0) + buffer.put(SYNC1) + buffer.put(SYNC2) + buffer.put(SYNC3) + buffer.putShort(length.toShort()) + buffer.putShort(msgId.toShort()) + buffer.put(format.toByte()) + // Reserved 7 字节,默认 0x00 + buffer.put(ByteArray(7)) + return buffer.array() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/m20_gamepad/network/models/StatusReports.kt b/app/src/main/java/com/example/m20_gamepad/network/models/StatusReports.kt new file mode 100644 index 0000000..a42f2e9 --- /dev/null +++ b/app/src/main/java/com/example/m20_gamepad/network/models/StatusReports.kt @@ -0,0 +1,359 @@ +package com.example.m20_gamepad.network.models + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.double +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * 状态上报解析结果。所有字段可为 null,表示该次上报中不存在。 + */ +data class StatusReport( + val type: Int, + val command: Int, + val time: String, + // 通用响应 + val errorCode: Int? = null, + val errorMessage: String? = null, + // 基础状态 (Cmd=6) + val basicStatus: BasicStatus? = null, + // 运控状态 (Cmd=4) + val motionStatus: MotionStatus? = null, + val motorStatus: MotorStatus? = null, + // 设备状态 (Cmd=5) + val batteryList: List? = null, + val batteryStatus: BatteryStatus? = null, + val deviceTemperature: DeviceTemperature? = null, + val led: LedStatus? = null, + val gps: GpsInfo? = null, + val devEnable: DevEnable? = null, + val cpu: CpuInfo? = null, + // 异常状态 (Cmd=3) + val errorList: List? = null +) + +data class BasicStatus( + val motionState: Int, + val gait: Int, + val charge: Int, + val hes: Int, + val controlUsageMode: Int, + val direction: Int, + val ooa: Int, + val powerManagement: Int, + val sleep: Int, + val version: String +) + +data class MotionStatus( + val roll: Double, + val pitch: Double, + val yaw: Double, + val omegaZ: Double, + val linearX: Double, + val linearY: Double, + val height: Double, + val payload: Double, + val remainMile: Double +) + +data class MotorStatus( + val joint: List, + val leftFrontHipX: Double, + val leftFrontHipY: Double, + val leftFrontKnee: Double, + val leftFrontWheel: Double, + val rightFrontHipX: Double, + val rightFrontHipY: Double, + val rightFrontKnee: Double, + val rightFrontWheel: Double, + val leftBackHipX: Double, + val leftBackHipY: Double, + val leftBackKnee: Double, + val leftBackWheel: Double, + val rightBackHipX: Double, + val rightBackHipY: Double, + val rightBackKnee: Double, + val rightBackWheel: Double +) + +data class BatteryInfo( + val voltage: Double, + val batteryLevel: Double, + val batteryTemperature: Double, + val charge: Boolean, + val serial: String +) + +data class BatteryStatus( + val voltageLeft: Double, + val voltageRight: Double, + val batteryLevelLeft: Double, + val batteryLevelRight: Double, + val batteryTemperatureLeft: Double, + val batteryTemperatureRight: Double, + val chargeLeft: Boolean, + val chargeRight: Boolean +) + +data class DeviceTemperature( + val motor: List, + val driver: List +) + +data class LedStatus( + val front: Int, + val back: Int +) + +data class GpsInfo( + val latitude: Double, + val longitude: Double, + val speed: Double, + val course: Double, + val fixQuality: Int, + val numSatellites: Int, + val altitude: Double, + val hdop: Double, + val vdop: Double, + val pdop: Double, + val visibleSatellites: Int +) + +data class DevEnable( + val fanSpeed: Int, + val loadPower: Int, + val ledHost: Int, + val ledExt: Int, + val fp: Int, + val lidarFront: Int, + val lidarBack: Int, + val gps: Int, + val videoFront: Int, + val videoBack: Int +) + +data class CpuInfo( + val aosTemperature: Double, + val aosFrequencyInt: Double, + val aosFrequencyApp: Double, + val nosTemperature: Double, + val nosFrequencyInt: Double, + val nosFrequencyApp: Double, + val gosTemperature: Double, + val gosFrequencyInt: Double, + val gosFrequencyApp: Double +) + +data class ErrorInfo( + val errorCode: Int, + val component: Int +) + +/** + * 从 JSON 解析 StatusReport。 + * 输入为完整的 JSON 字符串(包含 PatrolDevice 外层)。 + */ +fun parseStatusReport(json: String): StatusReport? { + return try { + val root = kotlinx.serialization.json.Json.parseToJsonElement(json).jsonObject + val patrolDevice = root["PatrolDevice"]?.jsonObject ?: return null + val type = patrolDevice["Type"]?.jsonPrimitive?.int ?: return null + val command = patrolDevice["Command"]?.jsonPrimitive?.int ?: return null + val time = patrolDevice["Time"]?.jsonPrimitive?.content.orEmpty() + val items = patrolDevice["Items"]?.jsonObject ?: return null + + StatusReport( + type = type, + command = command, + time = time, + errorCode = items["ErrorCode"]?.jsonPrimitive?.int, + errorMessage = items["ErrorMessage"]?.jsonPrimitive?.content, + basicStatus = parseBasicStatus(items["BasicStatus"]?.jsonObject), + motionStatus = parseMotionStatus(items["MotionStatus"]?.jsonObject), + motorStatus = parseMotorStatus(items["MotorStatus"]?.jsonObject), + batteryList = parseBatteryList(items["BatteryList"]?.jsonArray), + batteryStatus = parseBatteryStatus(items["BatteryStatus"]?.jsonObject), + deviceTemperature = parseDeviceTemperature(items["DeviceTemperature"]?.jsonObject), + led = parseLed(items["Led"]?.jsonObject), + gps = parseGps(items["GPS"]?.jsonObject), + devEnable = parseDevEnable(items["DevEnable"]?.jsonObject), + cpu = parseCpu(items["CPU"]?.jsonObject), + errorList = parseErrorList(items["ErrorList"]?.jsonArray) + ) + } catch (_: Exception) { + null + } +} + +private fun parseBasicStatus(obj: JsonObject?): BasicStatus? { + if (obj == null) return null + return BasicStatus( + motionState = obj["MotionState"]?.jsonPrimitive?.int ?: return null, + gait = obj["Gait"]?.jsonPrimitive?.int ?: 0, + charge = obj["Charge"]?.jsonPrimitive?.int ?: 0, + hes = obj["HES"]?.jsonPrimitive?.int ?: 0, + controlUsageMode = obj["ControlUsageMode"]?.jsonPrimitive?.int ?: 0, + direction = obj["Direction"]?.jsonPrimitive?.int ?: 0, + ooa = obj["OOA"]?.jsonPrimitive?.int ?: 0, + powerManagement = obj["PowerManagement"]?.jsonPrimitive?.int ?: 0, + sleep = obj["Sleep"]?.jsonPrimitive?.int ?: 0, + version = obj["Version"]?.jsonPrimitive?.content.orEmpty() + ) +} + +private fun parseMotionStatus(obj: JsonObject?): MotionStatus? { + if (obj == null) return null + return MotionStatus( + roll = obj["Roll"]?.jsonPrimitive?.double ?: 0.0, + pitch = obj["Pitch"]?.jsonPrimitive?.double ?: 0.0, + yaw = obj["Yaw"]?.jsonPrimitive?.double ?: 0.0, + omegaZ = obj["OmegaZ"]?.jsonPrimitive?.double ?: 0.0, + linearX = obj["LinearX"]?.jsonPrimitive?.double ?: 0.0, + linearY = obj["LinearY"]?.jsonPrimitive?.double ?: 0.0, + height = obj["Height"]?.jsonPrimitive?.double ?: 0.0, + payload = obj["Payload"]?.jsonPrimitive?.double ?: 0.0, + remainMile = obj["RemainMile"]?.jsonPrimitive?.double ?: 0.0 + ) +} + +private fun parseMotorStatus(obj: JsonObject?): MotorStatus? { + if (obj == null) return null + fun getDouble(key: String) = obj[key]?.jsonPrimitive?.double ?: 0.0 + fun getList(key: String) = obj[key]?.jsonArray?.map { it.jsonPrimitive.double } ?: emptyList() + return MotorStatus( + joint = getList("Joint"), + leftFrontHipX = getDouble("LeftFrontHipX"), + leftFrontHipY = getDouble("LeftFrontHipY"), + leftFrontKnee = getDouble("LeftFrontKnee"), + leftFrontWheel = getDouble("LeftFrontWheel"), + rightFrontHipX = getDouble("RightFrontHipX"), + rightFrontHipY = getDouble("RightFrontHipY"), + rightFrontKnee = getDouble("RightFrontKnee"), + rightFrontWheel = getDouble("RightFrontWheel"), + leftBackHipX = getDouble("LeftBackHipX"), + leftBackHipY = getDouble("LeftBackHipY"), + leftBackKnee = getDouble("LeftBackKnee"), + leftBackWheel = getDouble("LeftBackWheel"), + rightBackHipX = getDouble("RightBackHipX"), + rightBackHipY = getDouble("RightBackHipY"), + rightBackKnee = getDouble("RightBackKnee"), + rightBackWheel = getDouble("RightBackWheel") + ) +} + +private fun parseBatteryList(arr: JsonElement?): List? { + if (arr == null) return null + return arr.jsonArray.map { elem -> + val obj = elem.jsonObject + BatteryInfo( + voltage = obj["Voltage"]?.jsonPrimitive?.double ?: 0.0, + batteryLevel = obj["BatteryLevel"]?.jsonPrimitive?.double ?: 0.0, + batteryTemperature = obj["battery_temperature"]?.jsonPrimitive?.double ?: 0.0, + charge = obj["charge"]?.jsonPrimitive?.boolean ?: false, + serial = obj["serial"]?.jsonPrimitive?.content.orEmpty() + ) + } +} + +private fun parseBatteryStatus(obj: JsonObject?): BatteryStatus? { + if (obj == null) return null + return BatteryStatus( + voltageLeft = obj["VoltageLeft"]?.jsonPrimitive?.double ?: 0.0, + voltageRight = obj["VoltageRight"]?.jsonPrimitive?.double ?: 0.0, + batteryLevelLeft = obj["BatteryLevelLeft"]?.jsonPrimitive?.double ?: 0.0, + batteryLevelRight = obj["BatteryLevelRight"]?.jsonPrimitive?.double ?: 0.0, + batteryTemperatureLeft = obj["battery_temperatureLeft"]?.jsonPrimitive?.double ?: 0.0, + batteryTemperatureRight = obj["battery_temperatureRight"]?.jsonPrimitive?.double ?: 0.0, + chargeLeft = obj["chargeLeft"]?.jsonPrimitive?.boolean ?: false, + chargeRight = obj["chargeRight"]?.jsonPrimitive?.boolean ?: false + ) +} + +private fun parseDeviceTemperature(obj: JsonObject?): DeviceTemperature? { + if (obj == null) return null + fun getList(key: String) = obj[key]?.jsonArray?.map { it.jsonPrimitive.double } ?: emptyList() + return DeviceTemperature( + motor = getList("Motor"), + driver = getList("Driver") + ) +} + +private fun parseLed(obj: JsonObject?): LedStatus? { + if (obj == null) return null + val fill = obj["Fill"]?.jsonObject ?: return null + return LedStatus( + front = fill["Front"]?.jsonPrimitive?.int ?: 0, + back = fill["Back"]?.jsonPrimitive?.int ?: 0 + ) +} + +private fun parseGps(obj: JsonObject?): GpsInfo? { + if (obj == null) return null + return GpsInfo( + latitude = obj["Latitude"]?.jsonPrimitive?.double ?: 0.0, + longitude = obj["Longitude"]?.jsonPrimitive?.double ?: 0.0, + speed = obj["Speed"]?.jsonPrimitive?.double ?: 0.0, + course = obj["Course"]?.jsonPrimitive?.double ?: 0.0, + fixQuality = obj["FixQuality"]?.jsonPrimitive?.int ?: 0, + numSatellites = obj["NumSatellites"]?.jsonPrimitive?.int ?: 0, + altitude = obj["Altitude"]?.jsonPrimitive?.double ?: 0.0, + hdop = obj["HDOP"]?.jsonPrimitive?.double ?: 0.0, + vdop = obj["VDOP"]?.jsonPrimitive?.double ?: 0.0, + pdop = obj["PDOP"]?.jsonPrimitive?.double ?: 0.0, + visibleSatellites = obj["VisibleSatellites"]?.jsonPrimitive?.int ?: 0 + ) +} + +private fun parseDevEnable(obj: JsonObject?): DevEnable? { + if (obj == null) return null + val lidar = obj["Lidar"]?.jsonObject + val video = obj["Video"]?.jsonObject + return DevEnable( + fanSpeed = obj["FanSpeed"]?.jsonPrimitive?.int ?: 0, + loadPower = obj["LoadPower"]?.jsonPrimitive?.int ?: 0, + ledHost = obj["LedHost"]?.jsonPrimitive?.int ?: 0, + ledExt = obj["LedExt"]?.jsonPrimitive?.int ?: 0, + fp = obj["FP"]?.jsonPrimitive?.int ?: 0, + lidarFront = lidar?.get("Front")?.jsonPrimitive?.int ?: 0, + lidarBack = lidar?.get("Back")?.jsonPrimitive?.int ?: 0, + gps = obj["GPS"]?.jsonPrimitive?.int ?: 0, + videoFront = video?.get("Front")?.jsonPrimitive?.int ?: 0, + videoBack = video?.get("Back")?.jsonPrimitive?.int ?: 0 + ) +} + +private fun parseCpu(obj: JsonObject?): CpuInfo? { + if (obj == null) return null + fun getSub(key: String) = obj[key]?.jsonObject + fun getDouble(sub: JsonObject?, field: String) = sub?.get(field)?.jsonPrimitive?.double ?: 0.0 + val aos = getSub("AOS") + val nos = getSub("NOS") + val gos = getSub("GOS") + return CpuInfo( + aosTemperature = getDouble(aos, "Temperature"), + aosFrequencyInt = getDouble(aos, "FrequencyInt"), + aosFrequencyApp = getDouble(aos, "FrequencyApp"), + nosTemperature = getDouble(nos, "Temperature"), + nosFrequencyInt = getDouble(nos, "FrequencyInt"), + nosFrequencyApp = getDouble(nos, "FrequencyApp"), + gosTemperature = getDouble(gos, "Temperature"), + gosFrequencyInt = getDouble(gos, "FrequencyInt"), + gosFrequencyApp = getDouble(gos, "FrequencyApp") + ) +} + +private fun parseErrorList(arr: JsonElement?): List? { + if (arr == null) return null + return arr.jsonArray.map { elem -> + val obj = elem.jsonObject + ErrorInfo( + errorCode = obj["errorCode"]?.jsonPrimitive?.int ?: 0, + component = obj["component"]?.jsonPrimitive?.int ?: 0 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/m20_gamepad/service/JoystickController.kt b/app/src/main/java/com/example/m20_gamepad/service/JoystickController.kt new file mode 100644 index 0000000..b57dac1 --- /dev/null +++ b/app/src/main/java/com/example/m20_gamepad/service/JoystickController.kt @@ -0,0 +1,55 @@ +package com.example.m20_gamepad.service + +import com.example.m20_gamepad.network.models.ControlCommands +import kotlin.math.cos +import kotlin.math.sin + +/** + * 摇杆输入 -> 协议轴指令映射。 + * + * 摇杆库 angle = atan2(-thumbOffsetY, -thumbOffsetX),弧度范围 [-PI, PI]: + * - 0 = 左 + * - PI/2 = 上 + * - PI = 右 + * - -PI/2 = 下 + * + * 协议轴定义: + * - X 正 = 前进,Y 正 = 左移,Yaw 正 = 逆时针(左转) + * + * 映射公式: + * - 左摇杆 X(前后) = sin(angle) * power/100 + * - 左摇杆 Y(左右) = cos(angle) * power/100 + * - 右摇杆 Yaw = cos(angle) * power/100 + */ +object JoystickController { + + /** 左摇杆角度/强度 -> 轴指令 JSON(X=前后, Y=左右) */ + fun leftStickToAxisCommand(angle: Double, power: Double): String { + val scale = power / 100.0 + val x = sin(angle) * scale + val y = cos(angle) * scale + return ControlCommands.axisCommand(x = x, y = y) + } + + /** 右摇杆角度/强度 -> 轴指令 JSON(Yaw=偏航) */ + fun rightStickToAxisCommand(angle: Double, power: Double): String { + val yaw = cos(angle) * power / 100.0 + return ControlCommands.axisCommand(yaw = yaw) + } + + /** 双摇杆合并 -> 轴指令 JSON(左摇杆控制 X/Y,右摇杆控制 Yaw) */ + fun dualStickToAxisCommand( + leftAngle: Double, leftPower: Double, + rightAngle: Double, rightPower: Double + ): String { + val leftScale = leftPower / 100.0 + val rightScale = rightPower / 100.0 + val x = sin(leftAngle) * leftScale + val y = cos(leftAngle) * leftScale + val yaw = cos(rightAngle) * rightScale + return ControlCommands.axisCommand(x = x, y = y, yaw = yaw) + } + + /** 零速度指令(松手时发送,保持连接活跃) */ + fun zeroAxisCommand(): String = ControlCommands.axisCommand() +} \ No newline at end of file diff --git a/app/src/main/java/com/example/m20_gamepad/service/RobotConnection.kt b/app/src/main/java/com/example/m20_gamepad/service/RobotConnection.kt new file mode 100644 index 0000000..1899544 --- /dev/null +++ b/app/src/main/java/com/example/m20_gamepad/service/RobotConnection.kt @@ -0,0 +1,155 @@ +package com.example.m20_gamepad.service + +import com.example.m20_gamepad.network.ProtocolClient +import com.example.m20_gamepad.network.models.ControlCommands +import com.example.m20_gamepad.network.models.StatusReport +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * 连接状态机 + 运动状态机。负责连接状态流转校验与运动指令前置条件校验。 + * + * - 连接状态:校验 [ProtocolClient.State] 转换合法性,避免非法跳转。 + * - 运动状态:依据 proto.md 2.3 节,正向流程 `空闲 -> 站立 -> RL控制`, + * 仅 RL 控制状态允许步态切换与轴指令。 + * + * 使用方式:ProtocolClient 状态变化时调用 [updateConnectionState], + * 收到状态上报时调用 [updateFromReport];下发运动指令前调用 + * [requestMotionTransition] / [canSwitchGait] / [canSendAxisCommand] 校验。 + */ +class RobotConnection { + + // ==================== 连接状态机 ==================== + + private val _connectionState = MutableStateFlow(ProtocolClient.State.DISCONNECTED) + val connectionState: StateFlow = _connectionState.asStateFlow() + + val isConnected: Boolean get() = _connectionState.value == ProtocolClient.State.CONNECTED + + /** + * 更新连接状态。非法转换被忽略并返回 false。 + * 合法转换:避免从 ERROR/TIMEOUT 直接跳到 CONNECTED,必须先 DISCONNECTED。 + */ + fun updateConnectionState(target: ProtocolClient.State): Boolean { + val current = _connectionState.value + if (!isValidConnectionTransition(current, target)) return false + _connectionState.value = target + return true + } + + /** 连接状态流转合法性。 */ + private fun isValidConnectionTransition( + from: ProtocolClient.State, + to: ProtocolClient.State + ): Boolean { + if (from == to) return true + return when (from) { + ProtocolClient.State.DISCONNECTED -> + to == ProtocolClient.State.CONNECTING + ProtocolClient.State.CONNECTING -> + to == ProtocolClient.State.CONNECTED || + to == ProtocolClient.State.ERROR || + to == ProtocolClient.State.DISCONNECTED + ProtocolClient.State.CONNECTED -> + to == ProtocolClient.State.TIMEOUT || + to == ProtocolClient.State.ERROR || + to == ProtocolClient.State.DISCONNECTED + ProtocolClient.State.TIMEOUT -> + to == ProtocolClient.State.CONNECTED || + to == ProtocolClient.State.DISCONNECTED || + to == ProtocolClient.State.ERROR + ProtocolClient.State.ERROR -> + to == ProtocolClient.State.DISCONNECTED + } + } + + // ==================== 运动状态机 ==================== + + private val _motionState = MutableStateFlow(ControlCommands.MOTION_IDLE) + val motionState: StateFlow = _motionState.asStateFlow() + + /** 是否允许下发步态切换(仅 RL 控制)。 */ + val canSwitchGait: Boolean + get() = _motionState.value == ControlCommands.MOTION_RL_CONTROL + + /** 是否允许下发轴指令(仅 RL 控制)。 */ + val canSendAxisCommand: Boolean + get() = _motionState.value == ControlCommands.MOTION_RL_CONTROL + + /** 是否允许下发运动状态转换指令(需已连接)。 */ + val canSendMotionControl: Boolean + get() = _connectionState.value == ProtocolClient.State.CONNECTED + + /** + * 校验运动状态转换请求。 + * + * 依据 proto.md 2.3:`空闲 -> 站立 -> RL控制` 正向流程;其余常见回退(站立->空闲/ + * RL控制->站立/各状态->趴下->站立)允许。软急停/开机阻尼可从任意活跃态进入,恢复到站立。 + */ + fun requestMotionTransition(target: Int): MotionTransitionResult { + if (!isConnected) return MotionTransitionResult.REJECTED_NOT_CONNECTED + val current = _motionState.value + if (current == target) return MotionTransitionResult.REJECTED_ALREADY_IN_STATE + if (!isValidMotionTransition(current, target)) { + return MotionTransitionResult.REJECTED_INVALID_TRANSITION + } + // 本地乐观更新:实际状态以下一次状态上报为准 + _motionState.value = target + return MotionTransitionResult.ACCEPTED + } + + /** 运动状态转换合法性。 */ + private fun isValidMotionTransition(from: Int, to: Int): Boolean { + // 阻尼态(软急停/开机阻尼)可从任意活跃态进入 + if (to == ControlCommands.MOTION_DAMPING || to == ControlCommands.MOTION_BOOT_DAMPING) { + return true + } + // 趴下可从站立/RL控制/空闲进入 + if (to == ControlCommands.MOTION_LIE_DOWN) { + return from == ControlCommands.MOTION_IDLE || + from == ControlCommands.MOTION_STAND || + from == ControlCommands.MOTION_RL_CONTROL + } + return when (from) { + ControlCommands.MOTION_IDLE -> + to == ControlCommands.MOTION_STAND + ControlCommands.MOTION_STAND -> + to == ControlCommands.MOTION_IDLE || + to == ControlCommands.MOTION_RL_CONTROL + ControlCommands.MOTION_RL_CONTROL -> + to == ControlCommands.MOTION_STAND + ControlCommands.MOTION_DAMPING, + ControlCommands.MOTION_BOOT_DAMPING -> + to == ControlCommands.MOTION_STAND + ControlCommands.MOTION_LIE_DOWN -> + to == ControlCommands.MOTION_STAND + else -> false + } + } + + /** + * 从状态上报同步真实运动状态。服务端上报值优先于本地乐观更新。 + */ + fun updateFromReport(report: StatusReport) { + report.basicStatus?.let { _motionState.value = it.motionState } + } + + /** 断开/重置:清回空闲与未连接。 */ + fun reset() { + _connectionState.value = ProtocolClient.State.DISCONNECTED + _motionState.value = ControlCommands.MOTION_IDLE + } +} + +/** 运动状态转换校验结果。 */ +enum class MotionTransitionResult { + /** 允许,已本地更新状态。 */ + ACCEPTED, + /** 未连接。 */ + REJECTED_NOT_CONNECTED, + /** 状态机不允许该转换。 */ + REJECTED_INVALID_TRANSITION, + /** 已处于目标状态。 */ + REJECTED_ALREADY_IN_STATE +} diff --git a/app/src/main/java/com/example/m20_gamepad/ui/MainScreen.kt b/app/src/main/java/com/example/m20_gamepad/ui/MainScreen.kt new file mode 100644 index 0000000..b7dd01b --- /dev/null +++ b/app/src/main/java/com/example/m20_gamepad/ui/MainScreen.kt @@ -0,0 +1,484 @@ +package com.example.m20_gamepad.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +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.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 +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +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.data.SettingsRepository +import com.example.m20_gamepad.network.ProtocolClient +import com.example.m20_gamepad.network.models.ControlCommands +import com.example.m20_gamepad.ui.components.StatusPanel +import com.example.m20_gamepad.video.RtspVideoPlayer + +// 半透明覆盖层颜色 +private val OverlayBg = Color(0x88000000) +private val OverlayText = Color.White + +/** + * 主界面:RTSP 视频背景 + 双摇杆覆盖层 + 状态栏 + 控制按钮。 + * + * @param repository 应用级设置仓库(注入 MainViewModel) + * @param onSettingsClick 打开设置界面 + */ +@Composable +fun MainScreen( + repository: SettingsRepository, + onSettingsClick: () -> Unit, + viewModel: MainViewModel = viewModel( + factory = viewModelFactory { + initializer { MainViewModel(repository) } + } + ) +) { + val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() + val status by viewModel.latestStatus.collectAsStateWithLifecycle() + val settings by viewModel.settings.collectAsStateWithLifecycle() + val motionState by viewModel.motionState.collectAsStateWithLifecycle() + val errorList by viewModel.errorList.collectAsStateWithLifecycle() + val motionStatus by viewModel.motionStatus.collectAsStateWithLifecycle() + val deviceTemperature by viewModel.deviceTemperature.collectAsStateWithLifecycle() + val batteryStatus by viewModel.batteryStatus.collectAsStateWithLifecycle() + val lastUpdate by viewModel.lastUpdate.collectAsStateWithLifecycle() + val actionFeedback by viewModel.actionFeedback.collectAsStateWithLifecycle() + + var statusPanelVisible by remember { mutableStateOf(false) } + val snackbarHostState = remember { SnackbarHostState() } + + // 指令校验反馈 -> Snackbar + LaunchedEffect(actionFeedback) { + val msg = actionFeedback + if (!msg.isNullOrBlank()) { + snackbarHostState.showSnackbar(msg) + viewModel.clearActionFeedback() + } + } + + Box(modifier = Modifier.fillMaxSize()) { + // ---- 视频背景层 ---- + RtspVideoPlayer( + url = settings.rtspUrl, + codec = settings.videoCodec, + modifier = Modifier.fillMaxSize() + ) + + // ---- 顶部状态栏 ---- + StatusBar( + connectionState = connectionState, + status = status, + onStatusClick = { statusPanelVisible = true }, + onSettingsClick = onSettingsClick, + modifier = Modifier + .align(Alignment.TopCenter) + .windowInsetsPadding(WindowInsets.statusBars) + ) + + // ---- 底部控制区 ---- + BottomControls( + viewModel = viewModel, + connectionState = connectionState, + motionState = motionState, + modifier = Modifier + .align(Alignment.BottomCenter) + .windowInsetsPadding(WindowInsets.navigationBars) + ) + + // ---- 状态上报面板 ---- + StatusPanel( + visible = statusPanelVisible, + errorList = errorList, + motionStatus = motionStatus, + deviceTemperature = deviceTemperature, + batteryStatus = batteryStatus, + lastUpdate = lastUpdate, + onDismiss = { statusPanelVisible = false }, + modifier = Modifier.fillMaxSize() + ) + + // ---- Snackbar ---- + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } +} + +// ==================== 顶部状态栏 ==================== + +@Composable +private fun StatusBar( + connectionState: ProtocolClient.State, + status: com.example.m20_gamepad.network.models.StatusReport?, + onStatusClick: () -> Unit, + onSettingsClick: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .background(OverlayBg) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + // 连接状态指示 + ConnectionIndicator(connectionState) + + // 电量 / 运动状态 + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + status?.batteryStatus?.let { + val avgLevel = (it.batteryLevelLeft + it.batteryLevelRight) / 2 + StatusChip("电量 ${avgLevel.toInt()}%") + } + status?.basicStatus?.let { + StatusChip(motionStateText(it.motionState)) + } + } + + // 状态详情 + 设置按钮 + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onStatusClick) { + Icon( + Icons.Default.Info, + contentDescription = "状态", + tint = OverlayText + ) + } + IconButton(onClick = onSettingsClick) { + Icon( + Icons.Default.Settings, + contentDescription = "设置", + tint = OverlayText + ) + } + } + } +} + +@Composable +private fun ConnectionIndicator(state: ProtocolClient.State) { + val (color, text) = when (state) { + ProtocolClient.State.CONNECTED -> Color(0xFF4CAF50) to "已连接" + ProtocolClient.State.CONNECTING -> Color(0xFFFFC107) to "连接中" + ProtocolClient.State.TIMEOUT -> Color(0xFFFF9800) to "断线" + 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) + } +} + +@Composable +private fun StatusChip(text: String) { + Surface( + color = Color(0x44000000), + shape = RoundedCornerShape(12.dp) + ) { + Text( + text = text, + color = OverlayText, + fontSize = 13.sp, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp) + ) + } +} + +// ==================== 底部控制区 ==================== + +@Composable +private fun BottomControls( + viewModel: MainViewModel, + connectionState: ProtocolClient.State, + motionState: Int, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(bottom = 16.dp, start = 12.dp, end = 12.dp), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.SpaceBetween + ) { + // 左摇杆(X/Y 移动) + Joystick( + modifier = Modifier.size(140.dp), + type = JoystickType.EIGHT_AXIS, + padColor = Color(0x55808080), + buttonColor = Color(0xCCFFFFFF), + onMove = { angle, power, _ -> + viewModel.updateLeftStick(angle, power) + } + ) + + // 中间控制按钮组 + ControlButtons( + viewModel = viewModel, + connectionState = connectionState, + motionState = motionState + ) + + // 右摇杆(Yaw 旋转) + Joystick( + modifier = Modifier.size(140.dp), + type = JoystickType.EIGHT_AXIS, + padColor = Color(0x55808080), + buttonColor = Color(0xCCFFFFFF), + onMove = { angle, power, _ -> + viewModel.updateRightStick(angle, power) + } + ) + } +} + +@Composable +private fun ControlButtons( + viewModel: MainViewModel, + connectionState: ProtocolClient.State, + motionState: Int +) { + 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 + ) { + 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("照明") + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + MiniButton("前灯开") { viewModel.sendLightControl(1, 0) } + MiniButton("前灯关") { viewModel.sendLightControl(0, 0) } + } + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + MiniButton("后灯开") { viewModel.sendLightControl(0, 1) } + MiniButton("后灯关") { viewModel.sendLightControl(0, 0) } + } + 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 SectionLabel(text: String) { + Text( + text, + color = Color(0xFFB0B0B0), + fontSize = 11.sp, + modifier = Modifier.fillMaxWidth() + ) +} + +@Composable +private fun Separator() { + Spacer(Modifier.height(2.dp)) + Box( + modifier = Modifier + .width(80.dp) + .height(1.dp) + .background(Color(0x44FFFFFF)) + ) + Spacer(Modifier.height(2.dp)) +} + +// ==================== 辅助函数 ==================== + +private fun motionStateText(state: Int): String = when (state) { + 0 -> "空闲" + 1 -> "站立" + 2 -> "软急停" + 3 -> "开机阻尼" + 4 -> "趴下" + 17 -> "RL控制" + else -> "未知($state)" +} \ No newline at end of file diff --git a/app/src/main/java/com/example/m20_gamepad/ui/MainViewModel.kt b/app/src/main/java/com/example/m20_gamepad/ui/MainViewModel.kt new file mode 100644 index 0000000..01acee1 --- /dev/null +++ b/app/src/main/java/com/example/m20_gamepad/ui/MainViewModel.kt @@ -0,0 +1,266 @@ +package com.example.m20_gamepad.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.m20_gamepad.data.AppSettings +import com.example.m20_gamepad.data.SettingsRepository +import com.example.m20_gamepad.network.ProtocolClient +import com.example.m20_gamepad.network.models.BatteryStatus +import com.example.m20_gamepad.network.models.ControlCommands +import com.example.m20_gamepad.network.models.DeviceTemperature +import com.example.m20_gamepad.network.models.ErrorInfo +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.RobotConnection +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * 主界面 ViewModel。管理 ProtocolClient 生命周期、状态收集、轴指令 20Hz 发送。 + * + * 连接参数从 [SettingsRepository] 读取,不再使用硬编码默认值。 + * 设置变更时若已连接则断开,下次连接使用新参数。 + * + * 运动指令经 [RobotConnection] 状态机校验:步态切换/轴指令仅 RL 控制下放行, + * 运动状态转换按 proto.md 2.3 正向流程校验。 + * + * @param repository 应用级设置仓库 + */ +class MainViewModel( + private val repository: SettingsRepository +) : ViewModel() { + + companion object { + private const val AXIS_SEND_INTERVAL_MS = 50L // 20 Hz + } + + private var client: ProtocolClient? = null + private val robot = RobotConnection() + + val connectionState: StateFlow = robot.connectionState + + /** 当前运动状态(来自状态上报,0=空闲 1=站立 17=RL控制 ...)。 */ + val motionState: StateFlow = robot.motionState + + private val _latestStatus = MutableStateFlow(null) + val latestStatus: StateFlow = _latestStatus.asStateFlow() + + /** 状态面板用:分项最新值(各上报频率不同,分别缓存最近一次)。 */ + private val _errorList = MutableStateFlow?>(null) + val errorList: StateFlow?> = _errorList.asStateFlow() + + private val _motionStatus = MutableStateFlow(null) + val motionStatus: StateFlow = _motionStatus.asStateFlow() + + private val _deviceTemperature = MutableStateFlow(null) + val deviceTemperature: StateFlow = _deviceTemperature.asStateFlow() + + private val _batteryStatus = MutableStateFlow(null) + val batteryStatus: StateFlow = _batteryStatus.asStateFlow() + + private val _lastUpdate = MutableStateFlow(null) + val lastUpdate: StateFlow = _lastUpdate.asStateFlow() + + /** 最近一次指令的校验反馈(供 UI 提示)。 */ + private val _actionFeedback = MutableStateFlow(null) + val actionFeedback: StateFlow = _actionFeedback.asStateFlow() + + /** 当前设置快照(主界面用于驱动视频播放器地址/编码器) */ + val settings: StateFlow = repository.settings.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = AppSettings() + ) + + // 摇杆状态(UI 线程更新,轴指令协程读取) + @Volatile private var leftAngle = 0.0 + @Volatile private var leftPower = 0.0 + @Volatile private var rightAngle = 0.0 + @Volatile private var rightPower = 0.0 + + private var axisJob: Job? = null + private var statusJob: Job? = null + private var settingsWatchJob: Job? = null + + val isConnected: Boolean get() = robot.isConnected + + init { + // 设置变更时断开当前连接,下次 connect 使用新参数 + settingsWatchJob = viewModelScope.launch { + settings + .map { it.robotHost to it.robotPort } + .distinctUntilChanged() + .collect { if (client != null) disconnect() } + } + } + + /** + * 建立连接并启动轴指令循环。参数取自当前设置。 + * + * 已连接时为幂等 no-op;处于断线/错误状态时先清理旧 client 再重连, + * 避免旧 client 非空导致无法从 TIMEOUT/ERROR 恢复。 + */ + fun connect() { + if (client != null) { + if (robot.isConnected) return + // 断线/错误:先清理旧连接再重连 + disconnect() + } + val s = settings.value + + val c = ProtocolClient(s.robotHost, s.robotPort, viewModelScope) + client = c + + statusJob = viewModelScope.launch { + c.state.collect { state -> + // 经状态机校验后写入;非法跳转忽略(保留旧状态) + robot.updateConnectionState(state) + } + } + viewModelScope.launch { + c.onStatusReport.collect { report -> handleStatusReport(report) } + } + + c.connect() + startAxisLoop() + } + + /** + * 断开连接。 + */ + fun disconnect() { + axisJob?.cancel() + axisJob = null + statusJob?.cancel() + statusJob = null + client?.disconnect() + client = null + robot.reset() + _latestStatus.value = null + _errorList.value = null + _motionStatus.value = null + _deviceTemperature.value = null + _batteryStatus.value = null + _lastUpdate.value = null + } + + /** 状态上报分发:更新机器人运动状态机 + 各分项缓存。 */ + private fun handleStatusReport(report: StatusReport) { + _latestStatus.value = report + _lastUpdate.value = report.time + robot.updateFromReport(report) + report.errorList?.let { _errorList.value = it } + report.motionStatus?.let { _motionStatus.value = it } + report.deviceTemperature?.let { _deviceTemperature.value = it } + report.batteryStatus?.let { _batteryStatus.value = it } + // 通用响应错误提示 + report.errorCode?.let { code -> + if (code != 0) { + _actionFeedback.value = "指令失败: ${report.errorMessage ?: code.toString()}" + } + } + } + + // ---- 摇杆输入 ---- + + fun updateLeftStick(angle: Double, power: Double) { + leftAngle = angle + leftPower = power + } + + fun updateRightStick(angle: Double, power: Double) { + rightAngle = angle + rightPower = power + } + + // ---- 控制指令(经状态机校验) ---- + + fun sendMotionControl(motionParam: Int) { + when (robot.requestMotionTransition(motionParam)) { + MotionTransitionResult.ACCEPTED -> + client?.sendCommandBlocking(ControlCommands.motionControl(motionParam)) + MotionTransitionResult.REJECTED_NOT_CONNECTED -> + _actionFeedback.value = "未连接,无法切换运动状态" + MotionTransitionResult.REJECTED_INVALID_TRANSITION -> + _actionFeedback.value = "当前状态不允许切换到该运动状态" + MotionTransitionResult.REJECTED_ALREADY_IN_STATE -> + _actionFeedback.value = "已处于该运动状态" + } + } + + fun sendSwitchGait(gaitParam: Int) { + if (!robot.canSwitchGait) { + _actionFeedback.value = "需先进入 RL 控制状态才能切换步态" + return + } + client?.sendCommandBlocking(ControlCommands.switchGait(gaitParam)) + } + + fun sendSwitchMode(mode: Int) { + client?.sendCommandBlocking(ControlCommands.switchMode(mode)) + } + + fun sendLightControl(front: Int, back: Int) { + client?.sendCommandBlocking(ControlCommands.lightControl(front, back)) + } + + fun sendChargeControl(charge: Int) { + client?.sendCommandBlocking(ControlCommands.chargeControl(charge)) + } + + fun sendSleepSettings(sleep: Boolean, auto: Boolean = false, timeMinutes: Int = 5) { + client?.sendCommandBlocking( + ControlCommands.sleepSettings(sleep, auto, timeMinutes) + ) + } + + /** 清除指令反馈。 */ + fun clearActionFeedback() { + _actionFeedback.value = null + } + + // ---- 内部 ---- + + /** + * 轴指令发送循环:20 Hz 发送双摇杆合并的轴指令。 + * 仅在 RL 控制状态下发送摇杆指令;否则发零速度保持连接(避免残留运动)。 + */ + private fun startAxisLoop() { + axisJob = viewModelScope.launch { + while (isActive) { + val c = client + if (c != null && c.isConnected) { + val cmd = if (robot.canSendAxisCommand) { + JoystickController.dualStickToAxisCommand( + leftAngle, leftPower, + rightAngle, rightPower + ) + } else { + // 非 RL 控制:发零速度 + ControlCommands.axisCommand() + } + c.sendCommand(cmd) + } + delay(AXIS_SEND_INTERVAL_MS) + } + } + } + + override fun onCleared() { + disconnect() + settingsWatchJob?.cancel() + super.onCleared() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/m20_gamepad/ui/SettingsScreen.kt b/app/src/main/java/com/example/m20_gamepad/ui/SettingsScreen.kt new file mode 100644 index 0000000..e2dd586 --- /dev/null +++ b/app/src/main/java/com/example/m20_gamepad/ui/SettingsScreen.kt @@ -0,0 +1,266 @@ +package com.example.m20_gamepad.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.example.m20_gamepad.data.AppSettings +import com.example.m20_gamepad.data.SettingsRepository +import com.example.m20_gamepad.video.VideoCodec +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +/** + * 设置界面:配置机器人 IP/端口、RTSP 地址、视频编码器。 + * + * 表单本地持有草稿值,点击保存时一次性写入仓库并回调 [onSaved]。 + * 返回按钮直接回调 [onBack],未保存的改动丢弃。 + * + * @param repository 应用级设置仓库(由 M20App 提供) + * @param onBack 返回主界面 + * @param onSaved 保存成功后回调(主界面据此断开旧连接并使用新参数) + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + repository: SettingsRepository, + onBack: () -> Unit, + onSaved: () -> Unit +) { + val scope = rememberCoroutineScope() + + // 表单草稿:先用默认值占位,避免空值触发校验报错;DataStore 读取后覆盖为真实存储值 + var host by remember { mutableStateOf(AppSettings.DEFAULT_ROBOT_HOST) } + var portText by remember { mutableStateOf(AppSettings.DEFAULT_ROBOT_PORT.toString()) } + var rtspUrl by remember { mutableStateOf(AppSettings.DEFAULT_RTSP_URL) } + var codec by remember { mutableStateOf(VideoCodec.AUTO) } + var hostError by remember { mutableStateOf(null) } + var portError by remember { mutableStateOf(null) } + var initialized by remember { mutableStateOf(false) } + + LaunchedEffect(repository) { + if (!initialized) { + val s = repository.settings.first() + host = s.robotHost + portText = s.robotPort.toString() + rtspUrl = s.rtspUrl + codec = s.videoCodec + initialized = true + } + } + + val saveEnabled = initialized && + AppSettings.isValidHost(host) && + (portText.toIntOrNull()?.let { AppSettings.isValidPort(it) } == true) + + Scaffold( + topBar = { + TopAppBar( + title = { Text("设置") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "返回" + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors() + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // ---- 连接配置 ---- + SectionLabel("机器人连接") + + OutlinedTextField( + value = host, + onValueChange = { + host = it + hostError = if (AppSettings.isValidHost(it)) null else "地址不能含空格" + }, + label = { Text("机器人 IP 地址") }, + isError = hostError != null, + supportingText = hostError?.let { { Text(it) } }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = portText, + onValueChange = { + // 仅允许数字 + portText = it.filter { ch -> ch.isDigit() }.take(5) + val n = portText.toIntOrNull() + portError = when { + n == null -> "请输入数字" + !AppSettings.isValidPort(n) -> "端口范围 1-65535" + else -> null + } + }, + label = { Text("UDP 端口") }, + isError = portError != null, + supportingText = portError?.let { { Text(it) } }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + + // ---- 视频流配置 ---- + SectionLabel("RTSP 视频流") + + OutlinedTextField( + value = rtspUrl, + onValueChange = { rtspUrl = it }, + label = { Text("RTSP 地址") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + CodecDropdown( + selected = codec, + onSelect = { codec = it }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.padding(top = 8.dp)) + + // ---- 操作按钮 ---- + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + TextButton( + onClick = onBack, + modifier = Modifier.weight(1f) + ) { + Text("取消") + } + Button( + onClick = { + val port = portText.toIntOrNull() ?: AppSettings.DEFAULT_ROBOT_PORT + scope.launch { + repository.setAll( + AppSettings( + robotHost = host, + robotPort = port, + rtspUrl = rtspUrl, + videoCodec = codec + ) + ) + onSaved() + } + }, + enabled = saveEnabled, + modifier = Modifier.weight(1f) + ) { + Text("保存") + } + } + } + } +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + style = androidx.compose.material3.MaterialTheme.typography.titleSmall, + color = androidx.compose.material3.MaterialTheme.colorScheme.primary + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CodecDropdown( + selected: VideoCodec, + onSelect: (VideoCodec) -> Unit, + modifier: Modifier = Modifier +) { + var expanded by remember { mutableStateOf(false) } + val options = VideoCodec.entries + val labels = mapOf( + VideoCodec.AUTO to "自动(优先硬件)", + VideoCodec.FORCE_HW to "强制硬件解码(低延迟)", + VideoCodec.FORCE_SW_H264 to "强制软解 H.264", + VideoCodec.FORCE_SW_H265 to "强制软解 H.265" + ) + + Column(modifier = modifier) { + Text( + text = "视频解码器", + style = androidx.compose.material3.MaterialTheme.typography.titleSmall, + color = androidx.compose.material3.MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.padding(top = 4.dp)) + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = labels[selected] ?: selected.name, + onValueChange = {}, + readOnly = true, + singleLine = true, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor() + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + options.forEach { c -> + DropdownMenuItem( + text = { Text(labels[c] ?: c.name) }, + onClick = { + onSelect(c) + expanded = false + } + ) + } + } + } + } +} diff --git a/app/src/main/java/com/example/m20_gamepad/ui/components/StatusPanel.kt b/app/src/main/java/com/example/m20_gamepad/ui/components/StatusPanel.kt new file mode 100644 index 0000000..16847d5 --- /dev/null +++ b/app/src/main/java/com/example/m20_gamepad/ui/components/StatusPanel.kt @@ -0,0 +1,313 @@ +package com.example.m20_gamepad.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.m20_gamepad.network.models.BatteryStatus +import com.example.m20_gamepad.network.models.DeviceTemperature +import com.example.m20_gamepad.network.models.ErrorCodes +import com.example.m20_gamepad.network.models.ErrorInfo +import com.example.m20_gamepad.network.models.MotionStatus + +private val PanelBg = Color(0xF0111111) +private val PanelText = Color.White +private val LabelColor = Color(0xFFB0B0B0) +private val ErrorColor = Color(0xFFFF5252) +private val WarnColor = Color(0xFFFFC107) +private val OkColor = Color(0xFF4CAF50) + +/** + * 状态上报面板:异常列表 + 运控详情 + 设备温度 + 电池。 + * 覆盖在主界面之上,[visible] 控制显隐,点击背景或关闭按钮可 [onDismiss]。 + */ +@Composable +fun StatusPanel( + visible: Boolean, + errorList: List?, + motionStatus: MotionStatus?, + deviceTemperature: DeviceTemperature?, + batteryStatus: BatteryStatus?, + lastUpdate: String?, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + AnimatedVisibility( + visible = visible, + enter = fadeIn() + slideInVertically(initialOffsetY = { it / 4 }), + exit = fadeOut() + slideOutVertically(targetOffsetY = { it / 4 }), + modifier = modifier + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0x88000000)) + ) { + Surface( + color = PanelBg, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .fillMaxHeight(0.55f) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 8.dp) + .verticalScroll(rememberScrollState()) + ) { + PanelHeader(lastUpdate, onDismiss) + Spacer(Modifier.height(8.dp)) + + ErrorListSection(errorList) + Spacer(Modifier.height(12.dp)) + + MotionStatusSection(motionStatus) + Spacer(Modifier.height(12.dp)) + + TemperatureSection(deviceTemperature) + Spacer(Modifier.height(12.dp)) + + BatterySection(batteryStatus) + Spacer(Modifier.height(16.dp)) + } + } + } + } +} + +@Composable +private fun PanelHeader(lastUpdate: String?, onDismiss: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + "机器人状态", + color = PanelText, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + Row(verticalAlignment = Alignment.CenterVertically) { + lastUpdate?.let { + Text( + it, + color = LabelColor, + fontSize = 11.sp + ) + Spacer(Modifier.width(8.dp)) + } + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "关闭", tint = PanelText) + } + } + } +} + +@Composable +private fun SectionTitle(title: String, accent: Color = LabelColor) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .width(3.dp) + .height(14.dp) + .background(accent) + ) + Spacer(Modifier.width(8.dp)) + Text( + title, + color = PanelText, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold + ) + } +} + +// ==================== 异常状态 ==================== + +@Composable +private fun ErrorListSection(errorList: List?) { + SectionTitle("异常状态", if (errorList.isNullOrEmpty()) OkColor else ErrorColor) + Spacer(Modifier.height(6.dp)) + when { + errorList == null -> InfoRow("暂无上报") + errorList.isEmpty() -> InfoRow("无异常", valueColor = OkColor) + else -> errorList.forEach { err -> + val components = ErrorCodes.componentNames(err.component) + val compText = if (components.isEmpty()) "无" else components.joinToString("、") + ErrorItem( + code = err.errorCode, + desc = ErrorCodes.describe(err.errorCode), + component = compText + ) + } + } +} + +@Composable +private fun ErrorItem(code: Int, desc: String, component: String) { + Surface( + color = Color(0x33FF5252), + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 3.dp) + ) { + Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "0x${code.toString(16).uppercase()}", + color = ErrorColor, + fontSize = 13.sp, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.width(8.dp)) + Text(desc, color = PanelText, fontSize = 13.sp) + } + if (component.isNotEmpty()) { + Text("部件: $component", color = LabelColor, fontSize = 11.sp) + } + } + } +} + +// ==================== 运控状态 ==================== + +@Composable +private fun MotionStatusSection(motionStatus: MotionStatus?) { + SectionTitle("运控状态") + Spacer(Modifier.height(6.dp)) + if (motionStatus == null) { + InfoRow("暂无上报") + return + } + // 姿态角 + MetricRow("Roll", "%.2f rad".format(motionStatus.roll)) + MetricRow("Pitch", "%.2f rad".format(motionStatus.pitch)) + MetricRow("Yaw", "%.2f rad".format(motionStatus.yaw)) + // 速度 + MetricRow("OmegaZ", "%.2f rad/s".format(motionStatus.omegaZ)) + MetricRow("LinearX", "%.2f m/s".format(motionStatus.linearX)) + MetricRow("LinearY", "%.2f m/s".format(motionStatus.linearY)) + // 其他 + MetricRow("机身高度", "%.2f m".format(motionStatus.height)) + MetricRow("剩余续航", "%.1f km".format(motionStatus.remainMile)) +} + +// ==================== 设备温度 ==================== + +@Composable +private fun TemperatureSection(deviceTemperature: DeviceTemperature?) { + SectionTitle("设备温度") + Spacer(Modifier.height(6.dp)) + if (deviceTemperature == null) { + InfoRow("暂无上报") + return + } + val motorMax = deviceTemperature.motor.maxOrNull() ?: 0.0 + val driverMax = deviceTemperature.driver.maxOrNull() ?: 0.0 + val motorColor = tempColor(motorMax) + val driverColor = tempColor(driverMax) + MetricRow("电机最高温", "%.1f ℃".format(motorMax), valueColor = motorColor) + MetricRow("驱动器最高温", "%.1f ℃".format(driverMax), valueColor = driverColor) + // 全部电机温度(紧凑) + if (deviceTemperature.motor.isNotEmpty()) { + Text( + "电机: " + deviceTemperature.motor.joinToString(" ") { "%.0f".format(it) }, + color = LabelColor, + fontSize = 11.sp, + modifier = Modifier.padding(top = 4.dp) + ) + } + if (deviceTemperature.driver.isNotEmpty()) { + Text( + "驱动: " + deviceTemperature.driver.joinToString(" ") { "%.0f".format(it) }, + color = LabelColor, + fontSize = 11.sp, + modifier = Modifier.padding(top = 2.dp) + ) + } +} + +private fun tempColor(temp: Double): Color = when { + temp >= 75.0 -> ErrorColor + temp >= 60.0 -> WarnColor + else -> PanelText +} + +// ==================== 电池 ==================== + +@Composable +private fun BatterySection(batteryStatus: BatteryStatus?) { + SectionTitle("电池") + Spacer(Modifier.height(6.dp)) + if (batteryStatus == null) { + InfoRow("暂无上报") + return + } + MetricRow("左电池", "%.1f V / %.0f%%".format( + batteryStatus.voltageLeft, batteryStatus.batteryLevelLeft)) + MetricRow("右电池", "%.1f V / %.0f%%".format( + batteryStatus.voltageRight, batteryStatus.batteryLevelRight)) + MetricRow( + "左电池温度", "%.1f ℃".format(batteryStatus.batteryTemperatureLeft), + valueColor = tempColor(batteryStatus.batteryTemperatureLeft) + ) + MetricRow( + "右电池温度", "%.1f ℃".format(batteryStatus.batteryTemperatureRight), + valueColor = tempColor(batteryStatus.batteryTemperatureRight) + ) + MetricRow("左充电", if (batteryStatus.chargeLeft) "是" else "否") + MetricRow("右充电", if (batteryStatus.chargeRight) "是" else "否") +} + +// ==================== 通用行 ==================== + +@Composable +private fun MetricRow(label: String, value: String, valueColor: Color = PanelText) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(label, color = LabelColor, fontSize = 13.sp) + Text(value, color = valueColor, fontSize = 13.sp) + } +} + +@Composable +private fun InfoRow(text: String, valueColor: Color = LabelColor) { + Text(text, color = valueColor, fontSize = 13.sp, modifier = Modifier.padding(vertical = 2.dp)) +} diff --git a/app/src/main/java/com/example/m20_gamepad/ui/theme/Color.kt b/app/src/main/java/com/example/m20_gamepad/ui/theme/Color.kt new file mode 100644 index 0000000..30ad3f1 --- /dev/null +++ b/app/src/main/java/com/example/m20_gamepad/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.example.m20_gamepad.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/example/m20_gamepad/ui/theme/Theme.kt b/app/src/main/java/com/example/m20_gamepad/ui/theme/Theme.kt new file mode 100644 index 0000000..1d8cf98 --- /dev/null +++ b/app/src/main/java/com/example/m20_gamepad/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.example.m20_gamepad.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun M20_gamepadTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/m20_gamepad/ui/theme/Type.kt b/app/src/main/java/com/example/m20_gamepad/ui/theme/Type.kt new file mode 100644 index 0000000..16559da --- /dev/null +++ b/app/src/main/java/com/example/m20_gamepad/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.example.m20_gamepad.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/java/com/example/m20_gamepad/video/RtspVideoPlayer.kt b/app/src/main/java/com/example/m20_gamepad/video/RtspVideoPlayer.kt new file mode 100644 index 0000000..634fa65 --- /dev/null +++ b/app/src/main/java/com/example/m20_gamepad/video/RtspVideoPlayer.kt @@ -0,0 +1,225 @@ +package com.example.m20_gamepad.video + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.mediacodec.MediaCodecInfo +import androidx.media3.exoplayer.mediacodec.MediaCodecSelector +import androidx.media3.ui.PlayerView + +/** + * 视频解码器选择策略。 + */ +enum class VideoCodec { + /** 自动选择(优先硬件解码) */ + AUTO, + /** 强制硬件解码(最快,延迟优先,接受丢帧) */ + FORCE_HW, + /** 强制软件 H.264 解码 */ + FORCE_SW_H264, + /** 强制软件 H.265 解码 */ + FORCE_SW_H265 +} + +/** RTSP 播放状态 */ +sealed class RtspState { + /** 连接中 */ + object Connecting : RtspState() + /** 播放中 */ + object Playing : RtspState() + /** 连接失败 */ + data class Error(val message: String) : RtspState() +} + +/** + * RTSP 视频流播放 Composable。 + * + * 使用 Media3 ExoPlayer + RTSP 扩展拉取 RTSP 流,作为全屏背景层。 + * [codec] 变化时重建播放器并应用新的解码器策略。 + * 连接失败时显示错误提示。 + * + * @param url RTSP 流地址,如 `rtsp://10.21.31.103:554/stream` + * @param codec 解码器选择策略 + * @param modifier 布局修饰符 + */ +@Composable +fun RtspVideoPlayer( + url: String, + codec: VideoCodec = VideoCodec.AUTO, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + + var state by remember { mutableStateOf(RtspState.Connecting) } + + // codec 变化时重建 ExoPlayer,新的 selector 在 RenderersFactory 中注入, + // 同时使用最小缓冲策略降低延迟 + val player: ExoPlayer = remember(codec) { + val selector = codec.toMediaCodecSelector() + val renderersFactory = DefaultRenderersFactory(context) + renderersFactory.setMediaCodecSelector(selector) + val loadControl = DefaultLoadControl.Builder() + .setBufferDurationsMs( + 200, // minBufferMs — 必须 >= bufferForPlaybackAfterRebufferMs + 500, // maxBufferMs + 100, // bufferForPlaybackMs — 起播所需缓冲 + 200 // bufferForPlaybackAfterRebufferMs — 重缓冲后所需缓冲 + ) + .setBackBuffer(0, false) + .setPrioritizeTimeOverSizeThresholds(true) + .build() + ExoPlayer.Builder(context, renderersFactory) + .setLoadControl(loadControl) + .build() + .apply { + repeatMode = Player.REPEAT_MODE_OFF + playWhenReady = true + addListener(object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + state = when (playbackState) { + Player.STATE_BUFFERING -> RtspState.Connecting + Player.STATE_READY -> RtspState.Playing + Player.STATE_IDLE -> RtspState.Connecting + else -> state + } + } + + override fun onPlayerError(error: PlaybackException) { + state = RtspState.Error(error.message ?: "连接失败") + } + }) + } + } + + // URL 或 codec 变化时重新加载媒体源 + LaunchedEffect(url, codec) { + if (url.isNotBlank()) { + state = RtspState.Connecting + val mediaItem = MediaItem.fromUri(url) + player.setMediaItem(mediaItem) + player.prepare() + } + } + + // codec 变化时释放旧播放器 + DisposableEffect(codec) { + onDispose { + player.release() + } + } + + Box(modifier = modifier) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + PlayerView(ctx).apply { + useController = false + this.player = player + setShutterBackgroundColor(android.graphics.Color.BLACK) + } + }, + // player 引用变化时(codec 重建)更新 PlayerView + update = { view -> + view.player = player + } + ) + + // 连接中 / 错误时的提示覆盖层 + when (val s = state) { + is RtspState.Connecting -> StatusOverlay("连接 RTSP 流中...") + is RtspState.Error -> StatusOverlay("RTSP 连接失败: ${s.message}") + is RtspState.Playing -> { /* 正常播放,不显示覆盖层 */ } + } + } +} + +@Composable +private fun StatusOverlay(text: String) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + color = Color.White + ) + } +} + +// ==================== 解码器选择器 ==================== + +/** MIME 类型常量 */ +private const val MIME_H264 = "video/avc" +private const val MIME_HEVC = "video/hevc" + +/** + * 将 [VideoCodec] 枚举映射为 [MediaCodecSelector]。 + * + * - AUTO: 默认选择器(优先硬件解码) + * - FORCE_HW: 强制硬件解码,所有 MIME 类型仅保留硬件加速解码器 + * - FORCE_SW_H264: 针对 H.264 仅保留软件解码器,其余 MIME 走默认 + * - FORCE_SW_H265: 针对 H.265/HEVC 仅保留软件解码器,其余 MIME 走默认 + */ +private fun VideoCodec.toMediaCodecSelector(): MediaCodecSelector = when (this) { + VideoCodec.AUTO -> MediaCodecSelector.DEFAULT + + VideoCodec.FORCE_HW -> forceHardware() + VideoCodec.FORCE_SW_H264 -> forceSoftwareForMime(MIME_H264) + VideoCodec.FORCE_SW_H265 -> forceSoftwareForMime(MIME_HEVC) +} + +/** + * 创建一个 [MediaCodecSelector],仅对指定 [mimeType] 强制软件解码。 + * 其余 MIME 类型使用默认行为。 + */ +private fun forceSoftwareForMime(targetMime: String): MediaCodecSelector = object : MediaCodecSelector { + override fun getDecoderInfos( + mimeType: String, + requiresSecureDecoder: Boolean, + requiresTunnelingDecoder: Boolean + ): List { + val all = MediaCodecSelector.DEFAULT.getDecoderInfos( + mimeType, requiresSecureDecoder, requiresTunnelingDecoder + ) + // 只对目标 MIME 类型过滤,其余保持默认 + return if (mimeType == targetMime) { + all.filter { !it.hardwareAccelerated } + } else { + all + } + } +} + +/** + * 创建一个 [MediaCodecSelector],所有 MIME 类型仅保留硬件加速解码器。 + * 用于强制硬件解码模式(延迟优先)。 + */ +private fun forceHardware(): MediaCodecSelector = object : MediaCodecSelector { + override fun getDecoderInfos( + mimeType: String, + requiresSecureDecoder: Boolean, + requiresTunnelingDecoder: Boolean + ): List { + return MediaCodecSelector.DEFAULT.getDecoderInfos( + mimeType, requiresSecureDecoder, requiresTunnelingDecoder + ).filter { it.hardwareAccelerated } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9 GIT binary patch literal 2898 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 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�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} 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

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..810d44f --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + m20_gamepad + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..dd46f56 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +