feat: 添加导航控制与MQTT命令处理功能

- 新增NavController类,封装机器人导航相关操作(前往、停止、巡逻等)
- 扩展MqttManager以支持JSON命令解析,处理导航与语音指令
- 在AndroidManifest中添加temimetadata声明,使应用作为技能运行
- 移除设置界面中的日志显示功能,简化UI
- 优化主界面布局结构,修复缩进问题
- 添加到达目的地自动语音播报功能
- 固定表情视图尺寸,确保显示一致性
This commit is contained in:
2026-03-11 15:30:23 +08:00
parent 03cc654468
commit 8c687aa76e
7 changed files with 199 additions and 80 deletions

View File

@@ -16,6 +16,9 @@
android:supportsRtl="true"
android:theme="@style/Theme.Lzwcaiterminaltemi"
tools:targetApi="31">
<meta-data
android:name="com.robotemi.sdk.metadata.SKILL"
android:value="@string/app_name" />
<activity
android:name=".MainActivity"
android:exported="true">

View File

@@ -12,14 +12,20 @@ import com.example.lzwcai_terminal_temi.databinding.ActivityMainBinding
import com.robotemi.sdk.Robot
import com.robotemi.sdk.TtsRequest
import com.robotemi.sdk.Robot.TtsListener
import com.robotemi.sdk.listeners.OnGoToLocationStatusChangedListener
import com.robotemi.sdk.listeners.OnRobotReadyListener
class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, SharedPreferences.OnSharedPreferenceChangeListener {
class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnGoToLocationStatusChangedListener, SharedPreferences.OnSharedPreferenceChangeListener {
private lateinit var robot: Robot
private lateinit var binding: ActivityMainBinding
private var mqttManager: MqttManager? = null
private lateinit var prefs: SharedPreferences
private lateinit var navCon: NavController
private var lastArrivalLocation: String? = null
private var lastArrivalAt: Long = 0L
private val fixedFaceScale = 1.0f
private val baseFaceSizeDp = 1000f
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
@@ -30,6 +36,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, Sha
LogManager.startLogcatListener()
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN)
robot = Robot.getInstance()
navCon = NavController(robot)
prefs = getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
@@ -46,6 +53,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, Sha
}
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.SMILE
applyFaceScale(fixedFaceScale)
updateMqttConnection()
}
@@ -54,6 +62,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, Sha
super.onStart()
robot.addOnRobotReadyListener(this)
robot.addTtsListener(this)
robot.addOnGoToLocationStatusChangedListener(this)
prefs.registerOnSharedPreferenceChangeListener(this)
mqttManager?.connect()
}
@@ -62,13 +71,13 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, Sha
super.onStop()
robot.removeOnRobotReadyListener(this)
robot.removeTtsListener(this)
robot.removeOnGoToLocationStatusChangedListener(this)
prefs.unregisterOnSharedPreferenceChangeListener(this)
mqttManager?.disconnect()
}
override fun onDestroy() {
super.onDestroy()
// 确保在应用销毁时彻底释放资源
mqttManager?.disconnect()
LogManager.stopLogcatListener()
Log.i("MainActivity", "All resources released on destroy.")
@@ -108,6 +117,23 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, Sha
}
}
override fun onGoToLocationStatusChanged(location: String, status: String, descriptionId: Int, description: String) {
val normalized = status.lowercase()
if (normalized != "complete") {
return
}
val now = System.currentTimeMillis()
if (lastArrivalLocation == location && now - lastArrivalAt < 5000L) {
return
}
lastArrivalLocation = location
lastArrivalAt = now
val text = "已到达$location"
val ttsRequest = TtsRequest.create(text, false, language = TtsRequest.Language.ZH_CN)
robot.speak(ttsRequest)
Log.i("MainActivity", "Arrived at $location, announcement sent.")
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
if (key == "network_ip") {
Log.i("MainActivity", "IP address changed, re-initializing MQTT connection.")
@@ -119,7 +145,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, Sha
mqttManager?.disconnect()
val ip = prefs.getString("network_ip", null)
if (!ip.isNullOrEmpty()) {
mqttManager = MqttManager(this, ip)
mqttManager = MqttManager(this, ip, robot, navCon)
mqttManager?.connect()
Log.i("MainActivity", "MQTT Manager updated with new IP: $ip")
} else {
@@ -127,4 +153,14 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, Sha
Log.w("MainActivity", "MQTT Manager disabled: IP address is not set.")
}
}
private fun applyFaceScale(scale: Float) {
val density = resources.displayMetrics.density
val sizePx = (baseFaceSizeDp * density * scale).toInt()
val params = binding.animatedEmojiView.layoutParams
params.width = sizePx
params.height = sizePx
binding.animatedEmojiView.layoutParams = params
binding.animatedEmojiView.requestLayout()
}
}

View File

@@ -2,11 +2,19 @@ package com.example.lzwcai_terminal_temi
import android.content.Context
import android.util.Log
import com.robotemi.sdk.Robot
import com.robotemi.sdk.TtsRequest
import kotlinx.coroutines.*
import org.eclipse.paho.client.mqttv3.*
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence
import org.json.JSONObject
class MqttManager(private val context: Context, private val serverIp: String) {
class MqttManager(
private val context: Context,
private val serverIp: String,
private val robot: Robot,
private val navController: NavController
) {
private var mqttClient: MqttClient? = null
private val TAG = "MqttManager"
@@ -33,10 +41,10 @@ class MqttManager(private val context: Context, private val serverIp: String) {
override fun messageArrived(topic: String?, message: MqttMessage?) {
val payload = String(message?.payload ?: ByteArray(0))
Log.i(TAG, "Message arrived: Topic=$topic, Payload=$payload")
handleIncomingMessage(topic, payload)
}
override fun deliveryComplete(token: IMqttDeliveryToken?) {
// This is not critical for our use case
}
})
} catch (e: MqttException) {
@@ -70,7 +78,7 @@ class MqttManager(private val context: Context, private val serverIp: String) {
private fun scheduleReconnect() {
if (reconnectJob?.isActive == true) {
return // 如果已经在计划重连,则不再创建新的
return
}
reconnectJob = scope.launch {
Log.d(TAG, "Scheduling reconnect in 5 seconds.")
@@ -82,7 +90,7 @@ class MqttManager(private val context: Context, private val serverIp: String) {
fun disconnect() {
scope.launch {
try {
reconnectJob?.cancel() // 取消任何待处理的重连任务
reconnectJob?.cancel()
if (mqttClient?.isConnected == true) {
mqttClient?.disconnect()
Log.i(TAG, "Disconnected from MQTT broker.")
@@ -123,4 +131,76 @@ class MqttManager(private val context: Context, private val serverIp: String) {
}
}
}
private fun handleIncomingMessage(topic: String?, payload: String) {
val trimmed = payload.trim()
if (trimmed.isEmpty()) {
return
}
if (!trimmed.startsWith("{")) {
Log.w(TAG, "Ignored non-JSON payload on ${topic ?: "unknown"}: $payload")
return
}
try {
val obj = JSONObject(trimmed)
handleJsonCommand(obj)
} catch (e: Exception) {
Log.w(TAG, "Invalid JSON payload: $payload")
}
}
private fun handleJsonCommand(obj: JSONObject) {
val action = obj.optString("action", obj.optString("cmd", obj.optString("type", ""))).lowercase()
when (action) {
"goto" -> {
val location = obj.optString("location", obj.optString("target", ""))
goTo(location)
}
"speak" -> {
val text = obj.optString("text", obj.optString("speech", ""))
val lang = obj.optString("lang", "")
speak(text, lang)
}
"stop" -> {
navController.stop()
}
"patrol" -> {
navController.randomPatrol()
}
else -> Log.w(TAG, "Unknown command action: $action")
}
}
private fun goTo(location: String) {
val target = location.trim()
if (target.isEmpty()) {
Log.w(TAG, "GoTo ignored: empty location")
return
}
val ok = navController.goTo(target)
Log.i(TAG, "GoTo command sent: $target, result=$ok")
}
private fun speak(text: String, langCode: String?) {
val content = text.trim()
if (content.isEmpty()) {
Log.w(TAG, "Speak ignored: empty text")
return
}
val language = resolveLanguage(langCode)
scope.launch(Dispatchers.Main) {
val ttsRequest = TtsRequest.create(content, false, language = language)
robot.speak(ttsRequest)
Log.i(TAG, "Speak command sent: $content, lang=$language")
}
}
private fun resolveLanguage(langCode: String?): TtsRequest.Language {
val code = langCode?.trim()?.lowercase().orEmpty()
return when (code) {
"zh", "zh_cn", "zh-cn" -> TtsRequest.Language.ZH_CN
"en", "en_us", "en-us" -> TtsRequest.Language.EN_US
else -> TtsRequest.Language.ZH_CN
}
}
}

View File

@@ -0,0 +1,37 @@
package com.example.lzwcai_terminal_temi
import android.util.Log
import com.robotemi.sdk.Robot
class NavController(private val robot: Robot) {
private val TAG = "NavController"
fun goTo(location: String): Boolean {
robot.goTo(location)
return true
}
fun stop(): Boolean {
robot.stopMovement()
return true
}
fun getAllLocations(): List<String> {
return robot.locations
}
fun patrol(locations: List<String>, nonStop: Boolean = false, times: Int = 1, waiting: Int = 3) {
robot.patrol(locations, nonStop, times, waiting)
}
fun randomPatrol() {
val allLocations = getAllLocations()
if (allLocations.size < 3) {
return
}
val patrolCount = (3..minOf(6, allLocations.size)).random()
val patrolLocations = allLocations.shuffled().take(patrolCount)
patrol(patrolLocations, false, 1, 3)
}
}

View File

@@ -6,42 +6,42 @@
android:padding="16dp"
tools:context=".MainActivity">
<ImageButton
android:id="@+id/btnSettings"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_alignParentTop="true"
android:layout_alignParentEnd="true"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/btn_settings"
android:src="@android:drawable/ic_menu_manage" />
<ImageButton
android:id="@+id/btnSettings"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_alignParentTop="true"
android:layout_alignParentEnd="true"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/btn_settings"
android:src="@android:drawable/ic_menu_manage" />
<com.example.lzwcai_terminal_temi.AnimatedEmojiView
android:id="@+id/animatedEmojiView"
android:layout_width="500dp"
android:layout_height="500dp"
android:layout_centerInParent="true" />
<com.example.lzwcai_terminal_temi.AnimatedEmojiView
android:id="@+id/animatedEmojiView"
android:layout_width="500dp"
android:layout_height="500dp"
android:layout_centerInParent="true" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_centerHorizontal="true"
android:layout_marginBottom="32dp"
android:orientation="horizontal">
<Button
android:id="@+id/btnRandomExpression"
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:text="@string/btn_random_expression" />
android:layout_alignParentBottom="true"
android:layout_centerHorizontal="true"
android:layout_marginBottom="32dp"
android:orientation="horizontal">
<Button
android:id="@+id/btnSpeak"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/btn_speak" />
</LinearLayout>
<Button
android:id="@+id/btnRandomExpression"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:text="@string/btn_random_expression" />
<Button
android:id="@+id/btnSpeak"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/btn_speak" />
</LinearLayout>
</RelativeLayout>

View File

@@ -91,38 +91,5 @@
</FrameLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/restartLayout"
android:layout_marginTop="32dp"
android:orientation="vertical">
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginBottom="24dp"
android:background="#CCCCCC" />
<Button
android:id="@+id/btnToggleLogs"
android:layout_width="match_parent"
android:layout_height="80dp"
android:layout_marginBottom="16dp"
android:text="@string/btn_show_logs"
android:textSize="24sp" />
<TextView
android:id="@+id/tvLog"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#EEEEEE"
android:padding="16dp"
android:scrollbars="vertical"
android:text="@string/log_placeholder"
android:textSize="18sp"
android:visibility="gone" />
</LinearLayout>
</RelativeLayout>
</ScrollView>

View File

@@ -8,12 +8,8 @@
<string name="hint_ip_address">请输入 IP 地址 (例如 192.168.1.100)</string>
<string name="btn_save">保存</string>
<string name="btn_back">返回主界面</string>
<string name="btn_show_logs">显示日志</string>
<string name="btn_hide_logs">隐藏日志</string>
<string name="msg_ip_saved">IP 已保存: %1$s</string>
<string name="msg_invalid_ip">请输入有效的 IP 地址</string>
<string name="log_init">应用启动完成。目标 IP: %1$s</string>
<string name="log_placeholder">日志将显示在这里...</string>
<string name="btn_random_expression">随机表情</string>
<string name="btn_speak">让机器人说话</string>
<string name="btn_restart_app">长按重启应用</string>