Initial commit: M20 Gamepad control app
山猫 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 <vibe@mistral.ai>
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AndroidProjectSystem">
|
||||
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetSelector">
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-07-17T08:19:04.728150600Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="Default" identifier="serial=192.168.137.221:5555;connection=f0ff1c4d" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DeviceTable">
|
||||
<option name="columnSorters">
|
||||
<list>
|
||||
<ColumnSorterState>
|
||||
<option name="column" value="Name" />
|
||||
<option name="order" value="ASCENDING" />
|
||||
</ColumnSorterState>
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
<option value="$PROJECT_DIR$/joysticklibrary" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,61 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
<option name="id" value="Android" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="RunConfigurationProducerService">
|
||||
<option name="ignoredProducers">
|
||||
<set>
|
||||
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
@@ -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<AppSettings>` 供 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<AppSettings>` + 单项 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<AppSettings>`(`stateIn(Eagerly)`),`connect()` 读取 `settings.value` 的 host/port;`init {}` 监听 host/port 变化(`distinctUntilChanged`)后断开当前连接,下次连接使用新参数(符合“修改设置后断开当前连接”约束)。移除 DEFAULT_HOST/DEFAULT_PORT/DEFAULT_RTSP_URL 硬编码
|
||||
- [x] 连接状态机:RobotConnection.kt(连接状态流转校验 `isValidConnectionTransition`,运动状态机 `requestMotionTransition` 按 proto.md 2.3 正向流程 `空闲->站立->RL控制` 校验;`canSwitchGait`/`canSendAxisCommand` 仅 RL 控制下放行;`MotionTransitionResult` 枚举反馈)。MainViewModel 经状态机校验后下发指令,轴指令循环非 RL 控制时发零速度
|
||||
- [x] 状态上报 UI 展示:StatusPanel.kt(异常列表+错误码映射 ErrorCodes.kt、运控状态 Roll/Pitch/Yaw/速度/高度、设备温度电机/驱动器最高温、电池左右电压/电量/温度),顶部状态栏 Info 按钮触发底部弹出面板;指令失败/状态机拒绝通过 Snackbar 反馈
|
||||
- [x] 更多控制按钮:Mode(常规/导航/辅助)、步态(基础/楼梯/平地敏捷/楼梯敏捷)、照明(前/后灯开/关)、充电(开始/结束)、休眠(休眠/唤醒)。底部控制区可纵向滚动,点击"更多"展开全部控制选项
|
||||
- [x] RTSP 解码器策略实现:`VideoCodec` 枚举含 `AUTO`/`FORCE_HW`/`FORCE_SW_H264`/`FORCE_SW_H265`;`toMediaCodecSelector()` 映射到对应 `MediaCodecSelector`;`DefaultLoadControl` 最小缓冲(100ms起播,总缓冲<500ms,无回退缓存,时间优先于大小阈值);`FORCE_HW` 模式对所有 MIME 仅保留硬件加速解码器
|
||||
|
||||
### 待办
|
||||
|
||||
(无 — 所有功能已实现)
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".M20App"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.M20_gamepad">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:screenOrientation="landscape"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:theme="@style/Theme.M20_gamepad">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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<Preferences> 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<AppSettings> = 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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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<ApduMessage, Int>? {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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> = _state.asStateFlow()
|
||||
|
||||
/** 状态上报事件流 */
|
||||
private val _statusReport = MutableSharedFlow<StatusReport>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 16,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
val onStatusReport: SharedFlow<StatusReport> = _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<ByteArray> = 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<Pair<Int, Int>> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<Int, String> = 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<String> {
|
||||
if (component == 0) return emptyList()
|
||||
val result = mutableListOf<String>()
|
||||
for (bit in 0 until 16) {
|
||||
if ((component shr bit) and 1 == 1) {
|
||||
result.add(jointName(bit))
|
||||
}
|
||||
}
|
||||
// bit0/1 在电池上下文表示电池,但关节上下文优先按关节解释
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<BatteryInfo>? = 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<ErrorInfo>? = 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<Double>,
|
||||
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<Double>,
|
||||
val driver: List<Double>
|
||||
)
|
||||
|
||||
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<BatteryInfo>? {
|
||||
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<ErrorInfo>? {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<ProtocolClient.State> = _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<Int> = _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
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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<ProtocolClient.State> = robot.connectionState
|
||||
|
||||
/** 当前运动状态(来自状态上报,0=空闲 1=站立 17=RL控制 ...)。 */
|
||||
val motionState: StateFlow<Int> = robot.motionState
|
||||
|
||||
private val _latestStatus = MutableStateFlow<StatusReport?>(null)
|
||||
val latestStatus: StateFlow<StatusReport?> = _latestStatus.asStateFlow()
|
||||
|
||||
/** 状态面板用:分项最新值(各上报频率不同,分别缓存最近一次)。 */
|
||||
private val _errorList = MutableStateFlow<List<ErrorInfo>?>(null)
|
||||
val errorList: StateFlow<List<ErrorInfo>?> = _errorList.asStateFlow()
|
||||
|
||||
private val _motionStatus = MutableStateFlow<MotionStatus?>(null)
|
||||
val motionStatus: StateFlow<MotionStatus?> = _motionStatus.asStateFlow()
|
||||
|
||||
private val _deviceTemperature = MutableStateFlow<DeviceTemperature?>(null)
|
||||
val deviceTemperature: StateFlow<DeviceTemperature?> = _deviceTemperature.asStateFlow()
|
||||
|
||||
private val _batteryStatus = MutableStateFlow<BatteryStatus?>(null)
|
||||
val batteryStatus: StateFlow<BatteryStatus?> = _batteryStatus.asStateFlow()
|
||||
|
||||
private val _lastUpdate = MutableStateFlow<String?>(null)
|
||||
val lastUpdate: StateFlow<String?> = _lastUpdate.asStateFlow()
|
||||
|
||||
/** 最近一次指令的校验反馈(供 UI 提示)。 */
|
||||
private val _actionFeedback = MutableStateFlow<String?>(null)
|
||||
val actionFeedback: StateFlow<String?> = _actionFeedback.asStateFlow()
|
||||
|
||||
/** 当前设置快照(主界面用于驱动视频播放器地址/编码器) */
|
||||
val settings: StateFlow<AppSettings> = 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()
|
||||
}
|
||||
}
|
||||
@@ -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<String?>(null) }
|
||||
var portError by remember { mutableStateOf<String?>(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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ErrorInfo>?,
|
||||
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<ErrorInfo>?) {
|
||||
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))
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
*/
|
||||
)
|
||||
@@ -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>(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<MediaCodecInfo> {
|
||||
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<MediaCodecInfo> {
|
||||
return MediaCodecSelector.DEFAULT.getDecoderInfos(
|
||||
mimeType, requiresSecureDecoder, requiresTunnelingDecoder
|
||||
).filter { it.hardwareAccelerated }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
@@ -0,0 +1,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">m20_gamepad</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.M20_gamepad" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older than API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.example.m20_gamepad
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Project-wide Gradle settings.
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. For more details, visit
|
||||
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
||||
# org.gradle.parallel=true
|
||||
# Kotlin code style for this project: "official" or "obsolete":
|
||||
kotlin.code.style=official
|
||||
@@ -0,0 +1,12 @@
|
||||
#This file is generated by updateDaemonJvm
|
||||
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
|
||||
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
|
||||
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
|
||||
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
|
||||
toolchainVersion=21
|
||||
@@ -0,0 +1,45 @@
|
||||
[versions]
|
||||
agp = "9.1.1"
|
||||
coreKtx = "1.18.0"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.3.0"
|
||||
espressoCore = "3.7.0"
|
||||
lifecycleRuntimeKtx = "2.10.0"
|
||||
activityCompose = "1.13.0"
|
||||
kotlin = "2.2.10"
|
||||
composeBom = "2024.09.00"
|
||||
datastore = "1.1.3"
|
||||
navigationCompose = "2.8.6"
|
||||
media3 = "1.5.1"
|
||||
kotlinxSerialization = "1.7.3"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
|
||||
androidx-media3-exoplayer-rtsp = { group = "androidx.media3", name = "media3-exoplayer-rtsp", version.ref = "media3" }
|
||||
androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
|
||||
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
#Fri Jul 17 11:50:11 CST 2026
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,63 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
`maven-publish`
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.erz.joysticklibrary"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 21
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
publishing {
|
||||
singleVariant("release") {
|
||||
withSourcesJar()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform(libs.androidx.compose.bom)
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
}
|
||||
|
||||
publishing {
|
||||
publications {
|
||||
register<MavenPublication>("release") {
|
||||
groupId = "com.github.erz05"
|
||||
artifactId = "JoyStick"
|
||||
version = "2.0.0"
|
||||
|
||||
afterEvaluate {
|
||||
from(components["release"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# By default, the flags in this file are appended to flags specified
|
||||
# in /Users/edgarramirez/sdk/tools/proguard/proguard-android.txt
|
||||
# You can edit the include path and order by changing the proguardFiles
|
||||
# directive in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# Add any project specific keep options here:
|
||||
|
||||
# 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 *;
|
||||
#}
|
||||
@@ -0,0 +1,10 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true">
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,279 @@
|
||||
package com.erz.joysticklibrary
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.util.AttributeSet
|
||||
import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.sqrt
|
||||
|
||||
@Deprecated(
|
||||
message = "Use the Compose-based Joystick composable instead.",
|
||||
replaceWith = ReplaceWith("Joystick", "com.erz.joysticklibrary.Joystick")
|
||||
)
|
||||
class JoyStick @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : View(context, attrs), GestureDetector.OnGestureListener, GestureDetector.OnDoubleTapListener {
|
||||
|
||||
companion object {
|
||||
const val DIRECTION_CENTER = -1
|
||||
const val DIRECTION_LEFT = 0
|
||||
const val DIRECTION_LEFT_UP = 1
|
||||
const val DIRECTION_UP = 2
|
||||
const val DIRECTION_UP_RIGHT = 3
|
||||
const val DIRECTION_RIGHT = 4
|
||||
const val DIRECTION_RIGHT_DOWN = 5
|
||||
const val DIRECTION_DOWN = 6
|
||||
const val DIRECTION_DOWN_LEFT = 7
|
||||
|
||||
const val TYPE_8_AXIS = 11
|
||||
const val TYPE_4_AXIS = 22
|
||||
const val TYPE_2_AXIS_LEFT_RIGHT = 33
|
||||
const val TYPE_2_AXIS_UP_DOWN = 44
|
||||
|
||||
private fun calculateDirection(degrees: Double): Int {
|
||||
return when {
|
||||
(degrees >= 0 && degrees < 22.5) || (degrees < 0 && degrees > -22.5) -> DIRECTION_LEFT
|
||||
degrees >= 22.5 && degrees < 67.5 -> DIRECTION_LEFT_UP
|
||||
degrees >= 67.5 && degrees < 112.5 -> DIRECTION_UP
|
||||
degrees >= 112.5 && degrees < 157.5 -> DIRECTION_UP_RIGHT
|
||||
(degrees >= 157.5 && degrees <= 180) || (degrees >= -180 && degrees < -157.5) -> DIRECTION_RIGHT
|
||||
degrees >= -157.5 && degrees < -112.5 -> DIRECTION_RIGHT_DOWN
|
||||
degrees >= -112.5 && degrees < -67.5 -> DIRECTION_DOWN
|
||||
degrees >= -67.5 && degrees < -22.5 -> DIRECTION_DOWN_LEFT
|
||||
else -> DIRECTION_CENTER
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface JoyStickListener {
|
||||
fun onMove(joyStick: JoyStick?, angle: Double, power: Double, direction: Int)
|
||||
fun onTap()
|
||||
fun onDoubleTap()
|
||||
}
|
||||
|
||||
private var listener: JoyStickListener? = null
|
||||
private val paint: Paint = Paint().apply {
|
||||
style = Paint.Style.FILL
|
||||
isAntiAlias = true
|
||||
isFilterBitmap = true
|
||||
}
|
||||
private val temp: RectF = RectF()
|
||||
private val gestureDetector: GestureDetector
|
||||
private var direction = DIRECTION_CENTER
|
||||
private var type = TYPE_8_AXIS
|
||||
private var centerX = 0f
|
||||
private var centerY = 0f
|
||||
private var posX = 0f
|
||||
private var posY = 0f
|
||||
private var radius = 0f
|
||||
private var buttonRadius = 0f
|
||||
private var power = 0.0
|
||||
private var angle = 0.0
|
||||
|
||||
// Background Color
|
||||
private var padColor: Int = Color.WHITE
|
||||
|
||||
// Stick Color
|
||||
private var buttonColor: Int = Color.RED
|
||||
|
||||
// Keeps joystick in last position
|
||||
private var stayPut = false
|
||||
|
||||
// Button Size percentage of the minimum(width, height)
|
||||
private var percentage = 25
|
||||
|
||||
// Background Bitmap
|
||||
private var padBGBitmap: Bitmap? = null
|
||||
|
||||
// Button Bitmap
|
||||
private var buttonBitmap: Bitmap? = null
|
||||
|
||||
init {
|
||||
gestureDetector = GestureDetector(context, this).apply {
|
||||
setIsLongpressEnabled(false)
|
||||
setOnDoubleTapListener(this@JoyStick)
|
||||
}
|
||||
|
||||
if (attrs != null) {
|
||||
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.JoyStick)
|
||||
padColor = typedArray.getColor(R.styleable.JoyStick_padColor, Color.WHITE)
|
||||
buttonColor = typedArray.getColor(R.styleable.JoyStick_buttonColor, Color.RED)
|
||||
stayPut = typedArray.getBoolean(R.styleable.JoyStick_stayPut, false)
|
||||
percentage = typedArray.getInt(R.styleable.JoyStick_percentage, 25)
|
||||
if (percentage > 50) percentage = 50
|
||||
if (percentage < 25) percentage = 25
|
||||
|
||||
val padResId = typedArray.getResourceId(R.styleable.JoyStick_backgroundDrawable, -1)
|
||||
val buttonResId = typedArray.getResourceId(R.styleable.JoyStick_buttonDrawable, -1)
|
||||
|
||||
if (padResId > 0) {
|
||||
padBGBitmap = BitmapFactory.decodeResource(resources, padResId)
|
||||
}
|
||||
if (buttonResId > 0) {
|
||||
buttonBitmap = BitmapFactory.decodeResource(resources, buttonResId)
|
||||
}
|
||||
typedArray.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
val width = MeasureSpec.getSize(widthMeasureSpec).toFloat()
|
||||
val height = MeasureSpec.getSize(heightMeasureSpec).toFloat()
|
||||
centerX = width / 2
|
||||
centerY = height / 2
|
||||
val minDim = minOf(width, height)
|
||||
posX = centerX
|
||||
posY = centerY
|
||||
buttonRadius = minDim / 2f * (percentage / 100f)
|
||||
radius = minDim / 2f * ((100f - percentage) / 100f)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val pad = padBGBitmap
|
||||
if (pad == null) {
|
||||
paint.color = padColor
|
||||
canvas.drawCircle(centerX, centerY, radius, paint)
|
||||
} else {
|
||||
temp.set(centerX - radius, centerY - radius, centerX + radius, centerY + radius)
|
||||
canvas.drawBitmap(pad, null, temp, paint)
|
||||
}
|
||||
|
||||
val button = buttonBitmap
|
||||
if (button == null) {
|
||||
paint.color = buttonColor
|
||||
canvas.drawCircle(posX, posY, buttonRadius, paint)
|
||||
} else {
|
||||
temp.set(posX - buttonRadius, posY - buttonRadius, posX + buttonRadius, posY + buttonRadius)
|
||||
canvas.drawBitmap(button, null, temp, paint)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
gestureDetector.onTouchEvent(event)
|
||||
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
|
||||
posX = event.x
|
||||
posY = event.y
|
||||
|
||||
when (type) {
|
||||
TYPE_2_AXIS_LEFT_RIGHT -> {
|
||||
posY = centerY
|
||||
}
|
||||
TYPE_2_AXIS_UP_DOWN -> {
|
||||
posX = centerX
|
||||
}
|
||||
TYPE_4_AXIS -> {
|
||||
if (abs(posX - centerX) > abs(posY - centerY)) {
|
||||
posY = centerY
|
||||
} else {
|
||||
posX = centerX
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val absVal = sqrt(((posX - centerX) * (posX - centerX) + (posY - centerY) * (posY - centerY)).toDouble()).toFloat()
|
||||
if (absVal > radius) {
|
||||
posX = (posX - centerX) * radius / absVal + centerX
|
||||
posY = (posY - centerY) * radius / absVal + centerY
|
||||
}
|
||||
|
||||
angle = atan2((centerY - posY).toDouble(), (centerX - posX).toDouble())
|
||||
power = 100 * sqrt(((posX - centerX) * (posX - centerX) + (posY - centerY) * (posY - centerY)).toDouble()) / radius
|
||||
direction = calculateDirection(Math.toDegrees(angle))
|
||||
invalidate()
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
if (!stayPut) {
|
||||
posX = centerX
|
||||
posY = centerY
|
||||
direction = DIRECTION_CENTER
|
||||
angle = 0.0
|
||||
power = 0.0
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
listener?.onMove(this, angle, power, direction)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onDown(motionEvent: MotionEvent): Boolean = true
|
||||
override fun onShowPress(motionEvent: MotionEvent) {}
|
||||
override fun onSingleTapUp(motionEvent: MotionEvent): Boolean = false
|
||||
override fun onScroll(motionEvent: MotionEvent?, motionEvent1: MotionEvent, v: Float, v1: Float): Boolean = false
|
||||
override fun onLongPress(motionEvent: MotionEvent) {}
|
||||
override fun onFling(motionEvent: MotionEvent?, motionEvent1: MotionEvent, v: Float, v1: Float): Boolean = false
|
||||
|
||||
override fun onSingleTapConfirmed(motionEvent: MotionEvent): Boolean {
|
||||
listener?.onTap()
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onDoubleTap(motionEvent: MotionEvent): Boolean {
|
||||
listener?.onDoubleTap()
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onDoubleTapEvent(motionEvent: MotionEvent): Boolean = false
|
||||
|
||||
fun setListener(listener: JoyStickListener?) {
|
||||
this.listener = listener
|
||||
}
|
||||
|
||||
fun getPower(): Double = power
|
||||
fun getAngle(): Double = angle
|
||||
fun getAngleDegrees(): Double = Math.toDegrees(angle)
|
||||
fun getDirection(): Int = direction
|
||||
fun getType(): Int = type
|
||||
|
||||
fun setPadColor(padColor: Int) {
|
||||
this.padColor = padColor
|
||||
}
|
||||
|
||||
fun setButtonColor(buttonColor: Int) {
|
||||
this.buttonColor = buttonColor
|
||||
}
|
||||
|
||||
fun setButtonRadiusScale(scale: Int) {
|
||||
percentage = scale
|
||||
if (percentage > 50) percentage = 50
|
||||
if (percentage < 25) percentage = 25
|
||||
}
|
||||
|
||||
fun enableStayPut(enable: Boolean) {
|
||||
this.stayPut = enable
|
||||
}
|
||||
|
||||
fun setPadBackground(resId: Int) {
|
||||
this.padBGBitmap = BitmapFactory.decodeResource(resources, resId)
|
||||
}
|
||||
|
||||
fun setPadBackground(bitmap: Bitmap?) {
|
||||
this.padBGBitmap = bitmap
|
||||
}
|
||||
|
||||
fun setButtonDrawable(resId: Int) {
|
||||
this.buttonBitmap = BitmapFactory.decodeResource(resources, resId)
|
||||
}
|
||||
|
||||
fun setButtonDrawable(bitmap: Bitmap?) {
|
||||
this.buttonBitmap = bitmap
|
||||
}
|
||||
|
||||
fun setType(type: Int) {
|
||||
this.type = type
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package com.erz.joysticklibrary
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChanged
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sqrt
|
||||
import androidx.compose.ui.util.fastAll
|
||||
import androidx.compose.ui.util.fastFirstOrNull
|
||||
|
||||
enum class JoystickDirection(val value: Int) {
|
||||
CENTER(-1),
|
||||
LEFT(0),
|
||||
LEFT_UP(1),
|
||||
UP(2),
|
||||
UP_RIGHT(3),
|
||||
RIGHT(4),
|
||||
RIGHT_DOWN(5),
|
||||
DOWN(6),
|
||||
DOWN_LEFT(7);
|
||||
|
||||
companion object {
|
||||
private val valuesByValue = entries.associateBy { it.value }
|
||||
fun fromValue(value: Int): JoystickDirection = valuesByValue[value] ?: CENTER
|
||||
}
|
||||
}
|
||||
|
||||
enum class JoystickType(val value: Int) {
|
||||
EIGHT_AXIS(11),
|
||||
FOUR_AXIS(22),
|
||||
TWO_AXIS_HORIZONTAL(33),
|
||||
TWO_AXIS_VERTICAL(44)
|
||||
}
|
||||
|
||||
/**
|
||||
* A highly customizable, performant, and responsive Joystick composable for Jetpack Compose.
|
||||
*
|
||||
* It supports various axis restrictions, customizable shapes/colors, custom drawables/painters,
|
||||
* and built-in gesture callbacks (move, tap, and double tap).
|
||||
*
|
||||
* ### How to Use
|
||||
*
|
||||
* ```kotlin
|
||||
* Joystick(
|
||||
* modifier = Modifier
|
||||
* .size(150.dp)
|
||||
* .background(Color.Gray)
|
||||
* .clip(CircleShape),
|
||||
* type = JoystickType.EIGHT_AXIS,
|
||||
* stayPut = false,
|
||||
* onMove = { angle, power, direction ->
|
||||
* // Handle movement
|
||||
* // angle (radians), power (0% - 100%), direction (enum)
|
||||
* },
|
||||
* onTap = {
|
||||
* // Handle tap
|
||||
* },
|
||||
* onDoubleTap = {
|
||||
* // Handle double tap
|
||||
* }
|
||||
* )
|
||||
* ```
|
||||
*
|
||||
* @param modifier The modifier to be applied to the layout. Crucial for setting the size (e.g., [Modifier.size]) of the joystick.
|
||||
* @param type The movement restriction constraints ([JoystickType]). Defaults to [JoystickType.EIGHT_AXIS].
|
||||
* @param stayPut If true, the thumb will stay in its last position when released. If false, it snaps back to the center.
|
||||
* @param radiusScale Scale ratio of the button (thumb) radius relative to the total joystick radius. Range: `[0.25f, 0.50f]`.
|
||||
* @param padColor Fallback background color of the pad if no [padPainter] is provided.
|
||||
* @param buttonColor Fallback background color of the thumb button if no [buttonPainter] is provided.
|
||||
* @param padPainter Custom [Painter] for drawing the background pad of the joystick.
|
||||
* @param buttonPainter Custom [Painter] for drawing the joystick thumb button.
|
||||
* @param onMove Callback triggered when the joystick is dragged. Receives:
|
||||
* - `angle` (Double): Direction angle in radians (ranging from `-PI` to `PI`).
|
||||
* - `power` (Double): Travel displacement percentage of the button from the center, ranging from `0.0` to `100.0`.
|
||||
* - `direction` (JoystickDirection): Decoded directional zone (e.g. `UP`, `DOWN_LEFT`, `CENTER`).
|
||||
* @param onTap Optional callback triggered when the joystick is clicked (pointer down & up without dragging).
|
||||
* @param onDoubleTap Optional callback triggered when the joystick is double-clicked.
|
||||
*/
|
||||
@Composable
|
||||
fun Joystick(
|
||||
modifier: Modifier = Modifier,
|
||||
type: JoystickType = JoystickType.EIGHT_AXIS,
|
||||
stayPut: Boolean = false,
|
||||
radiusScale: Float = 0.25f,
|
||||
padColor: Color = Color.White,
|
||||
buttonColor: Color = Color.Red,
|
||||
padPainter: Painter? = null,
|
||||
buttonPainter: Painter? = null,
|
||||
onMove: (angle: Double, power: Double, direction: JoystickDirection) -> Unit = { _, _, _ -> },
|
||||
onTap: (() -> Unit)? = null,
|
||||
onDoubleTap: (() -> Unit)? = null
|
||||
) {
|
||||
val currentOnMove by rememberUpdatedState(onMove)
|
||||
val currentOnTap by rememberUpdatedState(onTap)
|
||||
val currentOnDoubleTap by rememberUpdatedState(onDoubleTap)
|
||||
|
||||
val scale = remember(radiusScale) { radiusScale.coerceIn(0.25f, 0.50f) }
|
||||
|
||||
var thumbOffsetX by remember { mutableFloatStateOf(0f) }
|
||||
var thumbOffsetY by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(stayPut, type, scale) {
|
||||
val touchSlop = viewConfiguration.touchSlop
|
||||
var lastTapTime = 0L
|
||||
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val now = System.currentTimeMillis()
|
||||
val isDoubleTap = now - lastTapTime < 300L
|
||||
|
||||
var dragTriggered = false
|
||||
val pointerId = down.id
|
||||
|
||||
fun updatePosition(rawX: Float, rawY: Float) {
|
||||
val currentWidth = size.width
|
||||
val currentHeight = size.height
|
||||
val centerX = currentWidth / 2f
|
||||
val centerY = currentHeight / 2f
|
||||
val minDim = min(currentWidth, currentHeight).toFloat()
|
||||
val maxTravelDistance = (minDim / 2f) * (1f - scale)
|
||||
|
||||
if (maxTravelDistance <= 0f) return
|
||||
|
||||
val deltaX = rawX - centerX
|
||||
val deltaY = rawY - centerY
|
||||
|
||||
var targetDx = deltaX
|
||||
var targetDy = deltaY
|
||||
|
||||
when (type) {
|
||||
JoystickType.TWO_AXIS_HORIZONTAL -> targetDy = 0f
|
||||
JoystickType.TWO_AXIS_VERTICAL -> targetDx = 0f
|
||||
JoystickType.FOUR_AXIS -> {
|
||||
if (abs(deltaX) > abs(deltaY)) targetDy = 0f else targetDx = 0f
|
||||
}
|
||||
JoystickType.EIGHT_AXIS -> { /* No restriction */ }
|
||||
}
|
||||
|
||||
val distance = sqrt(targetDx * targetDx + targetDy * targetDy)
|
||||
|
||||
val newOffsetX: Float
|
||||
val newOffsetY: Float
|
||||
if (distance > maxTravelDistance) {
|
||||
newOffsetX = targetDx * maxTravelDistance / distance
|
||||
newOffsetY = targetDy * maxTravelDistance / distance
|
||||
} else {
|
||||
newOffsetX = targetDx
|
||||
newOffsetY = targetDy
|
||||
}
|
||||
|
||||
// Skip callback if thumb position hasn't meaningfully changed (prevent micro-jitter)
|
||||
if (abs(newOffsetX - thumbOffsetX) < 0.01f && abs(newOffsetY - thumbOffsetY) < 0.01f) return
|
||||
|
||||
thumbOffsetX = newOffsetX
|
||||
thumbOffsetY = newOffsetY
|
||||
|
||||
val power = (100f * sqrt(thumbOffsetX * thumbOffsetX + thumbOffsetY * thumbOffsetY) / maxTravelDistance).toDouble()
|
||||
val angle = atan2(-thumbOffsetY, -thumbOffsetX).toDouble()
|
||||
val direction = calculateDirection(Math.toDegrees(angle))
|
||||
|
||||
currentOnMove(angle, power, direction)
|
||||
}
|
||||
|
||||
updatePosition(down.position.x, down.position.y)
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
|
||||
val change = event.changes.fastFirstOrNull { it.id == pointerId }
|
||||
|
||||
if (change != null && change.positionChanged()) {
|
||||
// Only count as drag if movement exceeds touch slop
|
||||
if (!dragTriggered) {
|
||||
val totalDragX = change.position.x - down.position.x
|
||||
val totalDragY = change.position.y - down.position.y
|
||||
val totalDrag = sqrt(totalDragX * totalDragX + totalDragY * totalDragY)
|
||||
if (totalDrag > touchSlop) {
|
||||
dragTriggered = true
|
||||
}
|
||||
}
|
||||
updatePosition(change.position.x, change.position.y)
|
||||
change.consume()
|
||||
}
|
||||
|
||||
if (event.changes.fastAll { !it.pressed }) {
|
||||
if (!dragTriggered) {
|
||||
if (isDoubleTap) {
|
||||
currentOnDoubleTap?.invoke()
|
||||
lastTapTime = 0L // Reset to prevent triple-tap as double
|
||||
} else {
|
||||
currentOnTap?.invoke()
|
||||
lastTapTime = now // Only record tap time for actual taps
|
||||
}
|
||||
}
|
||||
if (!stayPut) {
|
||||
thumbOffsetX = 0f
|
||||
thumbOffsetY = 0f
|
||||
currentOnMove(0.0, 0.0, JoystickDirection.CENTER)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
val centerX = size.width / 2f
|
||||
val centerY = size.height / 2f
|
||||
val minDim = min(size.width, size.height)
|
||||
val maxTravelDistance = (minDim / 2f) * (1f - scale)
|
||||
val buttonRadius = (minDim / 2f) * scale
|
||||
|
||||
if (centerX == 0f || centerY == 0f) return@Canvas
|
||||
|
||||
// Draw Background Pad
|
||||
if (padPainter != null) {
|
||||
translate(left = centerX - maxTravelDistance, top = centerY - maxTravelDistance) {
|
||||
with(padPainter) {
|
||||
draw(size = Size(maxTravelDistance * 2, maxTravelDistance * 2))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
drawCircle(
|
||||
color = padColor,
|
||||
radius = maxTravelDistance,
|
||||
center = Offset(centerX, centerY)
|
||||
)
|
||||
}
|
||||
|
||||
// Draw Button tracking states cleanly
|
||||
val posX = centerX + thumbOffsetX
|
||||
val posY = centerY + thumbOffsetY
|
||||
|
||||
if (buttonPainter != null) {
|
||||
translate(left = posX - buttonRadius, top = posY - buttonRadius) {
|
||||
with(buttonPainter) {
|
||||
draw(size = Size(buttonRadius * 2, buttonRadius * 2))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
drawCircle(
|
||||
color = buttonColor,
|
||||
radius = buttonRadius,
|
||||
center = Offset(posX, posY)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean, O(1) mathematical lookup mapping degrees flawlessly to standard directions
|
||||
private fun calculateDirection(degrees: Double): JoystickDirection {
|
||||
// Normalize degrees from [-180, 180] to [0, 360)
|
||||
val normalized = if (degrees < 0) degrees + 360.0 else degrees
|
||||
|
||||
// Shift by 22.5 degrees so that the "LEFT" sector spans across the 0/360 boundary cleanly
|
||||
val shifted = (normalized + 22.5) % 360.0
|
||||
|
||||
// Map 45-degree chunks to their respective indices
|
||||
return when ((shifted / 45.0).toInt()) {
|
||||
0 -> JoystickDirection.LEFT
|
||||
1 -> JoystickDirection.LEFT_UP
|
||||
2 -> JoystickDirection.UP
|
||||
3 -> JoystickDirection.UP_RIGHT
|
||||
4 -> JoystickDirection.RIGHT
|
||||
5 -> JoystickDirection.RIGHT_DOWN
|
||||
6 -> JoystickDirection.DOWN
|
||||
7 -> JoystickDirection.DOWN_LEFT
|
||||
else -> JoystickDirection.CENTER
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<declare-styleable name="JoyStick">
|
||||
<attr name="padColor" format="color" />
|
||||
<attr name="buttonColor" format="color" />
|
||||
<attr name="stayPut" format="boolean" />
|
||||
<attr name="percentage" format="integer" />
|
||||
<attr name="backgroundDrawable" format="reference" />
|
||||
<attr name="buttonDrawable" format="reference" />
|
||||
</declare-styleable>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">JoyStick</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,612 @@
|
||||
# 山猫 M20 basic_server 通信协议(UDP + JSON)
|
||||
|
||||
本协议基于 TCP/UDP 传输层,本项目使用 **UDP + JSON** 模式。机器人本体为服务端,上位机(Android App)为客户端。
|
||||
|
||||
- **协议版本**: V1.2.0
|
||||
- **更新日期**: 2026-05-18
|
||||
- **服务端地址**: `10.21.31.103:30000` (UDP)
|
||||
- **载荷编码**: JSON(协议头 `ASDU格式` 字段填 `0x01`)
|
||||
|
||||
---
|
||||
|
||||
## 1. APDU 报文结构
|
||||
|
||||
```
|
||||
+---------------------+------------------+
|
||||
| Header (16 bytes) | ASDU (JSON 文本) |
|
||||
+---------------------+------------------+
|
||||
```
|
||||
|
||||
### 1.1 Header(固定 16 字节,小端 LE)
|
||||
|
||||
| 偏移 | 长度 | 字段 | 值 | 说明 |
|
||||
| ---: | ---: | --- | --- | --- |
|
||||
| 0 | 1 | Sync0 | `0xEB` | 固定 |
|
||||
| 1 | 1 | Sync1 | `0x91` | 固定 |
|
||||
| 2 | 1 | Sync2 | `0xEB` | 固定 |
|
||||
| 3 | 1 | Sync3 | `0x90` | 固定 |
|
||||
| 4 | 2 | Length | u16 LE | ASDU 字节长度,最大 65535 |
|
||||
| 6 | 2 | MsgId | u16 LE | 请求/响应配对;请求方从 0 递增,回绕 65535→0 |
|
||||
| 8 | 1 | Format | `0x01` | JSON(XML 为 `0x00`) |
|
||||
| 9 | 7 | Reserved | `0x00`×7 | 预留 |
|
||||
|
||||
### 1.2 ASDU(JSON 格式)
|
||||
|
||||
固定外层包裹 `PatrolDevice`,包含 4 个通用字段:
|
||||
|
||||
| 字段 | 类型 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| `Type` | int | 消息类型(十进制) |
|
||||
| `Command` | int | 命令码(十进制) |
|
||||
| `Time` | string | 本地时区,格式 `YYYY-MM-DD HH:MM:SS` |
|
||||
| `Items` | object | 参数项,视 Type/Command 而定 |
|
||||
|
||||
```json
|
||||
{
|
||||
"PatrolDevice": {
|
||||
"Type": 100,
|
||||
"Command": 100,
|
||||
"Time": "2026-07-17 12:00:00",
|
||||
"Items": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 控制类指令(上位机 → 机器人)
|
||||
|
||||
### 2.1 心跳指令
|
||||
|
||||
| Type | Command | 频率 |
|
||||
| ---: | ---: | --- |
|
||||
| 100 | 100 | ≥1 Hz |
|
||||
|
||||
`Items` 为空。服务端记录来源 IP/端口,并按需推送状态上报(见第 3 节)。
|
||||
|
||||
---
|
||||
|
||||
### 2.2 使用模式切换
|
||||
|
||||
| Type | Command | 说明 |
|
||||
| ---: | ---: | --- |
|
||||
| 1101 | 5 | 切换机器人使用模式 |
|
||||
|
||||
**Items**:
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `Mode` | int | `0`=常规模式 · `1`=导航模式 · `2`=辅助模式 |
|
||||
|
||||
```json
|
||||
{
|
||||
"PatrolDevice": {
|
||||
"Type": 1101, "Command": 5, "Time": "2026-07-17 12:00:00",
|
||||
"Items": { "Mode": 0 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **常规模式**:支持轴指令(Type=2, Cmd=21)
|
||||
- **导航模式**:支持速度指令(Type=2, Cmd=25)和导航任务
|
||||
|
||||
---
|
||||
|
||||
### 2.3 运动状态转换
|
||||
|
||||
| Type | Command | 说明 |
|
||||
| ---: | ---: | --- |
|
||||
| 2 | 22 | 机器人运动状态转换 |
|
||||
|
||||
**Items**:
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `MotionParam` | int | `0`=空闲 · `1`=站立 · `2`=关节阻尼/软急停 · `3`=开机阻尼 · `4`=趴下 · `17`=RL控制 |
|
||||
|
||||
```json
|
||||
{
|
||||
"PatrolDevice": {
|
||||
"Type": 2, "Command": 22, "Time": "2026-07-17 12:00:00",
|
||||
"Items": { "MotionParam": 0 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
状态流转:`空闲 → 站立 → RL控制` 为正向流程。RL 控制状态下才能下发步态切换和运动指令。
|
||||
|
||||
---
|
||||
|
||||
### 2.4 步态切换
|
||||
|
||||
| Type | Command | 说明 |
|
||||
| ---: | ---: | --- |
|
||||
| 2 | 23 | 切换步态(需在 RL 控制下) |
|
||||
|
||||
**Items**:
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GaitParam` | int | 见下表 |
|
||||
|
||||
| 值 | 步态 | 适用模式 |
|
||||
| ---: | --- | --- |
|
||||
| `0x1001` | 基础(标准运动模式) | 常规/辅助/导航 |
|
||||
| `0x1003` | 楼梯(标准运动模式) | 常规/辅助/导航 |
|
||||
| `0x3002` | 平地(敏捷运动模式) | 辅助/导航 |
|
||||
| `0x3003` | 楼梯(敏捷运动模式) | 辅助/导航 |
|
||||
|
||||
```json
|
||||
{
|
||||
"PatrolDevice": {
|
||||
"Type": 2, "Command": 23, "Time": "2026-07-17 12:00:00",
|
||||
"Items": { "GaitParam": 4097 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 敏捷运动模式速度响应更好,适合自主算法开发
|
||||
- 每次起立后或切换使用模式时,步态重置为基础步态 `0x1001`
|
||||
- 步态切换仅支持在 **RL 控制** 状态下执行
|
||||
|
||||
---
|
||||
|
||||
### 2.5 运动控制(轴指令 — 常规模式)
|
||||
|
||||
| Type | Command | 说明 |
|
||||
| ---: | ---: | --- |
|
||||
| 2 | 21 | 速度比例控制,仅常规模式 |
|
||||
|
||||
**Items** (全部 float, 范围 [-1, 1]):
|
||||
|
||||
| 参数 | 含义 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `X` | 前后方向速度比例 | 正=前进,负=后退 |
|
||||
| `Y` | 左右方向速度比例 | 正=左移,负=右移 |
|
||||
| `Z` | 高度方向速度比例 | 正=向上,负=向下 |
|
||||
| `Roll` | 翻滚角速度比例 | |
|
||||
| `Pitch` | 俯仰角速度比例 | |
|
||||
| `Yaw` | 偏航角速度比例 | 正=逆时针,负=顺时针 |
|
||||
|
||||
```json
|
||||
{
|
||||
"PatrolDevice": {
|
||||
"Type": 2, "Command": 21, "Time": "2026-07-17 12:00:00",
|
||||
"Items": { "X": 0.5, "Y": 0.0, "Z": 0.0, "Roll": 0.0, "Pitch": 0.0, "Yaw": 0.0 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 建议发送频率 ≥20 Hz
|
||||
- 常规模式的基础/楼梯步态仅 `X`, `Y`, `Yaw` 生效
|
||||
|
||||
---
|
||||
|
||||
### 2.6 运动控制(速度指令 — 导航模式)
|
||||
|
||||
| Type | Command | 说明 |
|
||||
| ---: | ---: | --- |
|
||||
| 2 | 25 | 速度绝对值控制,仅导航模式 |
|
||||
|
||||
**Items** (全部 float, 带物理单位):
|
||||
|
||||
| 参数 | 含义 | 单位 |
|
||||
| --- | --- | --- |
|
||||
| `X` | 前后方向速度 | m/s |
|
||||
| `Y` | 左右方向速度 | m/s |
|
||||
| `Z` | 高度方向速度 | m/s |
|
||||
| `Roll` | 翻滚角速度 | rad/s |
|
||||
| `Pitch` | 俯仰角速度 | rad/s |
|
||||
| `Yaw` | 偏航角速度 | rad/s |
|
||||
|
||||
```json
|
||||
{
|
||||
"PatrolDevice": {
|
||||
"Type": 2, "Command": 25, "Time": "2026-07-17 12:00:00",
|
||||
"Items": { "X": 0.5, "Y": 0.0, "Z": 0.0, "Roll": 0.0, "Pitch": 0.0, "Yaw": 0.0 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.7 照明灯控制
|
||||
|
||||
| Type | Command | 说明 |
|
||||
| ---: | ---: | --- |
|
||||
| 1101 | 2 | 照明灯开关 |
|
||||
|
||||
**Items**:
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `Front` | int | `0`=关闭 · `1`=开启 |
|
||||
| `Back` | int | `0`=关闭 · `1`=开启 |
|
||||
|
||||
```json
|
||||
{
|
||||
"PatrolDevice": {
|
||||
"Type": 1101, "Command": 2, "Time": "2026-07-17 12:00:00",
|
||||
"Items": { "Front": 1, "Back": 0 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.8 自主充电
|
||||
|
||||
| Type | Command | 说明 |
|
||||
| ---: | ---: | --- |
|
||||
| 2 | 24 | 自主充电控制 |
|
||||
|
||||
**Items**:
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `Charge` | int | `0`=结束充电 · `1`=开始充电 · `2`=清除充电状态(强制归零,谨慎使用) |
|
||||
|
||||
```json
|
||||
{
|
||||
"PatrolDevice": {
|
||||
"Type": 2, "Command": 24, "Time": "2026-07-17 12:00:00",
|
||||
"Items": { "Charge": 1 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.9 休眠模式设置
|
||||
|
||||
| Type | Command | 说明 |
|
||||
| ---: | ---: | --- |
|
||||
| 1101 | 6 | 设置休眠模式 |
|
||||
|
||||
**Items**:
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `Sleep` | bool | `true`=进入休眠 · `false`=唤醒 |
|
||||
| `Auto` | bool | `true`=启用自动休眠 · `false`=关闭 |
|
||||
| `Time` | uint | 自动休眠等待时间(分钟),范围 [5, 30] |
|
||||
|
||||
```json
|
||||
{
|
||||
"PatrolDevice": {
|
||||
"Type": 1101, "Command": 6, "Time": "2026-07-17 12:00:00",
|
||||
"Items": { "Sleep": false, "Auto": true, "Time": 5 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.10 休眠状态查询
|
||||
|
||||
| Type | Command | 说明 |
|
||||
| ---: | ---: | --- |
|
||||
| 1101 | 7 | 查询休眠状态及设置 |
|
||||
|
||||
`Items` 为空。响应同 2.9 的 Items 结构(`Sleep`, `Auto`, `Time`)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 状态上报(机器人 → 上位机)
|
||||
|
||||
UDP 下需先发一次对应查询请求触发订阅,服务端记录来源 IP/端口后持续推送。
|
||||
|
||||
### 3.1 基础状态上报 — Type=1002, Cmd=6
|
||||
|
||||
**频率**: 2 Hz
|
||||
|
||||
```json
|
||||
{
|
||||
"PatrolDevice": {
|
||||
"Type": 1002, "Command": 6, "Time": "2026-07-17 12:00:00",
|
||||
"Items": {
|
||||
"BasicStatus": {
|
||||
"MotionState": 0,
|
||||
"Gait": 0,
|
||||
"Charge": 0,
|
||||
"HES": 0,
|
||||
"ControlUsageMode": 0,
|
||||
"Direction": 0,
|
||||
"OOA": 0,
|
||||
"PowerManagement": 0,
|
||||
"Sleep": 0,
|
||||
"Version": "STD"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `MotionState` | int | `0`=空闲 · `1`=站立 · `2`=软急停 · `3`=开机阻尼 · `4`=趴下 · `17`=RL控制 |
|
||||
| `Gait` | int | `0x1001`=基础 · `0x1003`=楼梯 · `0x3002`=平地敏捷 · `0x3003`=楼梯敏捷 |
|
||||
| `Charge` | int | `0`=空闲 · `1`=前往充电桩 · `2`=充电中 · `3`=退出充电桩 · `4`=异常 · `5`=在桩上未充电 |
|
||||
| `HES` | int | `0`=未触发 · `1`=已触发(硬急停) |
|
||||
| `ControlUsageMode` | int | `0`=常规 · `1`=导航 · `2`=辅助 |
|
||||
| `Direction` | int | `0`=正向为前进正方向 · `1`=后向为前进正方向 |
|
||||
| `OOA` | int | `0`=未启动 · `1`=空闲中 · `2`=未触发避障 · `3`=主动避障中 |
|
||||
| `PowerManagement` | int | `0`=常规 · `1`=单电池模式 |
|
||||
| `Sleep` | int | `0`=未休眠 · `1`=已休眠 · `2`=进入休眠中 |
|
||||
| `Version` | string | `STD`=山猫M20 · `PRO`=山猫M20 Pro |
|
||||
|
||||
---
|
||||
|
||||
### 3.2 运控状态上报 — Type=1002, Cmd=4
|
||||
|
||||
**频率**: 10 Hz
|
||||
|
||||
```json
|
||||
{
|
||||
"PatrolDevice": {
|
||||
"Type": 1002, "Command": 4, "Time": "2026-07-17 12:00:00",
|
||||
"Items": {
|
||||
"MotionStatus": {
|
||||
"Roll": 0.0, "Pitch": 0.0, "Yaw": 0.0,
|
||||
"OmegaZ": 0.0, "LinearX": 0.0, "LinearY": 0.0,
|
||||
"Height": 0.0, "Payload": 0.0, "RemainMile": 0.0
|
||||
},
|
||||
"MotorStatus": {
|
||||
"Joint": [0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0],
|
||||
"LeftFrontHipX": 0.0, "LeftFrontHipY": 0.0, "LeftFrontKnee": 0.0, "LeftFrontWheel": 0.0,
|
||||
"RightFrontHipX": 0.0, "RightFrontHipY": 0.0, "RightFrontKnee": 0.0, "RightFrontWheel": 0.0,
|
||||
"LeftBackHipX": 0.0, "LeftBackHipY": 0.0, "LeftBackKnee": 0.0, "LeftBackWheel": 0.0,
|
||||
"RightBackHipX": 0.0, "RightBackHipY": 0.0, "RightBackKnee": 0.0, "RightBackWheel": 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**MotionStatus**:
|
||||
|
||||
| 字段 | 单位 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `Roll/Pitch/Yaw` | rad | 机身姿态角 |
|
||||
| `OmegaZ` | rad/s | Z 方向角速度 |
|
||||
| `LinearX/LinearY` | m/s | X/Y 方向线速度 |
|
||||
| `Height` | m | 机身高度 |
|
||||
| `RemainMile` | km | 预计剩余续航里程 |
|
||||
|
||||
**MotorStatus** — 16 关节顺序:LeftFrontHipX, HipY, Knee, Wheel → RightFrontHipX, HipY, Knee, Wheel → LeftBack... → RightBack...
|
||||
|
||||
| 字段 | 单位 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `Joint` | rad / rad/s | 16 元素数组,关节角度/转速 |
|
||||
| `*HipX` | rad | 侧摆关节角度 |
|
||||
| `*HipY` | rad | 髋关节角度 |
|
||||
| `*Knee` | rad | 膝关节角度 |
|
||||
| `*Wheel` | rad/s | 足轮转速 |
|
||||
|
||||
---
|
||||
|
||||
### 3.3 设备状态上报 — Type=1002, Cmd=5
|
||||
|
||||
**频率**: 2 Hz。包含 BatteryList, BatteryStatus, DeviceTemperature, Led, GPS, DevEnable, CPU 七组参数。
|
||||
|
||||
**BatteryList** (2 块电池):
|
||||
```json
|
||||
"BatteryList": [
|
||||
{ "Voltage": 0.0, "BatteryLevel": 100.0, "battery_temperature": 0.0, "charge": false, "serial": "xxx" },
|
||||
{ "Voltage": 0.0, "BatteryLevel": 100.0, "battery_temperature": 0.0, "charge": false, "serial": "xxx" }
|
||||
]
|
||||
```
|
||||
|
||||
**BatteryStatus**:
|
||||
```json
|
||||
"BatteryStatus": {
|
||||
"VoltageLeft": 0.0, "VoltageRight": 0.0,
|
||||
"BatteryLevelLeft": 0.0, "BatteryLevelRight": 0.0,
|
||||
"battery_temperatureLeft": 0.0, "battery_temperatureRight": 0.0,
|
||||
"chargeLeft": false, "chargeRight": false
|
||||
}
|
||||
```
|
||||
|
||||
**DeviceTemperature** — 16 电机 + 16 驱动器温度:
|
||||
```json
|
||||
"DeviceTemperature": {
|
||||
"Motor": [0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0],
|
||||
"Driver": [0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0],
|
||||
"LeftFrontHipXMotor": 0.0, "LeftFrontHipXDriver": 0.0, ...
|
||||
}
|
||||
```
|
||||
|
||||
**Led**:
|
||||
```json
|
||||
"Led": { "Fill": { "Front": 1, "Back": 1 } }
|
||||
```
|
||||
|
||||
**GPS**:
|
||||
```json
|
||||
"GPS": {
|
||||
"Latitude": 0.0, "Longitude": 0.0, "Speed": 0.0, "Course": 0.0,
|
||||
"FixQuality": 0, "NumSatellites": 0, "Altitude": 0.0,
|
||||
"HDOP": 0.0, "VDOP": 0.0, "PDOP": 0.0, "VisibleSatellites": 0
|
||||
}
|
||||
```
|
||||
|
||||
**DevEnable**:
|
||||
```json
|
||||
"DevEnable": {
|
||||
"FanSpeed": 100, "LoadPower": 1, "LedHost": 1, "LedExt": 1, "FP": 1,
|
||||
"Lidar": { "Front": 1, "Back": 1 },
|
||||
"GPS": 1,
|
||||
"Video": { "Front": 1, "Back": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
**CPU**:
|
||||
```json
|
||||
"CPU": {
|
||||
"AOS": { "Temperature": 0.0, "FrequencyInt": 0.0, "FrequencyApp": 0.0 },
|
||||
"NOS": { "Temperature": 0.0, "FrequencyInt": 0.0, "FrequencyApp": 0.0 },
|
||||
"GOS": { "Temperature": 0.0, "FrequencyInt": 0.0, "FrequencyApp": 0.0 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 异常状态上报 — Type=1002, Cmd=3
|
||||
|
||||
**频率**: 2 Hz,状态变更时即时上报。
|
||||
|
||||
```json
|
||||
{
|
||||
"PatrolDevice": {
|
||||
"Type": 1002, "Command": 3, "Time": "2026-07-17 12:00:00",
|
||||
"Items": {
|
||||
"ErrorList": [
|
||||
{ "errorCode": 32769, "component": 1 },
|
||||
{ "errorCode": 32770, "component": 4 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `errorCode` | int | 错误码,见下文 |
|
||||
| `component` | int | bit 位标识部件编号(关节/电池) |
|
||||
|
||||
`component` 关节位编号(自低位起):FL HipX(0) → FL HipY(1) → FL Knee(2) → FL Wheel(3) → FR HipX(4) → ... → RB Wheel(15)。电池位编号:bit0=后侧电池,bit1=前侧电池。
|
||||
|
||||
常见错误码(部分):
|
||||
|
||||
| errorCode | 含义 | errorCode | 含义 |
|
||||
| ---: | --- | ---: | --- |
|
||||
| `0x8001` | 电机温度预警 | `0x8002` | 电机温度过高保护 |
|
||||
| `0x8003` | 电机温度致命截止 | `0x8007` | 关节驱动器过温 |
|
||||
| `0x8008` | 驱动器欠压保护 | `0x8009` | 驱动器过压保护 |
|
||||
| `0x8012` | 与关节驱动器通讯超时 | `0x8016` | 编码器无值 |
|
||||
| `0x8020` | 驱动器过流保护 | `0x8102` | 低电量预警 |
|
||||
| `0x8103` | 保护电量 | `0x8107` | 电池放电过温保护 |
|
||||
| `0x8115` | 电池充电过温保护 | `0x8117` | 电池单体过压保护 |
|
||||
| `0x8201` | CPU占用率过高预警 | `0x8202` | CPU温度过高预警 |
|
||||
| `0x8211` | CPU占用率过高保护 | `0x8212` | CPU温度过高保护 |
|
||||
| `0x8501` | 自主充电定位超时 | `0x8503` | 自主充电定位异常 |
|
||||
| `0x8506` | 充电桩无电流 | `0x8507` | 充电停障错误 |
|
||||
| `0x8510` | 退桩超时 | `0x8022` | 关节角超限 |
|
||||
| `0x8027` | 机身姿态错误 | `0x8029` | 运动姿态错误 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 通用响应与错误码
|
||||
|
||||
每一条请求都会收到同 Type/Command 的响应,Items 至少包含 ErrorCode 和 ErrorMessage:
|
||||
|
||||
```json
|
||||
{
|
||||
"PatrolDevice": {
|
||||
"Type": 100, "Command": 100, "Time": "2026-07-17 12:00:00",
|
||||
"Items": { "ErrorCode": 0, "ErrorMessage": "Success" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| ErrorCode (hex) | dec | ErrorMessage | 含义 |
|
||||
| --- | ---: | --- | --- |
|
||||
| `0x0000` | 0 | Success | 命令已接收并触发(不代表业务成功) |
|
||||
| `0xE001` | 57345 | Unsupported data format | 帧头 Format 非 JSON/XML |
|
||||
| `0xE002` | 57346 | Data parsing failed | 缺少 PatrolDevice/Items/Type/Command |
|
||||
| `0xE003` | 57347 | Unsupported protocol type | Type/Command 组合不存在 |
|
||||
| `0xE004` | 57348 | Missing necessary fields | Items 缺字段 |
|
||||
| `0xE005` | 57349 | Mismatched data type | 字段类型不匹配 |
|
||||
| `0xE006` | 57350 | Mismatched request client | 2s 内轴指令须来自同一客户端 |
|
||||
| `0xE007` | 57351 | No operation permission | 无操作权限 |
|
||||
| `0xE008` | 57352 | Not allowed operation | 状态机不允许 |
|
||||
| `0xE009` | 57353 | Operation failure | 操作失败,查 Cmd=3 错误详情 |
|
||||
| `0xE00A` | 57354 | Unsupported function | 固件不支持(如导航需 Pro) |
|
||||
| `0xE00B` | 57355 | Internal error | 内部错误,查日志 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 导航任务(Pro 版本)
|
||||
|
||||
以下指令仅山猫 M20 Pro 支持。
|
||||
|
||||
### 5.1 下发单点导航 — Type=1003, Cmd=1
|
||||
|
||||
**Items**:
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `Value` | int | 目标点编号(默认 0) |
|
||||
| `MapID` | int | 地图编号(默认 0) |
|
||||
| `PosX/Y/Z` | float | 目标点在地图坐标系中的位置 (m) |
|
||||
| `AngleYaw` | float | 目标点朝向 (rad) |
|
||||
| `PointInfo` | int | `0`=过渡点 · `1`=任务点 · `3`=充电点 |
|
||||
| `Gait` | int | `0x3002`=平地敏捷 · `0x3003`=楼梯敏捷 |
|
||||
| `Speed` | int | `0`=正常 · `1`=低速 · `2`=高速 |
|
||||
| `Manner` | int | `0`=前进行走 · `1`=倒退行走 |
|
||||
| `ObsMode` | int | `0`=开启停避障 · `1`=关闭 |
|
||||
| `NavMode` | int | `0`=直线导航 · `1`=自主导航 |
|
||||
|
||||
### 5.2 取消导航 — Type=1004, Cmd=1
|
||||
|
||||
`Items` 为空。响应 `ErrorCode`: `0`=成功, `1`=失败。
|
||||
|
||||
### 5.3 查询导航任务状态 — Type=1007, Cmd=1
|
||||
|
||||
`Items` 为空。响应包含 `Value`, `Status`, `ErrorCode`。
|
||||
|
||||
`Status`: `0`=空闲 · `1`=退出充电桩中 · `2`=导航预处理 · `3`=导航中 · `4`=导航完成 · `5`=进入充电桩中 · `0xFF`=暂停中
|
||||
|
||||
### 5.4 获取地图位置 — Type=1007, Cmd=2
|
||||
|
||||
**响应 Items**:
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `Location` | int | `0`=定位正常 · `1`=定位丢失 |
|
||||
| `PosX/Y/Z` | float | 地图坐标 (m) |
|
||||
| `Roll/Pitch/Yaw` | float | 姿态角 (rad) |
|
||||
|
||||
### 5.5 初始化定位 — Type=2101, Cmd=1
|
||||
|
||||
**Items**:
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `PosX/Y/Z` | float | 重新定位坐标 (m) |
|
||||
| `Yaw` | float | 绕 Z 轴姿态角 (rad) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 手柄 App 接口清单
|
||||
|
||||
| 用途 | Type | Cmd | 方向 | 频率 |
|
||||
| --- | ---: | ---: | --- | --- |
|
||||
| 心跳保活 | 100 | 100 | 上位机→机器人 | ≥1 Hz |
|
||||
| 使用模式切换 | 1101 | 5 | 上位机→机器人 | 事件 |
|
||||
| 运动状态转换 | 2 | 22 | 上位机→机器人 | 事件 |
|
||||
| 步态切换 | 2 | 23 | 上位机→机器人 | 事件 |
|
||||
| 轴指令(常规模式) | 2 | 21 | 上位机→机器人 | ≥20 Hz |
|
||||
| 速度指令(导航模式) | 2 | 25 | 上位机→机器人 | ≥10 Hz |
|
||||
| 照明灯控制 | 1101 | 2 | 上位机→机器人 | 事件 |
|
||||
| 自主充电 | 2 | 24 | 上位机→机器人 | 事件 |
|
||||
| 休眠设置 | 1101 | 6 | 上位机→机器人 | 事件 |
|
||||
| 订阅基础状态 | 1002 | 6 | 双向 | 2 Hz 推送 |
|
||||
| 订阅运控状态 | 1002 | 4 | 双向 | 10 Hz 推送 |
|
||||
| 订阅设备状态 | 1002 | 5 | 双向 | 2 Hz 推送 |
|
||||
| 订阅异常状态 | 1002 | 3 | 双向 | 2 Hz / 即时 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 实现要点
|
||||
|
||||
1. UDP socket 绑定本地随机端口(源端口即为上报回执地址)。
|
||||
2. 心跳线程 1 Hz,发 `Type=100, Cmd=100`。
|
||||
3. 速度/轴指令线程 ≥20 Hz(无输入时发零速度保持连接)。
|
||||
4. 接收线程:解析 16 字节 header → 按 `Length` 读 JSON 字节 → 分发 handler。
|
||||
5. 请求 MsgId 从 0 递增,用于匹配响应帧。
|
||||
6. 首次连接时主动发 `1002/4`, `1002/5`, `1002/6`, `1002/3` 触发 UDP 订阅推送。
|
||||
7. 断线判定:>3 s 无任何 UDP 包 → 标记离线并 UI 提示。
|
||||
8. 轴指令 `X/Y/Yaw` 比例 [-1,1] 需映射到手柄摇杆范围。
|
||||
9. 步态切换仅 RL 控制状态下有效,需先发 `MotionParam=17`。
|
||||
10. 导航模式切换后需等待 `BasicStatus.ControlUsageMode` 确认。
|
||||
@@ -0,0 +1,28 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google {
|
||||
content {
|
||||
includeGroupByRegex("com\\.android.*")
|
||||
includeGroupByRegex("com\\.google.*")
|
||||
includeGroupByRegex("androidx.*")
|
||||
}
|
||||
}
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "m20_gamepad"
|
||||
include(":app")
|
||||
include(":joysticklibrary")
|
||||
|
||||