Compare commits

...

11 Commits

Author SHA1 Message Date
74f4c01603 docs: 添加项目交接文档
详细说明项目定位、架构、配置、关键业务流程和排障指南,便于后续维护人员快速上手。
2026-05-08 17:15:56 +08:00
663768ce17 refactor: 提取任务策略、自动充电和连接服务到独立类
将 MainActivity 中的任务策略逻辑、自动充电调度和连接管理代码提取到独立的类中,以提高代码的可维护性和可测试性。具体包括:
- 创建 MainTaskPolicy 对象封装任务类型定义和行为决策逻辑
- 创建 AutoRechargeScheduler 类处理空闲到达后的自动充电调度
- 创建 WorkflowService 类管理门控和工作流执行
- 创建 ConnectionService 类统一管理 MQTT 和 LiveKit 连接
- 重命名 ConnectionCoordinator 为 ConnectionService 以更准确反映其职责
2026-04-24 19:41:34 +08:00
c898345919 refactor: 移除未使用的初始状态变量并简化恢复逻辑
- 删除未使用的 `initialTask` 等成员变量
- 直接从 savedInstanceState 恢复状态并立即处理特殊值
- 简化 `shouldAutoRechargeAfterIdleArrival` 方法,直接使用行为决策
- 移除特殊状态下已跳过的到达播报中的冗余充电调度调用
2026-04-24 19:13:55 +08:00
fb9dba913d refactor: 重构行为决策逻辑以集中控制特殊状态行为
将特殊状态检查和行为决策集中到 `resolveBehaviorDecision` 方法中
- 新增 `BehaviorDecision` 数据类封装行为决策
- 在到达、检测状态变化等场景中使用统一决策逻辑
- 修复特殊状态下自动充电未正确取消的问题
2026-04-24 19:11:06 +08:00
b5b5d0ad5b fix: 修正自动充电任务检查逻辑
将自动充电的条件从检查任务是否为空改为检查任务是否允许充电
新增 `isAutoRechargeAllowedTask()` 方法,允许 "speech" 任务时也能触发自动充电
2026-04-24 18:19:39 +08:00
c1e9ee8d34 fix: 在空闲检测逻辑中添加TTS状态检查
防止机器人在语音播报时触发空闲问候,避免语音重叠
2026-04-23 15:30:42 +08:00
f89bce552a fix: 修复接待任务位置比较逻辑并硬编码默认位置
移除从 MQTT 消息动态获取接待位置,改为硬编码为"前台"。引入位置字符串规范化函数,用于统一比较接待任务中的位置信息,避免因空格、大小写或特殊字符导致的匹配失败。同时调整问候语触发逻辑,在接待任务期间即使未到达指定位置也禁用默认问候。
2026-04-21 13:15:54 +08:00
54b762abbf feat: 添加接待返回工作流支持
- 新增 VR 工作流配置键常量,用于存储接待返回工作流的 ID 和 API 密钥
- 在设置页面同步添加 VR 工作流的清除、获取与保存逻辑
- 重构门控工作流执行函数,使其通用化以支持接待返回工作流
- 当接待任务完成并返回基站时,自动触发接待返回工作流执行
2026-04-21 12:50:02 +08:00
d5ca5966f4 fix: 优化接待任务逻辑并修复门控工作流配置刷新
- 减少重置当前任务的动作集合,仅对显式导航/终止命令中断任务
- 修复接待任务中空位置导致任务异常的问题,增加参数校验与默认值处理
- 优化接待任务中位置匹配逻辑,忽略前后空格并修正状态处理流程
- 修复门控工作流执行时配置缺失的问题,添加服务器配置刷新机制
2026-04-21 11:11:15 +08:00
7d28490cec fix: 将按钮文本从"是的,请带我去"改为"带我去接待地点"
更新两个布局文件中的按钮文本,使其表述更简洁直接,符合用户引导意图。
2026-04-21 10:29:14 +08:00
6a3431d741 fix(MqttManager): 防止MQTT客户端重复连接
添加 `isConnecting` 标志位以跟踪连接状态,避免在连接过程中发起新的连接请求。
在连接失败时安全重置客户端,并在连接完成或丢失时正确更新状态标志。
2026-04-20 19:51:36 +08:00
12 changed files with 889 additions and 132 deletions

View File

@@ -0,0 +1,54 @@
package com.example.lzwcai_terminal_temi
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class AutoRechargeScheduler(
private val scope: CoroutineScope,
private val navController: NavController,
private val getCurrentTask: () -> String,
private val isSpecialStateEnabled: () -> Boolean,
private val getLastArrivalLocation: () -> String?,
private val normalizeLocation: (String?) -> String
) {
private var autoRechargeJob: Job? = null
fun scheduleAfterIdleArrival() {
if (!isAutoRechargeAllowedTask()) {
return
}
if (normalizeLocation(getLastArrivalLocation()) == "homebase") {
return
}
autoRechargeJob?.cancel()
autoRechargeJob = scope.launch {
delay(10_000L)
if (!isAutoRechargeAllowedTask()) {
return@launch
}
if (normalizeLocation(getLastArrivalLocation()) == "homebase") {
return@launch
}
Log.i("MainActivity", "Auto recharge triggered after idle arrival timeout.")
navController.recharge()
}
}
fun cancel(reason: String) {
if (autoRechargeJob?.isActive == true) {
Log.i("MainActivity", "Auto recharge canceled: $reason")
}
autoRechargeJob?.cancel()
autoRechargeJob = null
}
private fun isAutoRechargeAllowedTask(): Boolean {
if (isSpecialStateEnabled()) {
return false
}
return MainTaskPolicy.isIdleTask(getCurrentTask())
}
}

View File

@@ -6,7 +6,7 @@ import android.util.Log
import com.robotemi.sdk.Robot
import java.net.URL
class ConnectionCoordinator(
class ConnectionService(
private val context: Context,
private val prefs: SharedPreferences,
private val robot: Robot,
@@ -64,7 +64,7 @@ class ConnectionCoordinator(
if (host.isNullOrEmpty()) {
mqttManager = null
mqttStatusListener(false)
Log.w("ConnectionCoordinator", "MQTT disabled: base_url is invalid or not set.")
Log.w("ConnectionService", "MQTT disabled: base_url is invalid or not set.")
return
}
mqttManager = MqttManager(
@@ -82,7 +82,7 @@ class ConnectionCoordinator(
onPublishStatusSnapshot = onPublishStatusSnapshot
)
mqttManager?.connect()
Log.i("ConnectionCoordinator", "MQTT updated with host=$host")
Log.i("ConnectionService", "MQTT updated with host=$host")
}
fun updateLiveKitConnection(isActivated: Boolean) {

View File

@@ -25,6 +25,8 @@ object HttpManager {
const val PREF_KEY_OD_WF_KEY = "od_wf_key"
const val PREF_KEY_CD_WFID = "cd_wfid"
const val PREF_KEY_CD_WF_KEY = "cd_wf_key"
const val PREF_KEY_VR_WFID = "vr_wfid"
const val PREF_KEY_VR_WF_KEY = "vr_wf_key"
fun getBaseUrl(context: Context): String {
val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
@@ -98,6 +100,8 @@ object HttpManager {
.put(PREF_KEY_OD_WF_KEY)
.put(PREF_KEY_CD_WFID)
.put(PREF_KEY_CD_WF_KEY)
.put(PREF_KEY_VR_WFID)
.put(PREF_KEY_VR_WF_KEY)
.put(PREF_KEY_MQTT_PASSWORD)
val response = postJsonArray(context, "/system/config/getConfig", body, token)
if (response == null) {

View File

@@ -52,11 +52,20 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
OnDetectionStateChangedListener, OnReposeStatusChangedListener, SharedPreferences.OnSharedPreferenceChangeListener,
OnRequestPermissionResultListener, OnBatteryStatusChangedListener, OnMovementStatusChangedListener,
OnCurrentPositionChangedListener {
companion object {
private const val STATE_KEY_CURRENT_TASK = "currentTask"
private const val STATE_KEY_RECEPTION_LOCATION = "receptionLocation"
private const val STATE_KEY_RECEPTION_TEXT = "receptionText"
private const val STATE_KEY_RECEPTION_DESTINATION = "receptionDestination"
private const val STATE_KEY_NOTIFICATION_LOCATION = "notificationLocation"
private const val STATE_KEY_NOTIFICATION_TEXT = "notificationText"
private const val STATE_KEY_LAST_ARRIVAL_LOCATION = "lastArrivalLocation"
}
private lateinit var robot: Robot
private lateinit var binding: ActivityMainBinding
private lateinit var uiState: UiState
private lateinit var connectionCoordinator: ConnectionCoordinator
private lateinit var connectionService: ConnectionService
private val mainScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private lateinit var prefs: SharedPreferences
private val specialStateKey = "special_state"
@@ -79,14 +88,6 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
private var lastArrivalLocation: String? = null
private var lastArrivalAt: Long = 0L
private var initialTask: String = ""
private var initialReceptionLocation: String = ""
private var initialReceptionText: String = ""
private var initialReceptionDestination: String = ""
private var initialNotificationLocation: String = ""
private var initialNotificationText: String = ""
private var closeDoorJob: Job? = null
private var detectConfirmJob: Job? = null
private var idleConfirmJob: Job? = null
@@ -96,9 +97,11 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
private val idleConfirmDelayMs = 5000L
private var blinkJob: Job? = null
private var networkErrorJob: Job? = null
private var autoRechargeJob: Job? = null
private var latestYaw: Float? = null
private var receptionAnchorYaw: Float? = null
private var isTtsSpeaking: Boolean = false
private lateinit var autoRechargeScheduler: AutoRechargeScheduler
private lateinit var workflowService: WorkflowService
private lateinit var telemetryManager: TelemetryManager
private lateinit var taskController: TaskController
private val robotEventHandler = RobotEventHandler()
@@ -129,17 +132,15 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
navCon = NavController(robot)
permissionManager = PermissionManager(robot)
val restoredTask = (savedInstanceState?.getString(STATE_KEY_CURRENT_TASK, "") ?: "")
.let { if (it == MainTaskPolicy.LEGACY_TASK_SPECIAL) "" else it }
val restoredReceptionLocation = savedInstanceState?.getString(STATE_KEY_RECEPTION_LOCATION, "") ?: ""
val restoredReceptionText = savedInstanceState?.getString(STATE_KEY_RECEPTION_TEXT, "") ?: ""
val restoredReceptionDestination = savedInstanceState?.getString(STATE_KEY_RECEPTION_DESTINATION, "") ?: ""
val restoredNotificationLocation = savedInstanceState?.getString(STATE_KEY_NOTIFICATION_LOCATION, "") ?: ""
val restoredNotificationText = savedInstanceState?.getString(STATE_KEY_NOTIFICATION_TEXT, "") ?: ""
if (savedInstanceState != null) {
initialTask = savedInstanceState.getString("currentTask", "") ?: ""
initialReceptionLocation = savedInstanceState.getString("receptionLocation", "") ?: ""
initialReceptionText = savedInstanceState.getString("receptionText", "") ?: ""
initialReceptionDestination = savedInstanceState.getString("receptionDestination", "") ?: ""
initialNotificationLocation = savedInstanceState.getString("notificationLocation", "") ?: ""
initialNotificationText = savedInstanceState.getString("notificationText", "") ?: ""
lastArrivalLocation = savedInstanceState.getString("lastArrivalLocation")
}
if (initialTask == "special") {
initialTask = ""
lastArrivalLocation = savedInstanceState.getString(STATE_KEY_LAST_ARRIVAL_LOCATION)
}
prefs = getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
@@ -164,7 +165,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
clearTaskLauncher.launch(Intent(this, SettingsActivity::class.java))
}
connectionCoordinator = ConnectionCoordinator(
connectionService = ConnectionService(
context = this,
prefs = prefs,
robot = robot,
@@ -218,12 +219,27 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
}
)
taskController.restoreState(
initialTask,
initialReceptionLocation,
initialReceptionText,
initialReceptionDestination,
initialNotificationLocation,
initialNotificationText
restoredTask,
restoredReceptionLocation,
restoredReceptionText,
restoredReceptionDestination,
restoredNotificationLocation,
restoredNotificationText
)
autoRechargeScheduler = AutoRechargeScheduler(
scope = mainScope,
navController = navCon,
getCurrentTask = { taskController.currentTask },
isSpecialStateEnabled = { isSpecialStateEnabled() },
getLastArrivalLocation = { lastArrivalLocation },
normalizeLocation = { value -> robotEventHandler.normalizeLocation(value) }
)
workflowService = WorkflowService(
context = this@MainActivity,
prefs = prefs,
scope = mainScope,
normalizeLocation = { value -> robotEventHandler.normalizeLocation(value) },
onWorkflowFailed = { showNetworkErrorBanner() }
)
binding.btnReception.setOnClickListener {
@@ -231,6 +247,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
if (destination.isNullOrBlank()) {
return@setOnClickListener
}
workflowService.markReceptionReturnPending()
val ttsRequest = TtsRequest.create("接待任务确认,请跟我来", false, language = TtsRequest.Language.ZH_CN)
robot.speak(ttsRequest)
navCon.goTo(destination, false)
@@ -242,7 +259,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
locationProvider = { lastArrivalLocation },
mqttConnectedProvider = { isMqttConnected },
liveKitConnectedProvider = { isLiveKitConnected },
publish = { topic, payload -> connectionCoordinator.publish(topic, payload) },
publish = { topic, payload -> connectionService.publish(topic, payload) },
onLowBattery = { _ ->
val ttsRequest = TtsRequest.create("电量低,请及时充电。", false, language = TtsRequest.Language.ZH_CN)
robot.speak(ttsRequest)
@@ -268,8 +285,8 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
robot.constraintBeWith()
updateActivationBanner()
if (!isActivated()) {
connectionCoordinator.disconnectMqtt()
connectionCoordinator.disconnectLiveKit()
connectionService.disconnectMqtt()
connectionService.disconnectLiveKit()
setMqttConnectionStatus(false)
setLiveKitStatus(false)
stopBlinking()
@@ -295,7 +312,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
robot.removeOnCurrentPositionChangedListener(this)
robot.removeOnRequestPermissionResultListener(this)
// Keep MQTT alive in background/settings
connectionCoordinator.disconnectLiveKit()
connectionService.disconnectLiveKit()
stopBlinking()
telemetryManager.stop()
}
@@ -303,7 +320,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
override fun onDestroy() {
super.onDestroy()
prefs.unregisterOnSharedPreferenceChangeListener(this)
connectionCoordinator.release()
connectionService.release()
LogManager.stopLogcatListener()
mainScope.cancel()
Log.i("MainActivity", "All resources released on destroy.")
@@ -331,9 +348,10 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
}
override fun onTtsStatusChanged(ttsRequest: TtsRequest) {
connectionCoordinator.handleTtsStatusChange(ttsRequest)
connectionService.handleTtsStatusChange(ttsRequest)
when (ttsRequest.status) {
TtsRequest.Status.STARTED -> {
isTtsSpeaking = true
Log.i("MainActivity", "TTS started")
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.TALKING
liveKitManager?.setTtsMute(true)
@@ -341,8 +359,9 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
TtsRequest.Status.COMPLETED,
TtsRequest.Status.CANCELED,
TtsRequest.Status.NOT_ALLOWED -> {
isTtsSpeaking = false
Log.i("MainActivity", "TTS finished: ${ttsRequest.speech}")
if (taskController.currentTask == "patrol") {
if (MainTaskPolicy.isTask(taskController.currentTask, MainTaskPolicy.TASK_PATROL)) {
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.ANGRY
} else {
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.SMILE
@@ -350,6 +369,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
liveKitManager?.setTtsMute(false)
}
TtsRequest.Status.ERROR -> {
isTtsSpeaking = false
Log.e("MainActivity", "TTS error: ${ttsRequest.speech}")
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.SAD
liveKitManager?.setTtsMute(false)
@@ -380,14 +400,14 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
val isAbort = robotEventHandler.isAbortStatus(status)
val isMoving = robotEventHandler.isMovingStatus(status)
if (isMoving) {
cancelAutoRecharge("movement_started:$location/$status")
autoRechargeScheduler.cancel("movement_started:$location/$status")
taskController.cancelTaskWaitTimeout()
}
if (normalized != "complete" && !isAbort) {
return
}
if (isAbort) {
cancelAutoRecharge("movement_aborted:$location/$status")
autoRechargeScheduler.cancel("movement_aborted:$location/$status")
taskController.cancelTaskWaitTimeout()
taskController.clearLeavingHomeBase()
taskController.endNonSpecialTask("goTo aborted: $location, status=$status")
@@ -403,23 +423,26 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
prefs.edit().putString("current_location", location).apply()
if (robotEventHandler.normalizeLocation(location) == "homebase") {
navCon.tiltAngle(20)
workflowService.triggerReceptionReturnWorkflowIfNeeded(location)
}
if (taskController.currentTask == "patrol") {
if (MainTaskPolicy.isTask(taskController.currentTask, MainTaskPolicy.TASK_PATROL)) {
taskController.handlePatrolArrival(location)
}
if (taskController.currentTask == "reception" &&
location.equals(taskController.getReceptionLocation(), ignoreCase = true)
if (MainTaskPolicy.isTask(taskController.currentTask, MainTaskPolicy.TASK_RECEPTION) &&
robotEventHandler.normalizeLocation(location) ==
robotEventHandler.normalizeLocation(taskController.getReceptionLocation())
) {
captureReceptionAnchorYawIfNeeded()
taskController.startTaskWaitTimeout()
}
if (taskController.currentTask != "reception") {
if (!MainTaskPolicy.isTask(taskController.currentTask, MainTaskPolicy.TASK_RECEPTION)) {
receptionAnchorYaw = null
}
if (taskController.handleNotificationArrival(location)) {
return
}
if (isSpecialStateEnabled() && taskController.currentTask.isEmpty()) {
val behavior = MainTaskPolicy.resolveBehaviorDecision(taskController.currentTask, isSpecialStateEnabled())
if (behavior.skipArrivalAnnouncement) {
Log.i("MainActivity", "Special state: arrival announcement skipped at $location.")
return
}
@@ -427,7 +450,9 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
val ttsRequest = TtsRequest.create(text, false, language = TtsRequest.Language.ZH_CN)
robot.speak(ttsRequest)
Log.i("MainActivity", "Arrived at $location, announcement sent.")
scheduleAutoRechargeAfterIdleArrival()
if (behavior.allowAutoRecharge) {
autoRechargeScheduler.scheduleAfterIdleArrival()
}
}
override fun onDetectionStateChanged(state: Int) {
@@ -467,7 +492,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
private fun handleStableDetectionStateChanged(state: Int) {
Log.i("MainActivity", "Stable detection state: $state")
liveKitManager?.setDetectionActive(state == DETECTED)
if (taskController.currentTask == "reception") {
if (MainTaskPolicy.isTask(taskController.currentTask, MainTaskPolicy.TASK_RECEPTION)) {
if (state == DETECTED) {
captureReceptionAnchorYawIfNeeded()
} else if (state == IDLE) {
@@ -478,10 +503,9 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
Log.i("MainActivity", "Detection event handled by task controller.")
return
}
val isSpecialState = isSpecialStateEnabled()
val isIdleTask = taskController.currentTask.isEmpty() || taskController.currentTask == "speech"
val behavior = MainTaskPolicy.resolveBehaviorDecision(taskController.currentTask, isSpecialStateEnabled())
val atHomeBase = robotEventHandler.normalizeLocation(lastArrivalLocation) == "homebase"
val canHandleDoor = isIdleTask && atHomeBase && !taskController.isLeavingHomeBase && !isSpecialState
val canHandleDoor = behavior.allowDoorWorkflow && atHomeBase && !taskController.isLeavingHomeBase
if (canHandleDoor) {
when (state) {
DETECTED -> {
@@ -490,7 +514,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
val ttsRequest = TtsRequest.create("正在为你开门,请稍等。", false, language = TtsRequest.Language.ZH_CN)
robot.speak(ttsRequest)
mainScope.launch {
val result = executeDoorWorkflow(openDoor = true)
val result = workflowService.executeDoorWorkflow(openDoor = true)
if (result == null) {
showNetworkErrorBanner()
}
@@ -501,7 +525,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
val ttsRequest = TtsRequest.create("正准备关门,请小心被夹。", false, language = TtsRequest.Language.ZH_CN)
robot.speak(ttsRequest)
mainScope.launch {
val result = executeDoorWorkflow(openDoor = false)
val result = workflowService.executeDoorWorkflow(openDoor = false)
if (result == null) {
showNetworkErrorBanner()
}
@@ -510,7 +534,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
}
}
}
if (isIdleTask && !atHomeBase && state == DETECTED && !isSpecialState) {
if (behavior.allowIdleGreeting && !atHomeBase && state == DETECTED && !isTtsSpeaking) {
val hour = java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY)
val greeting = when (hour) {
in 6..11 -> "早上好"
@@ -537,9 +561,9 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
fun setCurrentTask(task: String) {
if (task.trim().isNotEmpty()) {
cancelAutoRecharge("task_set:$task")
autoRechargeScheduler.cancel("task_set:$task")
}
if (!task.equals("reception", ignoreCase = true)) {
if (!MainTaskPolicy.isTask(task, MainTaskPolicy.TASK_RECEPTION)) {
receptionAnchorYaw = null
}
taskController.setCurrentTask(task)
@@ -574,6 +598,9 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
if (key == specialStateKey) {
val isSpecial = isSpecialStateEnabled()
Log.i("MainActivity", "Special state pref changed: $isSpecial, currentTask: ${taskController.currentTask}")
if (isSpecial) {
autoRechargeScheduler.cancel("special_state_enabled")
}
}
if (key == LiveKitManager.PREF_KEY_URL ||
key == LiveKitManager.PREF_KEY_ROOM ||
@@ -592,11 +619,11 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
}
private fun updateMqttConnection() {
connectionCoordinator.updateMqttConnection(isActivated())
connectionService.updateMqttConnection(isActivated())
}
private fun updateLiveKitConnection() {
connectionCoordinator.updateLiveKitConnection(isActivated())
connectionService.updateLiveKitConnection(isActivated())
}
private fun hasAudioPermission(): Boolean {
@@ -679,7 +706,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
}
private fun updateLiveKitStatusSnapshot() {
connectionCoordinator.updateLiveKitStatusSnapshot(isActivated())
connectionService.updateLiveKitStatusSnapshot(isActivated())
}
private fun setLiveKitStatus(connected: Boolean) {
@@ -706,7 +733,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
.put("topic", topicLabel)
.put("participant", participantLabel)
.put("ts", System.currentTimeMillis())
connectionCoordinator.publish("robot/asr", data.toString())
connectionService.publish("robot/asr", data.toString())
}
private fun extractAsrText(payload: String): String? {
@@ -736,35 +763,6 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
}
}
private fun scheduleAutoRechargeAfterIdleArrival() {
if (taskController.currentTask.isNotEmpty()) {
return
}
if (robotEventHandler.normalizeLocation(lastArrivalLocation) == "homebase") {
return
}
autoRechargeJob?.cancel()
autoRechargeJob = mainScope.launch {
delay(10_000L)
if (taskController.currentTask.isNotEmpty()) {
return@launch
}
if (robotEventHandler.normalizeLocation(lastArrivalLocation) == "homebase") {
return@launch
}
Log.i("MainActivity", "Auto recharge triggered after idle arrival timeout.")
navCon.recharge()
}
}
private fun cancelAutoRecharge(reason: String) {
if (autoRechargeJob?.isActive == true) {
Log.i("MainActivity", "Auto recharge canceled: $reason")
}
autoRechargeJob?.cancel()
autoRechargeJob = null
}
private fun captureReceptionAnchorYawIfNeeded() {
if (receptionAnchorYaw != null) {
return
@@ -775,10 +773,12 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
}
private fun recoverReceptionFacingDirection() {
if (taskController.currentTask != "reception") {
if (!MainTaskPolicy.isTask(taskController.currentTask, MainTaskPolicy.TASK_RECEPTION)) {
return
}
val atReceptionLocation = lastArrivalLocation?.equals(taskController.getReceptionLocation(), ignoreCase = true) == true
val atReceptionLocation =
robotEventHandler.normalizeLocation(lastArrivalLocation) ==
robotEventHandler.normalizeLocation(taskController.getReceptionLocation())
if (!atReceptionLocation) {
return
}
@@ -797,23 +797,6 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
Log.i("MainActivity", "Reception facing recovered by $turn degrees (delta=$delta).")
}
private suspend fun executeDoorWorkflow(openDoor: Boolean): String? {
val workflowIdKey = if (openDoor) HttpManager.PREF_KEY_OD_WFID else HttpManager.PREF_KEY_CD_WFID
val workflowApiKey = if (openDoor) HttpManager.PREF_KEY_OD_WF_KEY else HttpManager.PREF_KEY_CD_WF_KEY
val workflowId = prefs.getString(workflowIdKey, "").orEmpty().trim()
val apiKey = prefs.getString(workflowApiKey, "").orEmpty().trim()
if (workflowId.isEmpty() || apiKey.isEmpty()) {
Log.w("MainActivity", "Door workflow config missing: openDoor=$openDoor")
return null
}
return HttpManager.workflow_execute(
context = this@MainActivity,
apiKey = apiKey,
workflowId = workflowId,
inputs = emptyMap<String, Any>()
)
}
private fun isActivated(): Boolean {
if (!::prefs.isInitialized) {
return false
@@ -854,13 +837,13 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString("currentTask", taskController.currentTask)
outState.putString("receptionLocation", taskController.getReceptionLocation())
outState.putString("receptionText", taskController.getReceptionText())
outState.putString("receptionDestination", taskController.getReceptionDestination())
outState.putString("notificationLocation", taskController.getNotificationLocation())
outState.putString("notificationText", taskController.getNotificationText())
outState.putString("lastArrivalLocation", lastArrivalLocation)
outState.putString(STATE_KEY_CURRENT_TASK, taskController.currentTask)
outState.putString(STATE_KEY_RECEPTION_LOCATION, taskController.getReceptionLocation())
outState.putString(STATE_KEY_RECEPTION_TEXT, taskController.getReceptionText())
outState.putString(STATE_KEY_RECEPTION_DESTINATION, taskController.getReceptionDestination())
outState.putString(STATE_KEY_NOTIFICATION_LOCATION, taskController.getNotificationLocation())
outState.putString(STATE_KEY_NOTIFICATION_TEXT, taskController.getNotificationText())
outState.putString(STATE_KEY_LAST_ARRIVAL_LOCATION, lastArrivalLocation)
}
override fun onReposeStatusChanged(status: Int, description: String) {

View File

@@ -0,0 +1,38 @@
package com.example.lzwcai_terminal_temi
object MainTaskPolicy {
const val TASK_PATROL = "patrol"
const val TASK_RECEPTION = "reception"
const val TASK_SPEECH = "speech"
const val LEGACY_TASK_SPECIAL = "special"
data class BehaviorDecision(
val skipArrivalAnnouncement: Boolean,
val allowAutoRecharge: Boolean,
val allowDoorWorkflow: Boolean,
val allowIdleGreeting: Boolean
)
fun normalizeTask(task: String?): String {
return task.orEmpty().trim().lowercase()
}
fun isTask(task: String?, expected: String): Boolean {
return normalizeTask(task) == expected
}
fun isIdleTask(task: String?): Boolean {
val normalized = normalizeTask(task)
return normalized.isEmpty() || normalized == TASK_SPEECH
}
fun resolveBehaviorDecision(task: String?, isSpecialState: Boolean): BehaviorDecision {
val isIdleTask = isIdleTask(task)
return BehaviorDecision(
skipArrivalAnnouncement = isSpecialState && normalizeTask(task).isEmpty(),
allowAutoRecharge = !isSpecialState && isIdleTask,
allowDoorWorkflow = !isSpecialState && isIdleTask,
allowIdleGreeting = !isSpecialState && isIdleTask
)
}
}

View File

@@ -36,6 +36,8 @@ class MqttManager(
private val job = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.IO + job)
private var reconnectJob: Job? = null
@Volatile
private var isConnecting: Boolean = false
private val prefs = appContext.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
private val agentDempIdKey = "agent_demp_id"
@@ -57,10 +59,15 @@ class MqttManager(
private var danceJob: Job? = null
init {
createMqttClient()
}
private fun createMqttClient() {
try {
mqttClient = MqttClient(brokerUri, clientId, MemoryPersistence())
mqttClient?.setCallback(object : MqttCallbackExtended {
override fun connectComplete(reconnect: Boolean, serverURI: String?) {
isConnecting = false
Log.i(TAG, "MQTT connection complete. Reconnect: $reconnect")
subscribeTopic("robot/cmd")
subscribeTopic("soul2user")
@@ -68,6 +75,7 @@ class MqttManager(
}
override fun connectionLost(cause: Throwable?) {
isConnecting = false
Log.e(TAG, "Connection lost: ${cause?.message}")
updateConnectionStatus(false)
scheduleReconnect()
@@ -96,6 +104,10 @@ class MqttManager(
updateConnectionStatus(true)
return@launch
}
if (isConnecting) {
Log.d(TAG, "MQTT connect skipped: already connecting.")
return@launch
}
val username = prefs.getString(HttpManager.PREF_KEY_MQTT_USERNAME, "").orEmpty().trim()
val password = prefs.getString(HttpManager.PREF_KEY_MQTT_PASSWORD, "").orEmpty()
if (username.isEmpty() || password.isEmpty()) {
@@ -103,8 +115,12 @@ class MqttManager(
updateConnectionStatus(false)
return@launch
}
if (mqttClient == null) {
createMqttClient()
}
Log.i(TAG, "Attempting to connect to MQTT broker at $brokerUri")
try {
isConnecting = true
val options = MqttConnectOptions().apply {
isAutomaticReconnect = false
isCleanSession = true
@@ -114,8 +130,10 @@ class MqttManager(
this.password = password.toCharArray()
}
mqttClient?.connect(options)
} catch (e: MqttException) {
Log.e(TAG, "Initial connection failed: ${e.message}")
} catch (t: Throwable) {
Log.e(TAG, "Initial connection failed", t)
isConnecting = false
resetClientSafely()
updateConnectionStatus(false)
scheduleReconnect()
}
@@ -137,6 +155,7 @@ class MqttManager(
scope.launch {
try {
reconnectJob?.cancel()
isConnecting = false
if (mqttClient?.isConnected == true) {
mqttClient?.disconnect()
Log.i(TAG, "Disconnected from MQTT broker.")
@@ -151,7 +170,7 @@ class MqttManager(
fun shutdown() {
reconnectJob?.cancel()
job.cancel()
isConnecting = false
try {
if (mqttClient?.isConnected == true) {
mqttClient?.disconnect()
@@ -165,6 +184,16 @@ class MqttManager(
}
}
private fun resetClientSafely() {
try {
mqttClient?.close()
} catch (_: Exception) {
} finally {
mqttClient = null
}
createMqttClient()
}
private fun updateConnectionStatus(connected: Boolean) {
Handler(Looper.getMainLooper()).post {
statusListener(connected)
@@ -284,7 +313,8 @@ class MqttManager(
private fun handleJsonCommand(obj: JSONObject) {
val action = obj.optString("action", obj.optString("cmd", obj.optString("type", ""))).lowercase()
val actionsResetTask = setOf("recharge", "goto", "notification", "reception", "patrol", "repose", "turn", "tilt", "terminate")
// Only interrupt current task for explicit navigation/termination commands.
val actionsResetTask = setOf("recharge", "goto", "terminate")
if (action in actionsResetTask) {
scope.launch(Dispatchers.Main) {
onSetCurrentTask("")
@@ -395,9 +425,9 @@ class MqttManager(
}
"reception" -> {
speak("接到接待任务", "zh")
val location = obj.optString("location", "前台")
val text = obj.optString("text", "你是我要接待的贵宾吗?")
val destination = obj.optString("destination", "会议室")
val location = "前台"
val text = obj.optString("text", "你是我要接待的贵宾吗?").trim()
val destination = obj.optString("destination", "会议室").trim()
scope.launch(Dispatchers.Main) {
onStartReceptionMode(location, text, destination)
}

View File

@@ -103,6 +103,8 @@ class SettingsActivity : AppCompatActivity() {
.remove(HttpManager.PREF_KEY_OD_WF_KEY)
.remove(HttpManager.PREF_KEY_CD_WFID)
.remove(HttpManager.PREF_KEY_CD_WF_KEY)
.remove(HttpManager.PREF_KEY_VR_WFID)
.remove(HttpManager.PREF_KEY_VR_WF_KEY)
binding.etActivationCode.setText("")
binding.etDeviceName.setText("")
updateActivationUi(false)
@@ -154,8 +156,15 @@ class SettingsActivity : AppCompatActivity() {
val odWfKey = runtimeConfigs?.get(HttpManager.PREF_KEY_OD_WF_KEY).orEmpty()
val cdWfid = runtimeConfigs?.get(HttpManager.PREF_KEY_CD_WFID).orEmpty()
val cdWfKey = runtimeConfigs?.get(HttpManager.PREF_KEY_CD_WF_KEY).orEmpty()
val vrWfid = runtimeConfigs?.get(HttpManager.PREF_KEY_VR_WFID).orEmpty()
val vrWfKey = runtimeConfigs?.get(HttpManager.PREF_KEY_VR_WF_KEY).orEmpty()
val mqttReady = mqttUser.isNotBlank() && mqttPass.isNotBlank()
val workflowReady = odWfid.isNotBlank() && odWfKey.isNotBlank() && cdWfid.isNotBlank() && cdWfKey.isNotBlank()
val workflowReady = odWfid.isNotBlank() &&
odWfKey.isNotBlank() &&
cdWfid.isNotBlank() &&
cdWfKey.isNotBlank() &&
vrWfid.isNotBlank() &&
vrWfKey.isNotBlank()
val configReady = mqttReady && workflowReady
val editor = prefs.edit()
.putString(HttpManager.PREF_KEY_ACTIVATION_CODE, activationCode)
@@ -168,6 +177,8 @@ class SettingsActivity : AppCompatActivity() {
.putString(HttpManager.PREF_KEY_OD_WF_KEY, odWfKey)
.putString(HttpManager.PREF_KEY_CD_WFID, cdWfid)
.putString(HttpManager.PREF_KEY_CD_WF_KEY, cdWfKey)
.putString(HttpManager.PREF_KEY_VR_WFID, vrWfid)
.putString(HttpManager.PREF_KEY_VR_WF_KEY, vrWfKey)
editor.apply()
updateActivationUi(true)
if (!configReady) {

View File

@@ -15,6 +15,15 @@ class TaskController(
private val setEmoji: (AnimatedEmojiView.Expression) -> Unit,
private val setReceptionButtonVisible: (Boolean) -> Unit
) {
private fun normalizeLocation(value: String?): String {
return value.orEmpty()
.trim()
.lowercase()
.replace(" ", "")
.replace("_", "")
.replace("-", "")
}
var currentTask: String = ""
private set
@@ -120,14 +129,21 @@ class TaskController(
}
fun startReceptionMode(location: String, text: String, destination: String) {
val targetLocation = location.trim()
val promptText = text.trim()
val targetDestination = destination.trim()
if (targetLocation.isEmpty()) {
setCurrentTask("")
return
}
setCurrentTask("reception")
receptionLocation = location
receptionText = text
receptionDestination = destination
receptionLocation = targetLocation
receptionText = promptText.ifEmpty { "你是我要接待的贵宾吗?" }
receptionDestination = targetDestination
isReceptionPromptVisible = false
setReceptionButtonVisible(false)
if (getLastArrivalLocation() != location) {
navController.goTo(location, false)
if (getLastArrivalLocation()?.trim()?.equals(targetLocation, ignoreCase = true) != true) {
navController.goTo(targetLocation, false)
} else {
startTaskWaitTimeout()
}
@@ -177,9 +193,13 @@ class TaskController(
speak("别妨碍我,我正在巡逻呢")
return true
}
if (currentTask == "reception" &&
getLastArrivalLocation()?.equals(receptionLocation, ignoreCase = true) == true
) {
if (currentTask == "reception") {
val isAtReceptionLocation =
normalizeLocation(getLastArrivalLocation()) == normalizeLocation(receptionLocation)
if (!isAtReceptionLocation) {
// Keep default greeting disabled during reception even before arriving at spot.
return true
}
when (state) {
DETECTED -> {
startTaskWaitTimeout()
@@ -194,8 +214,9 @@ class TaskController(
setReceptionButtonVisible(false)
}
}
return true
}
return currentTask == "reception"
return false
}
fun endNonSpecialTask(reason: String) {

View File

@@ -0,0 +1,117 @@
package com.example.lzwcai_terminal_temi
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class WorkflowService(
private val context: Context,
private val prefs: SharedPreferences,
private val scope: CoroutineScope,
private val normalizeLocation: (String?) -> String,
private val onWorkflowFailed: () -> Unit
) {
private var pendingReceptionReturnWorkflow: Boolean = false
private var lastWorkflowConfigRefreshAt: Long = 0L
fun markReceptionReturnPending() {
pendingReceptionReturnWorkflow = true
}
fun triggerReceptionReturnWorkflowIfNeeded(location: String) {
if (!pendingReceptionReturnWorkflow) {
return
}
if (normalizeLocation(location) != "homebase") {
return
}
pendingReceptionReturnWorkflow = false
scope.launch {
val nowText = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
val inputs = mapOf("flag" to nowText)
val result = executeConfiguredWorkflow(
workflowIdKey = HttpManager.PREF_KEY_VR_WFID,
workflowApiKey = HttpManager.PREF_KEY_VR_WF_KEY,
workflowName = "reception-return-home",
inputs = inputs
)
if (result == null) {
onWorkflowFailed()
}
}
}
suspend fun executeDoorWorkflow(openDoor: Boolean): String? {
val workflowIdKey = if (openDoor) HttpManager.PREF_KEY_OD_WFID else HttpManager.PREF_KEY_CD_WFID
val workflowApiKey = if (openDoor) HttpManager.PREF_KEY_OD_WF_KEY else HttpManager.PREF_KEY_CD_WF_KEY
val workflowName = if (openDoor) "open-door" else "close-door"
return executeConfiguredWorkflow(workflowIdKey, workflowApiKey, workflowName)
}
private suspend fun executeConfiguredWorkflow(
workflowIdKey: String,
workflowApiKey: String,
workflowName: String,
inputs: Any = emptyMap<String, Any>()
): String? {
var workflowId = prefs.getString(workflowIdKey, "").orEmpty().trim()
var apiKey = prefs.getString(workflowApiKey, "").orEmpty().trim()
if (workflowId.isEmpty() || apiKey.isEmpty()) {
refreshWorkflowConfigsIfNeeded()
workflowId = prefs.getString(workflowIdKey, "").orEmpty().trim()
apiKey = prefs.getString(workflowApiKey, "").orEmpty().trim()
}
if (workflowId.isEmpty() || apiKey.isEmpty()) {
Log.w(
"MainActivity",
"Workflow config missing after refresh: workflow=$workflowName, " +
"workflowIdKey=$workflowIdKey, workflowApiKey=$workflowApiKey"
)
return null
}
return HttpManager.workflow_execute(
context = context,
apiKey = apiKey,
workflowId = workflowId,
inputs = inputs
)
}
private suspend fun refreshWorkflowConfigsIfNeeded() {
val now = System.currentTimeMillis()
if (now - lastWorkflowConfigRefreshAt < 5000L) {
return
}
lastWorkflowConfigRefreshAt = now
val runtimeConfigs = HttpManager.fetchRuntimeConfigs(context) ?: return
val workflowKeys = listOf(
HttpManager.PREF_KEY_OD_WFID,
HttpManager.PREF_KEY_OD_WF_KEY,
HttpManager.PREF_KEY_CD_WFID,
HttpManager.PREF_KEY_CD_WF_KEY,
HttpManager.PREF_KEY_VR_WFID,
HttpManager.PREF_KEY_VR_WF_KEY
)
val editor = prefs.edit()
var changed = false
for (key in workflowKeys) {
val value = runtimeConfigs[key]?.trim().orEmpty()
if (value.isEmpty()) {
continue
}
if (prefs.getString(key, "").orEmpty() != value) {
editor.putString(key, value)
changed = true
}
}
if (changed) {
editor.apply()
Log.i("MainActivity", "Workflow configs refreshed from server.")
}
}
}

View File

@@ -93,7 +93,7 @@
android:layout_marginEnd="40dp"
android:layout_marginTop="40dp"
android:layout_marginBottom="40dp"
android:text="是的,请带我去"
android:text="带我去接待地点"
android:textSize="48sp"
android:visibility="gone"
app:layout_constraintTop_toTopOf="parent"

View File

@@ -93,7 +93,7 @@
android:layout_marginEnd="40dp"
android:layout_marginTop="40dp"
android:layout_marginBottom="40dp"
android:text="是的,请带我去"
android:text="带我去接待地点"
android:textSize="48sp"
android:visibility="gone"
app:layout_constraintTop_toTopOf="parent"

499
交接文档.md Normal file
View File

@@ -0,0 +1,499 @@
# Temi 终端控制应用交接文档
本文档面向后续接手本项目的技术人员,重点说明项目定位、当前架构、配置约定、关键业务流程、排障思路,以及与旧版实现相比已经发生的变化。建议先通读本文,再结合 `technique.md` 和代码深入。
## 1. 项目定位与当前状态
- 项目类型:基于 Temi SDK 的 Android 应用Kotlin
- Android 模块:`app`
- 包名:`com.example.lzwcai_terminal_temi`
- 核心能力:
- 通过 MQTT 指令控制 Temi 机器人导航、巡逻、接待、通知播报和基础动作。
- 通过 LiveKit 进行音视频接入,并把房间内收到的 ASR/文本数据转发到 MQTT。
- 通过 HTTP 接口做设备激活、运行时配置拉取、门禁工作流调用。
- 通过 Telemetry 上报机器人状态心跳、事件、电量和位姿。
- 当前版本和早期代码相比,最大的变化是:
- MQTT 用户名/密码不再写死在代码中,而是在激活后从服务端拉取并存入本地。
- 设置页主配置已经从单纯的 `network_ip` 扩展成 `base_url + 登录账号 + 激活码 + 设备名`
- 主流程增加了激活态控制、自动回充、门禁工作流、接待返航工作流、ASR 转发等能力。
推荐阅读顺序:
1. 本文档:快速建立维护视角。
2. `technique.md`:查看协议和流程图。
3. `MainActivity.kt``SettingsActivity.kt``MqttManager.kt``TaskController.kt`:理解当前真实行为。
## 2. 核心模块一览
代码主要集中在 `app/src/main/java/com/example/lzwcai_terminal_temi`
- `MainActivity`
- 主界面与机器人事件中心。
- 管理 MQTT / LiveKit 生命周期、TTS 状态、人体检测、到站处理、UI 状态、激活提示。
- `SettingsActivity`
- 配置页。
- 负责 `base_url`、登录账号密码、激活码、设备名、LiveKit 参数、当前位置、特殊状态、`agent_demp_id` 等配置。
- `ConnectionService`
- 连接层编排。
- 根据当前配置和激活状态创建/更新 `MqttManager``LiveKitManager`
- `MqttManager`
- MQTT 连接、重连、订阅、发布。
- 解析 `robot/cmd``soul2user`管理流式播报、TTS 队列和任务启动。
- `TaskController`
- 任务状态机。
- 管理 `patrol``reception``notification``speech` 等任务,以及任务超时、巡逻推进、接待确认按钮。
- `MainTaskPolicy`
- 统一定义空闲态 / 特殊状态下的行为决策。
- 控制是否允许自动回充、门禁工作流、到站播报、闲时问候。
- `NavController`
- Temi 导航与动作封装。
- 包含 `goTo``recharge``repose``turnBy``tiltAngle``randomPatrol` 等。
- `AutoRechargeScheduler`
- 机器人空闲到站后的延时自动回充。
- 当前策略是空闲到站后 10 秒且不在 `home base` 时触发 `recharge()`
- `WorkflowService`
- 工作流调用编排。
- 负责开门、关门、接待返航工作流,并在缺配置时尝试刷新服务端配置。
- `HttpManager`
- HTTP 接口封装。
- 负责登录、激活、拉取运行时配置、执行工作流。
- `LiveKitManager`
- LiveKit 房间连接、事件监听、自动重连、麦克风开关控制。
- 麦克风开启条件依赖人体检测稳定状态和 TTS 静音状态。
- `TelemetryManager`
- 周期性心跳与事件上报。
- 包含电量、位置、运动状态、任务状态、MQTT/LiveKit 连接状态。
- `RobotEventHandler`
- 机器人事件辅助逻辑。
- 用于状态归类、地点归一化、角度归一化。
- `UiState`
- 主界面上网络异常横幅、激活提示横幅、连接指示灯颜色更新。
- `PermissionManager`
- Temi 权限检查与申请。
- `AnimatedEmojiView`
- 自绘表情组件,受任务状态和 TTS 状态驱动。
- `LogManager`
- Logcat 监听与订阅。
## 3. 运行环境与依赖
### 3.1 开发环境
- Android Studio近期版本即可需支持
- AGP `8.10.1`
- Kotlin `2.0.21`
- JDK17
- Android SDK
- `compileSdk = 36`
- `minSdk = 23`
- `targetSdk = 36`
- 运行设备Temi 真机优先;模拟器仅适合 UI 预览。
### 3.2 主要依赖
见 [`app/build.gradle.kts`](file:///Users/tan/Documents/project/lzwcai-terminal-temi/app/build.gradle.kts#L41-L52) 与 [`libs.versions.toml`](file:///Users/tan/Documents/project/lzwcai-terminal-temi/gradle/libs.versions.toml#L1-L26)
- Temi SDK`com.robotemi:sdk:1.137.1`
- MQTT
- `org.eclipse.paho.android.service:1.1.1`
- `org.eclipse.paho.client.mqttv3:1.2.5`
- LiveKit Android`2.23.5`
- AndroidX / Material / Emoji2 等基础 UI 依赖。
### 3.3 权限
- Temi 权限:
- `MAP`
- `SEQUENCE`
- `FACE_RECOGNITION`
- `SETTINGS`
- Android 权限:
- `RECORD_AUDIO`
- `CAMERA`
说明:
- `MainActivity.onStart()` 会注册机器人监听器并按激活状态决定是否启动 MQTT / LiveKit / Telemetry。
- `onRobotReady()` 中会触发 `PermissionManager.checkAndRequestPermissions()`
- LiveKit 权限未授予时不会建立连接。
## 4. 配置与持久化约定
应用配置统一存放在 `SharedPreferences("app_prefs")`
### 4.1 关键配置项
- 服务与激活相关:
- `base_url`:后端服务基础地址,例如 `http://192.168.11.24:8088`
- `login_username` / `login_password`:调用登录接口用的账号密码
- `activation_code`:激活码
- `device_name`:设备名称
- `device_id`:本机生成并持久化的设备标识
- `is_activated`:是否已激活
- 运行时下发配置:
- `mqtt_username` / `mqtt_password`
- `od_wfid` / `od_wf_key`:开门工作流配置
- `cd_wfid` / `cd_wf_key`:关门工作流配置
- `vr_wfid` / `vr_wf_key`:接待返航工作流配置
- 任务与状态:
- `current_location`:当前位置
- `special_state`:特殊状态开关
- `agent_demp_id``soul2user` 过滤字段
- LiveKit
- `livekit_url`
- `livekit_room`
- `livekit_token`
- `livekit_enabled`
- 兼容字段:
- `network_ip`:仍会被保存,但现在是由 `base_url` 自动解析 host 后回填,主要给旧逻辑或排查时参考。
### 4.2 设置页实际能力
当前 `SettingsActivity` 不只是“填 IP”而是包含以下能力
- 保存 `base_url``agent_demp_id`、登录用户名、登录密码。
- 触发设备激活:
-`HttpManager.activateDevice()`
- 成功后拉取 MQTT 与工作流配置
-`is_activated` 设为 `true`
- 保存 LiveKit URL / 房间 / Token / 自动连接开关。
- 选择当前位置。
- 开关特殊状态。
- 清除当前任务。
- 查看 About 信息。
- 长按 3 秒重启应用。
### 4.3 一个重要行为
`base_url` 发生变化时,设置页会主动清空以下信息并重置激活状态:
- `activation_code`
- `device_name`
- `is_activated`
- MQTT 用户名/密码
- 所有工作流 ID / API Key
这意味着切环境后必须重新激活,文档和运维流程都要按这个行为来理解。
## 5. 通信协议与主题约定
### 5.1 MQTT Broker 与主题
- Broker 地址:`tcp://<base_url_host>:1883`
- 实际 host 由 `ConnectionService.resolveBrokerHostFromBaseUrl()``base_url` 提取。
- MQTT 账号:
- 从本地 `mqtt_username` / `mqtt_password` 读取。
- 这些值通常由激活成功后从服务端拉取。
- 订阅主题:
- `robot/cmd`:控制指令
- `soul2user`:流式播报文本
- 发布主题:
- `robot/status`:状态心跳
- `robot/event`:事件上报,例如 `battery_low`
- `robot/asr`LiveKit 收到的文本/ASR 转发结果
### 5.2 指令映射
指令解析统一在 `MqttManager.handleJsonCommand()`
- 基础控制:
- `recharge`
- `goto`
- `repose`
- `turn`
- `tilt`
- `stop`
- `terminate`
- `continue`
- `status`
- 语音:
- `speak`
- `stream`
- 任务:
- `patrol`
- `notification`
- `reception`
### 5.3 当前实现里的几个“非直觉行为”
这些点很容易和旧文档或接口约定不一致,接手时必须知道:
- `reception` 指令里的 `location` 目前没有真正使用。
- `MqttManager` 里接待地点被硬编码为 `前台`
- `destination` 默认值是 `会议室`
- `text` 默认值是“你是我要接待的贵宾吗?”
- `goto` 若收到未知地点,不会报错退出,而是会回退到 `会议室`
- 这是 `NavController.goTo()` 的当前保护逻辑。
- `stream` 不只是简单播报全文。
- 文本会进入 `speechBuffer`
- 根据中英文标点和换行拆句
- `is_final=true` 时会刷新剩余缓存
- `soul2user` 消息支持通过 `agent_demp_id` 过滤 `demp_id`
-`session_id``message_id` 变化时,当前流式 TTS 会被打断并切换到新的会话上下文。
### 5.4 常见指令示例
```json
{"action":"goto","location":"前台"}
```
```json
{"action":"speak","text":"欢迎光临","lang":"zh"}
```
```json
{"action":"patrol","flag":false,"locations":["前台","会议室"],"times":2,"waiting":5}
```
```json
{"action":"notification","location":"大厅","text":"会议即将开始,请尽快入场"}
```
## 6. 关键业务流程说明
### 6.1 激活态控制
`MainActivity` 已经把“是否激活”纳入主流程:
- 未激活时:
- 主界面显示“请先激活应用”横幅
- MQTT 断开
- LiveKit 断开
- Telemetry 停止
- 不再进入正常联网工作态
- 已激活时:
- 恢复 MQTT / LiveKit 连接流程
- 开始心跳上报
这部分逻辑是当前版本的重要门槛,所有“为什么没连上 MQTT / LiveKit”的排查都先确认 `is_activated`
### 6.2 任务状态机
任务逻辑集中在 [`TaskController.kt`](file:///Users/tan/Documents/project/lzwcai-terminal-temi/app/src/main/java/com/example/lzwcai_terminal_temi/TaskController.kt)
- `currentTask` 主要值:
- 空字符串:空闲
- `speech`
- `patrol`
- `reception`
- `notification`
- 巡逻:
- 支持指定路线和随机路线
- 支持 `times``waiting``nonStop`
- 通过 `handlePatrolArrival()` 推进路线索引和剩余圈数
- 接待:
- 到达接待点后开启等待
- 稳定检测到人时显示确认按钮并播报接待文案
- 点击按钮后前往目的地
- 通知:
- 到达目标地点即播报一次并结束任务
- 纯播报:
- 由 MQTT 流式/非流式语音触发
- TTS 队列清空后自动清回空任务
- 任务超时:
- 接待任务在等待阶段会启动 15 分钟超时
- 超时后播报“任务超时了,任务被取消,我先回充电桩了”并执行回充
### 6.3 到站、播报与自动回充
`onGoToLocationStatusChanged()` 是到站处理的关键入口:
- 到站后会:
- 更新 `lastArrivalLocation`
- 回写 `current_location`
- 处理巡逻到站推进
- 处理接待任务到站
- 处理通知任务到站
- 根据 `MainTaskPolicy` 决定是否播报“已到达xxx”
- 自动回充当前规则:
- 仅在空闲任务或 `speech` 任务下允许
- 特殊状态下禁用
- 仅在不在 `home base` 时生效
- 到站 10 秒后自动执行 `recharge()`
### 6.4 人体检测、门禁与问候
人体检测在 `MainActivity` 中做去抖:
- `DETECTED`:约 0.8 秒稳定确认
- `IDLE`:约 5 秒稳定确认
稳定状态变化后:
- 会调用 `LiveKitManager.setDetectionActive()` 更新麦克风逻辑。
- 会优先交给 `TaskController.handleDetectionStateChanged()` 处理任务内行为。
- 如果当前是空闲态且允许门禁逻辑:
-`home base` 检测到人时执行开门工作流
- 回到 `IDLE` 时执行关门工作流
- 如果当前是空闲态且不在 `home base`
- 检测到人时会按当前时段播报“早上好 / 中午好 / 下午好 / 晚上好”
### 6.5 LiveKit 与语音互斥
LiveKit 麦克风并不是“连上就开”,而是由三项共同决定:
- LiveKit 连接时传入的 `enableMic`
- `detectionActive == true`
- `ttsMuted == false`
也就是说:
- 机器人开始 TTS 时会自动关闭麦克风。
- TTS 结束后才允许再次开麦。
- 没有人时不会持续开麦。
### 6.6 工作流调用
当前工作流主要有三类:
- 开门:`od_wfid` / `od_wf_key`
- 关门:`cd_wfid` / `cd_wf_key`
- 接待返航:`vr_wfid` / `vr_wf_key`
调用路径:
- `WorkflowService.executeDoorWorkflow()`:处理开门/关门。
- `WorkflowService.markReceptionReturnPending()`
- 在接待确认按钮点击后设置“返航待触发”。
- `WorkflowService.triggerReceptionReturnWorkflowIfNeeded()`
- 当机器人后续回到 `home base` 时触发返航工作流。
补充说明:
- 如果本地没有 workflow 配置,`WorkflowService` 会尝试重新调用 `fetchRuntimeConfigs()` 刷新一次。
- 工作流执行失败时,主界面会展示短暂的“网络异常”横幅。
### 6.7 Telemetry 上报
[`TelemetryManager`](file:///Users/tan/Documents/project/lzwcai-terminal-temi/app/src/main/java/com/example/lzwcai_terminal_temi/TelemetryManager.kt) 负责状态上报:
- 心跳频率:每 15 秒一次
- 状态快照字段包括:
- `task`
- `location`
- `mqttConnected`
- `liveKitConnected`
- `battery`
- `movement`
- `position`
- 低电量策略:
- 电量 `<= 20%` 且未充电
- 10 分钟内只重复提醒一次
- 本地播报“电量低,请及时充电。”
-`robot/event` 发布 `battery_low`
## 7. 日志与排障
### 7.1 主要日志来源
- Logcat Tag
- `MainActivity`
- `MqttManager`
- `LiveKitManager`
- `SettingsActivity`
- `ConnectionService`
- `TaskController`
- `TelemetryManager`
- `HttpManager`
- `LogManager.startLogcatListener()` 会在应用启动时开始监听。
### 7.2 排障建议
- MQTT 连不上:
- 先确认是否已激活
- 再看 `base_url` 是否能正确解析出 host
- 再看本地是否已有 `mqtt_username` / `mqtt_password`
- 查看 `MqttManager` 日志中的 `MQTT connect skipped``Initial connection failed`
- 激活失败:
- 检查 `base_url` 是否正确
- 检查登录账号密码是否正确
- 检查激活码和设备名是否填写完整
- 查看 `HttpManager``Login failed``Activation failed`
- 工作流不触发:
- 检查本地是否已保存 `od_*` / `cd_*` / `vr_*`
- 确认 `home base` 地点名称归一化后能匹配
- 查看是否处于特殊状态或非空闲任务,导致门禁逻辑被策略层禁用
- 接待任务不工作:
- 先注意当前实现里接待地点默认就是 `前台`
- 检查是否稳定检测到人,而不是瞬时检测
- 检查按钮是否显示、任务是否超时被取消
- 导航到错误地点:
- 当前未知地点会自动回退到 `会议室`
- 先检查 Temi 侧地点名称是否与消息一致
- LiveKit 不连或没声音:
- 检查 URL / Room / Token / 自动连接开关
- 检查麦克风和摄像头权限
- 注意“已连接但没人时不开麦”是当前设计,不是异常
- `monitor.py` 调试失败:
- 它仍然写死了 `API_KEY` / `API_SECRET` / URL / 房间名
- 需要和当前 LiveKit 环境保持一致
## 8. 常见改动场景指引
### 8.1 新增 MQTT 指令
推荐修改顺序:
1.`MqttManager.handleJsonCommand()` 增加 `action` 分支。
2. 如果涉及持续态流程,在 `TaskController` 增加任务类型,而不是把状态散落在 `MainActivity`
3. 如果需要策略控制,同时补 `MainTaskPolicy`
4. 如果需要状态上报,扩展 `TelemetryManager`
### 8.2 修正接待逻辑
如果你希望接待任务真正使用 MQTT 中的 `location`
1. 修改 `MqttManager``reception` 分支,不要再写死 `前台`
2. 联调 `TaskController.startReceptionMode()` 与现场 Temi 地点名称。
3. 回归测试接待确认按钮、接待返航工作流、朝向恢复逻辑。
### 8.3 调整自动回充或门禁策略
优先改这里:
- `MainTaskPolicy`:决定什么情况下允许自动回充、门禁、问候
- `AutoRechargeScheduler`:决定延时多久回充
- `MainActivity.handleStableDetectionStateChanged()`:决定何时触发开门/关门
### 8.4 更换服务环境
新环境切换步骤建议如下:
1. 在设置页改 `base_url`
2. 确认激活状态已被重置
3. 重新填写登录信息和激活码
4. 重新激活,拉取 MQTT 与工作流配置
5. 再检查 LiveKit 参数是否也需要同步更新
### 8.5 扩展 HTTP / 工作流集成
建议在 `HttpManager` 中新增原子接口,在 `WorkflowService` 中编排业务流程,避免把 HTTP 细节直接散落到 `MainActivity`
## 9. 安全与后续优化建议
- 敏感信息:
- 登录账号密码目前明文保存在 `SharedPreferences`
- LiveKit 默认 `API_KEY` / `API_SECRET` 仍写在代码中
- `monitor.py` 里也写死了 LiveKit 凭据和 URL
- 配置管理:
- 现在已经有“激活后拉运行配置”的雏形,可以继续把更多环境配置收敛到服务端
- 协议一致性:
- `reception.location` 未实际生效
- `goto` 未知地点会静默降级到 `会议室`
- 这些行为最好和上游协议重新对齐
- 文案资源化:
- 业务提示语很多还写在 Kotlin 代码里,后续可以逐步迁移到 `strings.xml`
- 稳定性:
- 工作流失败现在只是展示网络异常横幅,可考虑补充错误事件上报和重试机制
## 10. 建议接手路径
如果你是第一次接手,建议按下面顺序熟悉:
1. 先在真机上跑通激活流程。
2. 在设置页填好 `base_url`、登录账号、激活码、设备名,确认 MQTT 能连上。
3.`goto``speak``patrol``notification` 四类指令验证基础链路。
4. 再测试空闲态门禁、接待任务、自动回充、低电量事件。
5. 最后再看 `technique.md` 里的流程图,对照 `MainActivity``TaskController` 理解策略差异。
如需继续维护,建议同步更新 `README.md``technique.md`,因为它们目前还有一部分内容停留在旧版实现描述。