Initial commit: M20 Gamepad control app

山猫 M20 四足机器人手柄控制 Android App,使用 UDP + JSON 协议控制机器人运动。

- 协议层:16 字节小端 Header + JSON ASDU 编解码
- 网络层:UDP 客户端,心跳 1Hz,断线检测 3s
- 连接状态机:空闲/连接中/已连接/断线 + 运动状态机:空闲/站立/RL 控制
- 虚拟摇杆:Compose 原生摇杆库,angle/power -> X/Y/Yaw 映射
- 视频播放:Media3 ExoPlayer RTSP,支持 AUTO/FORCE_HW/FORCE_SW_H264/FORCE_SW_H265
- 设置界面:机器人 IP/端口、RTSP 地址、编码器选择,DataStore 持久化
- 状态面板:异常列表、运控状态、设备温度、电池信息
- 控制按钮:模式/步态/照明/充电/休眠

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

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">m20_gamepad</string>
</resources>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.M20_gamepad" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
@@ -0,0 +1,17 @@
package com.example.m20_gamepad
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}