Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df35ff3b05 | |||
| 1b5bc582b9 |
Generated
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DropdownSelection timestamp="2026-07-17T12:57:33.559741200Z">
|
<DropdownSelection timestamp="2026-07-17T16:06:27.410601800Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\evan\.android\avd\Medium_Phone.avd" />
|
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\evan\.android\avd\Medium_Phone.avd" />
|
||||||
|
|||||||
@@ -133,12 +133,14 @@ val y = power / 100.0 * sin(angle) // 水平分量
|
|||||||
|
|
||||||
## 构建与运行
|
## 构建与运行
|
||||||
|
|
||||||
```bash
|
> **注意**:项目源码位于 Windows 目录下,通过 WSL 访问和编辑。WSL 环境中没有 Android SDK / Gradle,无法直接运行构建命令。如需构建或安装,请在 Windows 端的 Android Studio 或终端中执行。
|
||||||
# 在项目根目录
|
|
||||||
./gradlew assembleDebug
|
|
||||||
|
|
||||||
# 安装到设备
|
```bash
|
||||||
./gradlew installDebug
|
# 在项目根目录(Windows 端)
|
||||||
|
gradlew assembleDebug
|
||||||
|
|
||||||
|
# 安装到设备(Windows 端)
|
||||||
|
gradlew installDebug
|
||||||
```
|
```
|
||||||
|
|
||||||
### 依赖
|
### 依赖
|
||||||
@@ -193,10 +195,13 @@ val y = power / 100.0 * sin(angle) // 水平分量
|
|||||||
- [x] 状态机修复:趴下状态下允许切换站立状态(机器人站立后自动进入 RL 控制,无需 App 下发)
|
- [x] 状态机修复:趴下状态下允许切换站立状态(机器人站立后自动进入 RL 控制,无需 App 下发)
|
||||||
- [x] UI 布局:增加 bg1.png 作为底图,无视频流时显示(视频流播放时覆盖底图)
|
- [x] UI 布局:增加 bg1.png 作为底图,无视频流时显示(视频流播放时覆盖底图)
|
||||||
- [x] UI 布局:开关灯合并为前灯/后灯两个按键,通过颜色(琥珀色=开/暗色=关)表示置位状态,配合机器人回报状态同步
|
- [x] UI 布局:开关灯合并为前灯/后灯两个按键,通过颜色(琥珀色=开/暗色=关)表示置位状态,配合机器人回报状态同步
|
||||||
|
- [x] 状态栏:ICMP 延迟测量 + WiFi 信号强度图标显示,放置于连接状态文字右侧,延迟独立于连接生命周期
|
||||||
|
- [x] UI 布局:StatusBar 背景延伸到屏幕顶端,视觉吸附优化(Box + Row 双层结构,背景填满状态栏区域)
|
||||||
|
- [x] UI 布局:连接按钮合并到顶部 StatusBar 的 ConnectionIndicator,删除底部独立连接按钮
|
||||||
|
- [x] UI 布局:ConnectionIndicator 改为按钮形态(Surface + 圆角边框),仅指示器区域可点击切换连接
|
||||||
|
- [x] UI 布局:隐藏系统状态栏(WindowInsetsControllerCompat),压缩自定义 StatusBar 高度,扩展可视区域
|
||||||
|
|
||||||
### 待办
|
### 待办
|
||||||
|
|
||||||
- [ ] UI 布局:header 部分虽已避开状态栏,但底色与屏幕顶端视觉不吸附,需优化视觉传达效果
|
|
||||||
- [ ] UI 布局:功能按钮均布在四周,充分利用屏幕空间,优化单手操作体验
|
- [ ] UI 布局:功能按钮均布在四周,充分利用屏幕空间,优化单手操作体验
|
||||||
- [ ] 状态栏:增加 ICMP 延迟测量显示,显示当前 WiFi 强度和延迟 ms
|
|
||||||
- [ ] 视频进一步延迟优化:探索更激进的低延迟策略(如降低帧率、缩小分辨率、调整 ExoPlayer 缓冲策略等)
|
- [ ] 视频进一步延迟优化:探索更激进的低延迟策略(如降低帧率、缩小分辨率、调整 ExoPlayer 缓冲策略等)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".M20App"
|
android:name=".M20App"
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import androidx.activity.compose.setContent
|
|||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
@@ -18,6 +20,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
// 隐藏系统状态栏,顶部区域完全由自定义 StatusBar 接管
|
||||||
|
WindowInsetsControllerCompat(window, window.decorView).apply {
|
||||||
|
hide(WindowInsetsCompat.Type.statusBars())
|
||||||
|
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
|
}
|
||||||
setContent {
|
setContent {
|
||||||
M20_gamepadTheme {
|
M20_gamepadTheme {
|
||||||
AppNavGraph()
|
AppNavGraph()
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.example.m20_gamepad.service
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ICMP 延迟监测器。使用系统 ping 命令测量到目标主机的往返延迟。
|
||||||
|
*
|
||||||
|
* @param host 目标主机 IP
|
||||||
|
* @param intervalMs 测量间隔(毫秒)
|
||||||
|
* @param scope 协程作用域
|
||||||
|
*/
|
||||||
|
class PingMonitor(
|
||||||
|
private val host: String,
|
||||||
|
private val intervalMs: Long = 3000L,
|
||||||
|
private val scope: CoroutineScope
|
||||||
|
) {
|
||||||
|
private val _latencyMs = MutableStateFlow<Long?>(null)
|
||||||
|
val latencyMs: StateFlow<Long?> = _latencyMs.asStateFlow()
|
||||||
|
|
||||||
|
private var job: Job? = null
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
stop()
|
||||||
|
job = scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
_latencyMs.value = ping()
|
||||||
|
delay(intervalMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
job?.cancel()
|
||||||
|
job = null
|
||||||
|
_latencyMs.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行一次 ICMP ping,返回延迟毫秒数;失败返回 null。
|
||||||
|
*/
|
||||||
|
private fun ping(): Long? {
|
||||||
|
return try {
|
||||||
|
val process = ProcessBuilder(
|
||||||
|
"ping", "-c", "1", "-W", "2", host
|
||||||
|
).redirectErrorStream(true).start()
|
||||||
|
|
||||||
|
val reader = BufferedReader(InputStreamReader(process.inputStream))
|
||||||
|
val output = reader.readText()
|
||||||
|
process.waitFor()
|
||||||
|
|
||||||
|
// 解析 "time=12.3 ms" 格式
|
||||||
|
val regex = Regex("time=(\\d+(?:\\.\\d+)?)\\s*ms")
|
||||||
|
val match = regex.find(output)
|
||||||
|
match?.groupValues?.get(1)?.toDouble()?.toLong()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.example.m20_gamepad.ui
|
|||||||
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -14,7 +15,6 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
@@ -42,6 +42,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -52,6 +53,7 @@ import androidx.lifecycle.viewmodel.initializer
|
|||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
import com.erz.joysticklibrary.Joystick
|
import com.erz.joysticklibrary.Joystick
|
||||||
import com.erz.joysticklibrary.JoystickType
|
import com.erz.joysticklibrary.JoystickType
|
||||||
|
import com.example.m20_gamepad.M20App
|
||||||
import com.example.m20_gamepad.R
|
import com.example.m20_gamepad.R
|
||||||
import com.example.m20_gamepad.data.SettingsRepository
|
import com.example.m20_gamepad.data.SettingsRepository
|
||||||
import com.example.m20_gamepad.network.ProtocolClient
|
import com.example.m20_gamepad.network.ProtocolClient
|
||||||
@@ -73,13 +75,14 @@ private val OverlayText = Color.White
|
|||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(
|
fun MainScreen(
|
||||||
repository: SettingsRepository,
|
repository: SettingsRepository,
|
||||||
onSettingsClick: () -> Unit,
|
onSettingsClick: () -> Unit
|
||||||
viewModel: MainViewModel = viewModel(
|
) {
|
||||||
|
val app = LocalContext.current.applicationContext as M20App
|
||||||
|
val viewModel: MainViewModel = viewModel(
|
||||||
factory = viewModelFactory {
|
factory = viewModelFactory {
|
||||||
initializer { MainViewModel(repository) }
|
initializer { MainViewModel(repository, app) }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
) {
|
|
||||||
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||||
val status by viewModel.latestStatus.collectAsStateWithLifecycle()
|
val status by viewModel.latestStatus.collectAsStateWithLifecycle()
|
||||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||||
@@ -91,6 +94,8 @@ fun MainScreen(
|
|||||||
val ledStatus by viewModel.ledStatus.collectAsStateWithLifecycle()
|
val ledStatus by viewModel.ledStatus.collectAsStateWithLifecycle()
|
||||||
val lastUpdate by viewModel.lastUpdate.collectAsStateWithLifecycle()
|
val lastUpdate by viewModel.lastUpdate.collectAsStateWithLifecycle()
|
||||||
val actionFeedback by viewModel.actionFeedback.collectAsStateWithLifecycle()
|
val actionFeedback by viewModel.actionFeedback.collectAsStateWithLifecycle()
|
||||||
|
val pingLatencyMs by viewModel.pingLatencyMs.collectAsStateWithLifecycle()
|
||||||
|
val wifiSignalLevel by viewModel.wifiSignalLevel.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
var statusPanelVisible by remember { mutableStateOf(false) }
|
var statusPanelVisible by remember { mutableStateOf(false) }
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
@@ -125,11 +130,18 @@ fun MainScreen(
|
|||||||
StatusBar(
|
StatusBar(
|
||||||
connectionState = connectionState,
|
connectionState = connectionState,
|
||||||
status = status,
|
status = status,
|
||||||
|
pingLatencyMs = pingLatencyMs,
|
||||||
|
wifiSignalLevel = wifiSignalLevel,
|
||||||
onStatusClick = { statusPanelVisible = true },
|
onStatusClick = { statusPanelVisible = true },
|
||||||
onSettingsClick = onSettingsClick,
|
onSettingsClick = onSettingsClick,
|
||||||
modifier = Modifier
|
onConnectToggle = {
|
||||||
.align(Alignment.TopCenter)
|
if (connectionState == ProtocolClient.State.CONNECTED) {
|
||||||
.windowInsetsPadding(WindowInsets.statusBars)
|
viewModel.disconnect()
|
||||||
|
} else {
|
||||||
|
viewModel.connect()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.align(Alignment.TopCenter)
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- 底部控制区 ----
|
// ---- 底部控制区 ----
|
||||||
@@ -169,20 +181,33 @@ fun MainScreen(
|
|||||||
private fun StatusBar(
|
private fun StatusBar(
|
||||||
connectionState: ProtocolClient.State,
|
connectionState: ProtocolClient.State,
|
||||||
status: com.example.m20_gamepad.network.models.StatusReport?,
|
status: com.example.m20_gamepad.network.models.StatusReport?,
|
||||||
|
pingLatencyMs: Long?,
|
||||||
|
wifiSignalLevel: Int,
|
||||||
onStatusClick: () -> Unit,
|
onStatusClick: () -> Unit,
|
||||||
onSettingsClick: () -> Unit,
|
onSettingsClick: () -> Unit,
|
||||||
|
onConnectToggle: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Row(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(OverlayBg)
|
.background(OverlayBg)
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
// 连接状态指示
|
// 连接状态 + WiFi 信号 + Ping 延迟
|
||||||
ConnectionIndicator(connectionState)
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
ConnectionIndicator(connectionState, onConnectToggle)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
WifiSignalIcon(level = wifiSignalLevel)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
PingIndicator(pingLatencyMs)
|
||||||
|
}
|
||||||
|
|
||||||
// 电量 / 运动状态
|
// 电量 / 运动状态
|
||||||
Row(
|
Row(
|
||||||
@@ -216,10 +241,11 @@ private fun StatusBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ConnectionIndicator(state: ProtocolClient.State) {
|
private fun ConnectionIndicator(state: ProtocolClient.State, onClick: () -> Unit) {
|
||||||
val (color, text) = when (state) {
|
val (color, text) = when (state) {
|
||||||
ProtocolClient.State.CONNECTED -> Color(0xFF4CAF50) to "已连接"
|
ProtocolClient.State.CONNECTED -> Color(0xFF4CAF50) to "已连接"
|
||||||
ProtocolClient.State.CONNECTING -> Color(0xFFFFC107) to "连接中"
|
ProtocolClient.State.CONNECTING -> Color(0xFFFFC107) to "连接中"
|
||||||
@@ -227,7 +253,16 @@ private fun ConnectionIndicator(state: ProtocolClient.State) {
|
|||||||
ProtocolClient.State.ERROR -> Color(0xFFF44336) to "错误"
|
ProtocolClient.State.ERROR -> Color(0xFFF44336) to "错误"
|
||||||
ProtocolClient.State.DISCONNECTED -> Color(0xFF9E9E9E) to "未连接"
|
ProtocolClient.State.DISCONNECTED -> Color(0xFF9E9E9E) to "未连接"
|
||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
border = BorderStroke(1.dp, Color(0x55FFFFFF)),
|
||||||
|
color = Color(0x22000000)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(10.dp)
|
.size(10.dp)
|
||||||
@@ -236,6 +271,47 @@ private fun ConnectionIndicator(state: ProtocolClient.State) {
|
|||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
Text(text, color = OverlayText, fontSize = 14.sp)
|
Text(text, color = OverlayText, fontSize = 14.sp)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WiFi 信号强度图标(0-5 格信号柱),白色填充、灰色未填充。 */
|
||||||
|
@Composable
|
||||||
|
private fun WifiSignalIcon(level: Int) {
|
||||||
|
val activeColor = OverlayText
|
||||||
|
val inactiveColor = Color(0x66FFFFFF)
|
||||||
|
val clamped = level.coerceIn(0, 5)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.Bottom,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
// 5 根信号柱,从低到高
|
||||||
|
val barHeights = intArrayOf(6, 10, 14, 18, 22)
|
||||||
|
for (i in 0 until 5) {
|
||||||
|
val isActive = i < clamped
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(4.dp)
|
||||||
|
.height(barHeights[i].dp)
|
||||||
|
.background(if (isActive) activeColor else inactiveColor, RoundedCornerShape(1.dp))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PingIndicator(latencyMs: Long?) {
|
||||||
|
if (latencyMs == null) {
|
||||||
|
Text("- -", color = Color(0xFF9E9E9E), fontSize = 13.sp)
|
||||||
|
} else {
|
||||||
|
val color = when {
|
||||||
|
latencyMs < 50 -> Color(0xFF4CAF50) // 绿色 — 良好
|
||||||
|
latencyMs < 150 -> Color(0xFFFFC107) // 黄色 — 一般
|
||||||
|
latencyMs < 300 -> Color(0xFFFF9800) // 橙色 — 较差
|
||||||
|
else -> Color(0xFFF44336) // 红色 — 很差
|
||||||
|
}
|
||||||
|
Text("${latencyMs}ms", color = color, fontSize = 13.sp)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -321,19 +397,6 @@ private fun ControlButtons(
|
|||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState)
|
||||||
.padding(top = 4.dp, bottom = 4.dp)
|
.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(
|
ControlButton(
|
||||||
"起立",
|
"起立",
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.example.m20_gamepad.ui
|
package com.example.m20_gamepad.ui
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.wifi.WifiManager
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.example.m20_gamepad.data.AppSettings
|
import com.example.m20_gamepad.data.AppSettings
|
||||||
@@ -14,6 +17,7 @@ import com.example.m20_gamepad.network.models.MotionStatus
|
|||||||
import com.example.m20_gamepad.network.models.StatusReport
|
import com.example.m20_gamepad.network.models.StatusReport
|
||||||
import com.example.m20_gamepad.service.JoystickController
|
import com.example.m20_gamepad.service.JoystickController
|
||||||
import com.example.m20_gamepad.service.MotionTransitionResult
|
import com.example.m20_gamepad.service.MotionTransitionResult
|
||||||
|
import com.example.m20_gamepad.service.PingMonitor
|
||||||
import com.example.m20_gamepad.service.RobotConnection
|
import com.example.m20_gamepad.service.RobotConnection
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -40,7 +44,8 @@ import kotlinx.coroutines.launch
|
|||||||
* @param repository 应用级设置仓库
|
* @param repository 应用级设置仓库
|
||||||
*/
|
*/
|
||||||
class MainViewModel(
|
class MainViewModel(
|
||||||
private val repository: SettingsRepository
|
private val repository: SettingsRepository,
|
||||||
|
private val application: Application
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -81,6 +86,18 @@ class MainViewModel(
|
|||||||
private val _actionFeedback = MutableStateFlow<String?>(null)
|
private val _actionFeedback = MutableStateFlow<String?>(null)
|
||||||
val actionFeedback: StateFlow<String?> = _actionFeedback.asStateFlow()
|
val actionFeedback: StateFlow<String?> = _actionFeedback.asStateFlow()
|
||||||
|
|
||||||
|
/** ICMP 延迟(毫秒),null 表示无数据或未连接。 */
|
||||||
|
private val _pingLatencyMs = MutableStateFlow<Long?>(null)
|
||||||
|
val pingLatencyMs: StateFlow<Long?> = _pingLatencyMs.asStateFlow()
|
||||||
|
|
||||||
|
private var pingMonitor: PingMonitor? = null
|
||||||
|
|
||||||
|
/** WiFi 信号强度级别(0-5 格),0=无WiFi/无信号。 */
|
||||||
|
private val _wifiSignalLevel = MutableStateFlow(0)
|
||||||
|
val wifiSignalLevel: StateFlow<Int> = _wifiSignalLevel.asStateFlow()
|
||||||
|
|
||||||
|
private var wifiJob: Job? = null
|
||||||
|
|
||||||
/** 当前设置快照(主界面用于驱动视频播放器地址/编码器) */
|
/** 当前设置快照(主界面用于驱动视频播放器地址/编码器) */
|
||||||
val settings: StateFlow<AppSettings> = repository.settings.stateIn(
|
val settings: StateFlow<AppSettings> = repository.settings.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@@ -101,6 +118,17 @@ class MainViewModel(
|
|||||||
val isConnected: Boolean get() = robot.isConnected
|
val isConnected: Boolean get() = robot.isConnected
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
// 启动 ICMP 延迟监测(独立于连接生命周期)
|
||||||
|
restartPingMonitor(settings.value.robotHost)
|
||||||
|
// 设置中 host 变更时重启 ping
|
||||||
|
viewModelScope.launch {
|
||||||
|
settings
|
||||||
|
.map { it.robotHost }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { host -> restartPingMonitor(host) }
|
||||||
|
}
|
||||||
|
// 启动 WiFi 信号强度监测(独立于连接生命周期)
|
||||||
|
startWifiMonitoring()
|
||||||
// 设置变更时断开当前连接,下次 connect 使用新参数
|
// 设置变更时断开当前连接,下次 connect 使用新参数
|
||||||
settingsWatchJob = viewModelScope.launch {
|
settingsWatchJob = viewModelScope.launch {
|
||||||
settings
|
settings
|
||||||
@@ -110,6 +138,44 @@ class MainViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 启动 WiFi 信号强度周期性监测。 */
|
||||||
|
private fun startWifiMonitoring() {
|
||||||
|
wifiJob?.cancel()
|
||||||
|
wifiJob = viewModelScope.launch {
|
||||||
|
val wifiManager = application.getSystemService(Context.WIFI_SERVICE) as? WifiManager
|
||||||
|
if (wifiManager == null) {
|
||||||
|
_wifiSignalLevel.value = 0
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
while (isActive) {
|
||||||
|
val info = wifiManager.connectionInfo
|
||||||
|
val rssi = info?.rssi ?: -127
|
||||||
|
_wifiSignalLevel.value = if (rssi == -127 || info == null) {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
when {
|
||||||
|
rssi >= -50 -> 5
|
||||||
|
rssi >= -60 -> 4
|
||||||
|
rssi >= -70 -> 3
|
||||||
|
rssi >= -80 -> 2
|
||||||
|
rssi >= -90 -> 1
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delay(3000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重启 PingMonitor(目标 host 变更时调用)。 */
|
||||||
|
private fun restartPingMonitor(host: String) {
|
||||||
|
pingMonitor?.stop()
|
||||||
|
pingMonitor = PingMonitor(host, scope = viewModelScope).also { it.start() }
|
||||||
|
viewModelScope.launch {
|
||||||
|
pingMonitor?.latencyMs?.collect { _pingLatencyMs.value = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 建立连接并启动轴指令循环。参数取自当前设置。
|
* 建立连接并启动轴指令循环。参数取自当前设置。
|
||||||
*
|
*
|
||||||
@@ -266,6 +332,9 @@ class MainViewModel(
|
|||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
disconnect()
|
disconnect()
|
||||||
|
wifiJob?.cancel()
|
||||||
|
pingMonitor?.stop()
|
||||||
|
pingMonitor = null
|
||||||
settingsWatchJob?.cancel()
|
settingsWatchJob?.cancel()
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user