feat(设置页): 添加 Agent 过滤配置字段

- 在设置页添加 Agent ID 输入框,用于过滤流式播报消息
- 新增字符串资源 label_agent_filter 和 hint_agent_id
- 在 MQTT 管理器中处理 soul2user 主题消息,根据配置的 demp_id 进行过滤
- 更新 README 文档结构,将详细技术说明移至 technique.md 文件
This commit is contained in:
2026-03-16 10:58:19 +08:00
parent d8e875793d
commit 1e7a45f19c
7 changed files with 282 additions and 104 deletions

115
README.md
View File

@@ -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
- 方式 BAndroid 模拟器(仅 UI 预览)
- Temi 系统服务缺失Robot SDK 可能报错或不可用
---
## 2. 功能概览
- 主界面表情与状态反馈AnimatedEmojiView
- MQTT 控制机器人行为与 TTS 播报
- 接待模式:到达指定地点后检测到人出现确认按钮
- 巡逻模式:按指定或随机地点巡逻
- 特殊任务模式开关
- 设置页:网络 IP、当前位置、重启应用
---
## 3. 页面说明
### 主界面MainActivity
- 顶部设置按钮进入设置页
- 表情组件根据 TTS 和任务状态切换表情
- 接待模式触发时显示 “是的” 按钮,点击后前往目标地点
### 设置页SettingsActivity
- 网络 IP 配置:保存后作为 MQTT Broker 地址
- 当前位置下拉选择:来源于 Temi 已保存地点
- 特殊任务模式开关与指示灯
- 长按 3 秒重启应用
- 版本号显示(当前为硬编码字符串)
---
## 4. MQTT 指令协议
应用连接 `tcp://<IP>: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://<IP>: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
```

View File

@@ -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<TtsRequest, TtsRequest.Language>()
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) {

View File

@@ -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 {

View File

@@ -102,6 +102,22 @@
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.App.TextInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/hint_agent_id">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etAgentDempId"
android:layout_width="match_parent"
android:layout_height="72dp"
android:inputType="text"
android:textSize="18sp"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/btnSave"
style="@style/Widget.App.Button"

View File

@@ -93,6 +93,22 @@
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.App.TextInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/hint_agent_id">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etAgentDempId"
android:layout_width="match_parent"
android:layout_height="72dp"
android:inputType="text"
android:textSize="18sp"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/btnSave"
style="@style/Widget.App.Button"

View File

@@ -16,6 +16,8 @@
<string name="btn_restart_app">长按重启应用</string>
<string name="label_location_config">当前位置</string>
<string name="hint_current_location">请选择当前地点</string>
<string name="label_agent_filter">Agent 过滤</string>
<string name="hint_agent_id">请输入 demp_id留空接收全部</string>
<string name="location_unknown">未知</string>
<string name="label_livekit_config">LiveKit 配置</string>
<string name="hint_livekit_url">请输入 LiveKit 地址 (wss://your-host)</string>

160
technique.md Normal file
View File

@@ -0,0 +1,160 @@
# Temi 终端控制应用 — 技术文档
## 1. 项目概览
本项目为基于 Temi SDK 的 Android 应用,核心能力是通过 MQTT 指令驱动机器人导航、接待/巡逻与语音播报,并配合表情状态展示与 LiveKit 音视频连接能力。
主要入口与核心逻辑集中在:
- MainActivity主界面与机器人事件监听
- MqttManagerMQTT 连接与指令解析)
- NavController导航控制封装
## 2. 系统架构
### 2.1 分层结构
- **UI 层**
- MainActivity主界面、机器人事件监听、任务状态流转
- SettingsActivity配置页面IP、当前位置、特殊任务模式、LiveKit 参数)
- AnimatedEmojiView表情动画组件
- **业务层**
- MqttManagerMQTT 连接与指令协议处理、TTS 队列与流式播报
- NavController导航/巡逻/重定位封装
- **连接能力**
- LiveKitManagerLiveKit 房间连接与状态管理
### 2.2 核心模块职责
- **MainActivity**
- 监听 TTS、导航状态、人检测事件
- 驱动表情变化与任务状态
- 管理 MQTT / LiveKit 连接生命周期
- **MqttManager**
- MQTT 连接、订阅主题
- JSON 指令解析与动作下发
- 流式播报与 TTS 队列管理
- **NavController**
- 封装 goTo、patrol、repose、recharge 等指令
## 3. 运行与构建
### 3.1 环境要求
- Android Studio
- Gradle
- Temi SDK 依赖
- 真实 Temi 设备或 Android 模拟器(模拟器仅可预览 UI
### 3.2 构建与安装
```bash
.\gradlew.bat :app:installDebug
```
## 4. MQTT 通信协议
### 4.1 连接信息
- Broker`tcp://<IP>:1883`
- 订阅主题:
- `robot/cmd`:指令通道
- `soul2user`:流式播报通道
### 4.2 基础指令
| action | 说明 |
|---|---|
| recharge | 前往充电桩 |
| goto | 前往地点location / target |
| repose | 重新定位 |
| stop | 停止移动并暂停播报 |
| continue | 继续播报 |
| terminate | 停止移动并清空 TTS |
### 4.3 播报指令
| action | 说明 |
|---|---|
| speak | 立即播报text / speech可选 lang |
| stream | 流式播报text / content可选 lang |
### 4.4 任务指令
| action | 说明 |
|---|---|
| patrol | 巡逻flag=true 随机地点flag=false 使用 locations |
| reception | 接待location / text / destination |
### 4.5 示例
```json
{"action":"goto","location":"前台"}
```
```json
{"action":"speak","text":"欢迎光临","lang":"zh"}
```
```json
{"action":"patrol","flag":false,"locations":["前台","会议室","大厅"]}
```
## 5. 任务与导航逻辑
### 5.1 接待模式
- 接收到 reception 指令后进入接待任务
- 到达接待点后检测到人显示确认按钮并播报提示
- 点击确认后前往目的地并结束接待模式
### 5.2 巡逻模式
- 随机巡逻:从已有地点中随机抽取 3~6 个(排除 home base
- 指定巡逻:按 locations 列表执行
- 完成全部地点后自动结束巡逻任务
### 5.3 到达逻辑
- 到达地点后更新 current_location
- 若为巡逻任务则推进巡逻索引
- 特殊任务模式下可跳过“已到达”播报
## 6. 表情与语音联动
- **TTS STARTED**:表情变为 TALKING
- **TTS COMPLETED/CANCELED**
- 巡逻中显示 ANGRY
- 非巡逻显示 SMILE
- **TTS ERROR**:表情显示 SAD
- 闲置时自动眨眼动画
## 7. 配置项与持久化
### 7.1 SharedPreferences 关键字段
- `network_ip`MQTT Broker 地址
- `current_location`:当前位置
- `special_task_mode`:特殊任务模式开关
- `livekit_url / livekit_room / livekit_token / livekit_enabled`
- `agent_demp_id`stream 匹配字段
### 7.2 设置页功能
- MQTT IP 设置
- LiveKit 连接参数设置
- 特殊任务模式开关
- 当前位置选择与保存
- 长按 3 秒重启应用
## 8. 特殊任务模式
开启后会:
- 跳过 Home Base 的开门/关门语音逻辑
- 跳过检测到人时的问候语
- 到达地点时不播报“已到达”(无任务状态下)
## 9. LiveKit 连接
- 默认 URL 会根据 IP 自动拼接 `ws://<IP>:7880`
- 若未配置 token则在本地使用默认 key/secret 生成临时 token
- 连接状态与 MQTT 状态共同驱动主界面状态指示灯
## 10. 权限要求
- Temi 权限MAP / SEQUENCE / FACE_RECOGNITION / SETTINGS
- Android 权限RECORD_AUDIO / CAMERALiveKit
## 11. 依赖说明
- Temi SDK`com.robotemi:sdk:1.137.1`
- MQTT`org.eclipse.paho:org.eclipse.paho.android.service:1.1.1`
- LiveKit`io.livekit:android`
- AndroidX / Material 等基础依赖
## 12. 安全注意事项
- MQTT 用户名/密码在代码内配置
- LiveKit 默认 key/secret 也在代码内生成 token
- 建议正式环境将敏感信息迁移至安全配置源