feat: 状态栏增加 WiFi 信号强度图标 + ICMP 延迟显示

- 新增 PingMonitor.kt:系统 ping 命令测量 ICMP 延迟,每 3 秒一次
- 新增 WifiSignalMonitor(内联在 MainViewModel):WiFiManager 读取 RSSI,
  按阈值映射为 0-5 格信号强度
- MainViewModel 改用 Application 注入以访问系统服务;PingMonitor 和 WiFi
  监测均独立于 UDP 连接生命周期(init 启动,onCleared 停止)
- MainScreen StatusBar 重新布局:
  连接状态文字 | WiFi 信号柱图标 | Ping 延迟 ms | 电量% | 运动状态 | Info | Settings
- AGENTS.md 待办项移至已完成

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
2026-07-18 00:13:42 +08:00
parent f055777a6f
commit 1b5bc582b9
6 changed files with 202 additions and 9 deletions
+1 -1
View File
@@ -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" />
+1 -1
View File
@@ -193,10 +193,10 @@ 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 信号强度图标显示,放置于连接状态文字右侧,延迟独立于连接生命周期
### 待办 ### 待办
- [ ] UI 布局:header 部分虽已避开状态栏,但底色与屏幕顶端视觉不吸附,需优化视觉传达效果 - [ ] UI 布局:header 部分虽已避开状态栏,但底色与屏幕顶端视觉不吸附,需优化视觉传达效果
- [ ] UI 布局:功能按钮均布在四周,充分利用屏幕空间,优化单手操作体验 - [ ] UI 布局:功能按钮均布在四周,充分利用屏幕空间,优化单手操作体验
- [ ] 状态栏:增加 ICMP 延迟测量显示,显示当前 WiFi 强度和延迟 ms
- [ ] 视频进一步延迟优化:探索更激进的低延迟策略(如降低帧率、缩小分辨率、调整 ExoPlayer 缓冲策略等) - [ ] 视频进一步延迟优化:探索更激进的低延迟策略(如降低帧率、缩小分辨率、调整 ExoPlayer 缓冲策略等)
+1
View File
@@ -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"
@@ -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
}
}
}
@@ -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,6 +130,8 @@ 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 modifier = Modifier
@@ -169,6 +176,8 @@ 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,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@@ -181,8 +190,14 @@ private fun StatusBar(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
// 连接状态指示 // 连接状态 + WiFi 信号 + Ping 延迟
Row(verticalAlignment = Alignment.CenterVertically) {
ConnectionIndicator(connectionState) ConnectionIndicator(connectionState)
Spacer(Modifier.width(12.dp))
WifiSignalIcon(level = wifiSignalLevel)
Spacer(Modifier.width(12.dp))
PingIndicator(pingLatencyMs)
}
// 电量 / 运动状态 // 电量 / 运动状态
Row( Row(
@@ -238,6 +253,46 @@ private fun ConnectionIndicator(state: ProtocolClient.State) {
} }
} }
/** 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
private fun StatusChip(text: String) { private fun StatusChip(text: String) {
Surface( Surface(
@@ -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()
} }