From 1e7a45f19c2babdb723b50863fe21c00da4da152 Mon Sep 17 00:00:00 2001 From: Sucan <632190820@qq.com> Date: Mon, 16 Mar 2026 10:58:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=AE=BE=E7=BD=AE=E9=A1=B5):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20Agent=20=E8=BF=87=E6=BB=A4=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在设置页添加 Agent ID 输入框,用于过滤流式播报消息 - 新增字符串资源 label_agent_filter 和 hint_agent_id - 在 MQTT 管理器中处理 soul2user 主题消息,根据配置的 demp_id 进行过滤 - 更新 README 文档结构,将详细技术说明移至 technique.md 文件 --- README.md | 115 ++----------- .../lzwcai_terminal_temi/MqttManager.kt | 68 +++++++- .../lzwcai_terminal_temi/SettingsActivity.kt | 9 +- .../res/layout-land/activity_settings.xml | 16 ++ app/src/main/res/layout/activity_settings.xml | 16 ++ app/src/main/res/values/strings.xml | 2 + technique.md | 160 ++++++++++++++++++ 7 files changed, 282 insertions(+), 104 deletions(-) create mode 100644 technique.md diff --git a/README.md b/README.md index e407be4..d111e07 100644 --- a/README.md +++ b/README.md @@ -1,114 +1,25 @@ # Temi 终端控制应用 -本项目是基于 Temi SDK 的 Android 应用,提供 MQTT 指令控制、导航/巡逻/接待流程,以及设置页管理网络 IP、当前位置与特殊任务模式。 +基于 Temi SDK 的 Android 应用,通过 MQTT 指令控制机器人导航、接待、巡逻与语音播报。 -## 1. 运行环境 +## 功能 +- MQTT 指令控制导航与播报 +- 接待与巡逻模式 +- 设置页:网络 IP、当前位置、特殊任务模式 -### 推荐工具:Android Studio -应用依赖 Gradle 与 Android 运行环境,无法通过浏览器直接预览。请使用 Android Studio 导入项目并运行到 Temi 机器人或模拟器。 +## 运行 +- 推荐使用 Android Studio 连接 Temi 设备运行 +- 模拟器仅用于 UI 预览 -### 运行方式 -- 方式 A:连接 Temi 机器人(推荐) - - 确保 Temi 已开启开发者模式 - - USB 连接或 ADB over Wi-Fi - - Android Studio 选择设备后 Run -- 方式 B:Android 模拟器(仅 UI 预览) - - Temi 系统服务缺失,Robot SDK 可能报错或不可用 - ---- - -## 2. 功能概览 - -- 主界面表情与状态反馈(AnimatedEmojiView) -- MQTT 控制机器人行为与 TTS 播报 -- 接待模式:到达指定地点后检测到人出现确认按钮 -- 巡逻模式:按指定或随机地点巡逻 -- 特殊任务模式开关 -- 设置页:网络 IP、当前位置、重启应用 - ---- - -## 3. 页面说明 - -### 主界面(MainActivity) -- 顶部设置按钮进入设置页 -- 表情组件根据 TTS 和任务状态切换表情 -- 接待模式触发时显示 “是的” 按钮,点击后前往目标地点 - -### 设置页(SettingsActivity) -- 网络 IP 配置:保存后作为 MQTT Broker 地址 -- 当前位置下拉选择:来源于 Temi 已保存地点 -- 特殊任务模式开关与指示灯 -- 长按 3 秒重启应用 -- 版本号显示(当前为硬编码字符串) - ---- - -## 4. MQTT 指令协议 - -应用连接 `tcp://:1883`,订阅主题 `robot/cmd`,仅处理 JSON payload。 - -### 基础指令 -- `recharge`:回充电桩 -- `goto`:前往地点(字段 `location` 或 `target`) -- `repose`:重新定位 -- `stop`:停止移动并暂停 TTS(保留队列与 stream buffer) -- `continue`:继续播报(优先重播被中断语句) -- `terminate`:停止移动并清空所有 TTS 队列与 buffer - -### 播报指令 -- `speak`:立即播报(字段 `text` 或 `speech`,可选 `lang`) -- `stream`:流式播报(字段 `text` 或 `content`,可选 `lang`) - - 按 `。!?!?\n` 分句进入队列 - -### 任务指令 -- `patrol`:巡逻 - - `flag=true` 随机抽取 3~6 个地点(排除 home base) - - `flag=false` + `locations` 指定地点列表 -- `reception`:接待 - - `location` 接待地点(默认 “前台”) - - `text` 询问语(默认 “你是我要接待的贵宾吗?”) - - `destination` 目的地(默认 “会议室”) - -### 示例 +## MQTT +- Broker:`tcp://:1883` +- 主题:`robot/cmd` +- 示例: ```json {"action":"goto","location":"前台"} ``` -```json -{"action":"speak","text":"欢迎光临","lang":"zh"} -``` - -```json -{"action":"patrol","flag":false,"locations":["前台","会议室","大厅"]} -``` - ---- - -## 5. 特殊任务模式 - -设置页开关 `special_task_mode` 控制以下行为: -- 跳过 Home Base 的开门/关门语音逻辑 -- 跳过检测到人时的问候语 -- 机器人到达地点时,不播报“已到达”提示(仅在当前无任务时生效) - ---- - -## 6. 项目结构 - -- `MainActivity.kt`:主逻辑与事件监听 -- `SettingsActivity.kt`:设置页逻辑 -- `MqttManager.kt`:MQTT 连接与指令解析 -- `NavController.kt`:导航与巡逻封装 -- `AnimatedEmojiView.kt`:表情动画绘制 -- `LogManager.kt`:Logcat 监听分发 -- `HttpManager.kt`:HTTP Workflow 请求封装(目前未在流程中启用) -- `activity_main.xml` / `activity_settings.xml`:主界面与设置页布局 - ---- - -## 7. 本地构建与安装 - +## 构建 ```bash .\gradlew.bat :app:installDebug ``` diff --git a/app/src/main/java/com/example/lzwcai_terminal_temi/MqttManager.kt b/app/src/main/java/com/example/lzwcai_terminal_temi/MqttManager.kt index f5e5b77..487e548 100644 --- a/app/src/main/java/com/example/lzwcai_terminal_temi/MqttManager.kt +++ b/app/src/main/java/com/example/lzwcai_terminal_temi/MqttManager.kt @@ -28,6 +28,8 @@ class MqttManager( private val job = SupervisorJob() private val scope = CoroutineScope(Dispatchers.IO + job) private var reconnectJob: Job? = null + private val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE) + private val agentDempIdKey = "agent_demp_id" // Streaming text buffer private val speechBuffer = StringBuilder() @@ -42,6 +44,8 @@ class MqttManager( private var interruptedLanguage: TtsRequest.Language? = null private var lastStreamLangCode: String? = null private val ttsLanguageMap = mutableMapOf() + private var currentStreamSessionId: String? = null + private var currentStreamMessageId: String? = null init { try { @@ -50,6 +54,7 @@ class MqttManager( override fun connectComplete(reconnect: Boolean, serverURI: String?) { Log.i(TAG, "MQTT connection complete. Reconnect: $reconnect") subscribeTopic("robot/cmd") + subscribeTopic("soul2user") updateConnectionStatus(true) } @@ -192,7 +197,11 @@ class MqttManager( } try { val obj = JSONObject(trimmed) - handleJsonCommand(obj) + if (topic == "soul2user") { + handleSoulStream(obj) + } else { + handleJsonCommand(obj) + } } catch (e: Exception) { Log.e(TAG, "Invalid JSON payload: $payload", e) val ttsRequest = TtsRequest.create("指令格式错误,请检查指令格式", false, language = TtsRequest.Language.ZH_CN) @@ -200,6 +209,63 @@ class MqttManager( } } + private fun handleSoulStream(obj: JSONObject) { + val configuredDempId = prefs.getString(agentDempIdKey, "").orEmpty().trim() + val dempId = obj.optString("demp_id", "").trim() + if (configuredDempId.isNotEmpty() && configuredDempId != dempId) { + Log.i(TAG, "Stream ignored: demp_id mismatch ($dempId)") + return + } + val sessionId = obj.optString("session_id", "").trim() + val messageId = obj.optString("message_id", "").trim() + ensureStreamContext(sessionId, messageId) + val text = obj.optString("text", "") + val isFinal = obj.optBoolean("is_final", false) + val lang = obj.optString("lang", "").trim() + scope.launch(Dispatchers.Main) { + (context as? MainActivity)?.markSpeechTaskActive() + } + processStreamText(text, lang) + if (isFinal) { + flushStreamRemainder(lang) + currentStreamSessionId = null + currentStreamMessageId = null + } + } + + private fun ensureStreamContext(sessionId: String, messageId: String) { + if (sessionId.isEmpty() && messageId.isEmpty()) { + return + } + val sessionChanged = currentStreamSessionId != null && sessionId.isNotEmpty() && sessionId != currentStreamSessionId + val messageChanged = currentStreamMessageId != null && messageId.isNotEmpty() && messageId != currentStreamMessageId + if (currentStreamSessionId == null && currentStreamMessageId == null) { + currentStreamSessionId = sessionId.ifEmpty { null } + currentStreamMessageId = messageId.ifEmpty { null } + return + } + if (sessionChanged || messageChanged) { + stopTts() + currentStreamSessionId = sessionId.ifEmpty { null } + currentStreamMessageId = messageId.ifEmpty { null } + return + } + if (currentStreamSessionId == null && sessionId.isNotEmpty()) { + currentStreamSessionId = sessionId + } + if (currentStreamMessageId == null && messageId.isNotEmpty()) { + currentStreamMessageId = messageId + } + } + + private fun flushStreamRemainder(langCode: String?) { + val remaining = speechBuffer.toString().trim() + if (remaining.isNotEmpty()) { + speechBuffer.setLength(0) + speak(remaining, langCode) + } + } + private fun handleJsonCommand(obj: JSONObject) { val action = obj.optString("action", obj.optString("cmd", obj.optString("type", ""))).lowercase() when (action) { diff --git a/app/src/main/java/com/example/lzwcai_terminal_temi/SettingsActivity.kt b/app/src/main/java/com/example/lzwcai_terminal_temi/SettingsActivity.kt index 1ed54d6..46ae0b5 100644 --- a/app/src/main/java/com/example/lzwcai_terminal_temi/SettingsActivity.kt +++ b/app/src/main/java/com/example/lzwcai_terminal_temi/SettingsActivity.kt @@ -33,6 +33,7 @@ class SettingsActivity : AppCompatActivity() { private val liveKitRoomKey = "livekit_room" private val liveKitTokenKey = "livekit_token" private val liveKitEnabledKey = "livekit_enabled" + private val agentDempIdKey = "agent_demp_id" private val liveKitUrlDefault = "ws://192.168.2.236:7880" private val liveKitRoomDefault = "temi-room" @@ -47,6 +48,8 @@ class SettingsActivity : AppCompatActivity() { prefs = getSharedPreferences("app_prefs", Context.MODE_PRIVATE) val savedIp = prefs.getString("network_ip", "") binding.etIpAddress.setText(savedIp) + val savedDempId = prefs.getString(agentDempIdKey, "") + binding.etAgentDempId.setText(savedDempId) val savedLiveKitUrl = prefs.getString(liveKitUrlKey, resolveLiveKitUrl()) val savedLiveKitRoom = prefs.getString(liveKitRoomKey, liveKitRoomDefault) val savedLiveKitToken = prefs.getString(liveKitTokenKey, "") @@ -66,8 +69,12 @@ class SettingsActivity : AppCompatActivity() { binding.btnSave.setOnClickListener { hideKeyboard() val ip = binding.etIpAddress.text.toString().trim() + val dempId = binding.etAgentDempId.text?.toString()?.trim().orEmpty() if (ip.isNotEmpty()) { - prefs.edit().putString("network_ip", ip).apply() + prefs.edit() + .putString("network_ip", ip) + .putString(agentDempIdKey, dempId) + .apply() Toast.makeText(this, getString(R.string.msg_ip_saved, ip), Toast.LENGTH_SHORT).show() finish() } else { diff --git a/app/src/main/res/layout-land/activity_settings.xml b/app/src/main/res/layout-land/activity_settings.xml index 4ca7bf2..fa71e09 100644 --- a/app/src/main/res/layout-land/activity_settings.xml +++ b/app/src/main/res/layout-land/activity_settings.xml @@ -102,6 +102,22 @@ android:textColor="@color/text_primary" /> + + + + +