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>
This commit is contained in:
2026-07-17 19:24:39 +08:00
commit 04afb3331f
77 changed files with 5659 additions and 0 deletions
+15
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
+6
View File
@@ -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>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>
+18
View File
@@ -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>
+13
View File
@@ -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>
+18
View File
@@ -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>
+61
View File
@@ -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>
+10
View File
@@ -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>
+17
View File
@@ -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>
+193
View File
@@ -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.ktHeader + ASDU 封装)
- [x] 协议层:ControlCommands.kt(全部指令构建函数 + 常量)
- [x] 协议层:StatusReports.kt(全部状态上报数据类 + JSON 解析器)
- [x] 协议层:PacketEncoder.ktMsgId 管理 + APDU 编码)
- [x] 协议层:PacketDecoder.ktAPDU 解码 + 缓冲区搜索)
- [x] 网络层:ProtocolClient.ktUDP socket、心跳 1Hz、指令发送通道、接收循环、断线检测 3s、订阅触发)
- [x] 摇杆映射:JoystickController.ktangle/power -> X/Y/Yaw
- [x] 状态管理:MainViewModel.ktProtocolClient 生命周期、20Hz 轴指令循环、状态收集)
- [x] 视频播放:RtspVideoPlayer.ktMedia3 ExoPlayer RTSPComposable 包装)
- [x] 主界面:MainScreen.kt(视频背景 + 双摇杆 + 状态栏 + 控制按钮)
- [x] 入口:MainActivity.kt + INTERNET 权限
- [x] 持久化层:AppSettings.kt(配置数据类 + 默认值 + 校验)、SettingsRepository.ktDataStore Preferences 封装,`settings: Flow<AppSettings>` + 单项 setter + `setAll()`)、M20App.ktApplication 子类,进程级 `settingsRepository` 单例)
- [x] 设置界面 UISettingsScreen.kt(主机/端口/RTSP 输入 + 编码器 ExposedDropdownMenu,保存按钮调用 `setAll` 后回调 `onSaved`
- [x] 导航框架:NavRoutes.kt`MAIN`/`SETTINGS` 路由常量)、MainActivity.kt 改写为 `AppNavGraph()`NavHoststartDestination=MAIN,主界面齿轮按钮跳转设置,返回/保存后 popBackStack 到 MAIN
- [x] 连接参数从设置读取:MainViewModel 构造注入 `SettingsRepository``settings: StateFlow<AppSettings>``stateIn(Eagerly)`),`connect()` 读取 `settings.value` 的 host/port`init {}` 监听 host/port 变化(`distinctUntilChanged`)后断开当前连接,下次连接使用新参数(符合“修改设置后断开当前连接”约束)。移除 DEFAULT_HOST/DEFAULT_PORT/DEFAULT_RTSP_URL 硬编码
- [x] 连接状态机: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 仅保留硬件加速解码器
### 待办
(无 — 所有功能已实现)
+1
View File
@@ -0,0 +1 @@
/build
+68
View File
@@ -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)
}
+21
View File
@@ -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)
}
}
+33
View File
@@ -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)
/**
* 生成下一个 MsgIdu16,从 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 {
/** 左摇杆角度/强度 -> 轴指令 JSONX=前后, 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)
}
/** 右摇杆角度/强度 -> 轴指令 JSONYaw=偏航) */
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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+10
View File
@@ -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>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">m20_gamepad</string>
</resources>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.M20_gamepad" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
+13
View File
@@ -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)
}
}
+1
View File
@@ -0,0 +1 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
+15
View File
@@ -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
+12
View File
@@ -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
+45
View File
@@ -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" }
Binary file not shown.
+9
View File
@@ -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
Vendored
+251
View File
@@ -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" "$@"
Vendored
+94
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
/build
+63
View File
@@ -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"])
}
}
}
}
+17
View File
@@ -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>
+612
View File
@@ -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` | JSONXML 为 `0x00` |
| 9 | 7 | Reserved | `0x00`×7 | 预留 |
### 1.2 ASDUJSON 格式)
固定外层包裹 `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` 确认。
+28
View File
@@ -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")