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