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