feat: 新增机器人运动控制、任务管理及远程监控功能

- 扩展 NavController 支持旋转、倾斜及遥控操作
- 实现任务控制器统一管理接待、巡逻、通知等任务逻辑
- 新增遥测管理器定期上报状态并支持低电量预警
- 增强 LiveKit 管理器支持自动重连与麦克风状态联动
- 优化人体检测去抖逻辑并更新技术文档
- 调整设置界面文本描述并添加网络异常提示
This commit is contained in:
2026-03-20 15:36:38 +08:00
parent 66fc204cff
commit fea2ba7591
14 changed files with 1058 additions and 408 deletions

1
.idea/misc.xml generated
View File

@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

View File

@@ -12,6 +12,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
enum class LiveKitStatus { enum class LiveKitStatus {
@@ -20,12 +21,36 @@ enum class LiveKitStatus {
Failed Failed
} }
class LiveKitManager(appContext: Context, private val statusListener: (LiveKitStatus) -> Unit) { class LiveKitManager(
appContext: Context,
private val statusListener: (LiveKitStatus) -> Unit,
private val onDataReceived: (payload: String, topic: String?, participantIdentity: String?) -> Unit = { _, _, _ -> }
) {
private val context = appContext.applicationContext private val context = appContext.applicationContext
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var room: Room? = null private var room: Room? = null
private var eventsJob: Job? = null private var eventsJob: Job? = null
private var reconnectJob: Job? = null
private var autoReconnectEnabled = false
private var lastUrl: String? = null
private var lastToken: String? = null
private var lastEnableMic: Boolean = false
private var lastEnableCamera: Boolean = false
private var ttsMuted: Boolean = false
private var detectionActive: Boolean = false
companion object {
const val PREF_KEY_URL = "livekit_url"
const val PREF_KEY_ROOM = "livekit_room"
const val PREF_KEY_TOKEN = "livekit_token"
const val PREF_KEY_ENABLED = "livekit_enabled"
const val DEFAULT_URL = "ws://localhost:7880"
const val DEFAULT_ROOM = "temi-room"
const val DEFAULT_API_KEY = "devkey"
const val DEFAULT_API_SECRET = "secret"
const val PERMISSION_REQUEST_CODE = 2001
}
fun connect(url: String, token: String, enableMic: Boolean, enableCamera: Boolean) { fun connect(url: String, token: String, enableMic: Boolean, enableCamera: Boolean) {
val finalUrl = url.trim() val finalUrl = url.trim()
@@ -34,6 +59,13 @@ class LiveKitManager(appContext: Context, private val statusListener: (LiveKitSt
Log.w("LiveKitManager", "LiveKit connect skipped: empty url or token.") Log.w("LiveKitManager", "LiveKit connect skipped: empty url or token.")
return return
} }
reconnectJob?.cancel()
autoReconnectEnabled = true
lastUrl = finalUrl
lastToken = finalToken
lastEnableMic = enableMic
lastEnableCamera = enableCamera
Log.i("LiveKitManager", "LiveKit connect requested: mic=$enableMic, camera=$enableCamera, url=$finalUrl")
scope.launch { scope.launch {
val currentRoom = room ?: LiveKit.create(context).also { room = it } val currentRoom = room ?: LiveKit.create(context).also { room = it }
eventsJob?.cancel() eventsJob?.cancel()
@@ -42,11 +74,19 @@ class LiveKitManager(appContext: Context, private val statusListener: (LiveKitSt
when (event) { when (event) {
is RoomEvent.Connected -> { is RoomEvent.Connected -> {
Log.i("LiveKitManager", "LiveKit connected.") Log.i("LiveKitManager", "LiveKit connected.")
reconnectJob?.cancel()
statusListener(LiveKitStatus.Connected) statusListener(LiveKitStatus.Connected)
applyMicState()
} }
is RoomEvent.Disconnected -> { is RoomEvent.Disconnected -> {
Log.i("LiveKitManager", "LiveKit disconnected.") Log.i("LiveKitManager", "LiveKit disconnected.")
statusListener(LiveKitStatus.Disconnected) statusListener(LiveKitStatus.Disconnected)
scheduleReconnect()
}
is RoomEvent.DataReceived -> {
val payload = runCatching { event.data.toString(Charsets.UTF_8) }.getOrDefault("")
val participantIdentity = event.participant?.identity?.toString()
onDataReceived(payload, event.topic, participantIdentity)
} }
else -> {} else -> {}
} }
@@ -62,12 +102,15 @@ class LiveKitManager(appContext: Context, private val statusListener: (LiveKitSt
}.onFailure { e -> }.onFailure { e ->
Log.e("LiveKitManager", "LiveKit connect error: ${e.message}", e) Log.e("LiveKitManager", "LiveKit connect error: ${e.message}", e)
statusListener(LiveKitStatus.Failed) statusListener(LiveKitStatus.Failed)
scheduleReconnect()
} }
} }
} }
fun disconnect() { fun disconnect() {
autoReconnectEnabled = false
scope.launch { scope.launch {
reconnectJob?.cancel()
eventsJob?.cancel() eventsJob?.cancel()
runCatching { room?.disconnect() } runCatching { room?.disconnect() }
statusListener(LiveKitStatus.Disconnected) statusListener(LiveKitStatus.Disconnected)
@@ -75,10 +118,64 @@ class LiveKitManager(appContext: Context, private val statusListener: (LiveKitSt
} }
fun release() { fun release() {
autoReconnectEnabled = false
reconnectJob?.cancel()
eventsJob?.cancel() eventsJob?.cancel()
runCatching { room?.disconnect() } runCatching { room?.disconnect() }
room = null room = null
scope.cancel() scope.cancel()
statusListener(LiveKitStatus.Disconnected) statusListener(LiveKitStatus.Disconnected)
} }
fun setTtsMute(active: Boolean) {
if (ttsMuted == active) {
return
}
ttsMuted = active
Log.i("LiveKitManager", "LiveKit TTS mute changed: $active")
applyMicState()
}
fun setDetectionActive(active: Boolean) {
if (detectionActive == active) {
return
}
detectionActive = active
Log.i("LiveKitManager", "LiveKit detection active: $active")
applyMicState()
}
private fun scheduleReconnect() {
if (!autoReconnectEnabled) {
return
}
if (reconnectJob?.isActive == true) {
return
}
val url = lastUrl
val token = lastToken
if (url.isNullOrBlank() || token.isNullOrBlank()) {
return
}
reconnectJob = scope.launch {
delay(5000L)
connect(url, token, lastEnableMic, lastEnableCamera)
}
}
private fun applyMicState() {
val currentRoom = room ?: return
val enabled = lastEnableMic && detectionActive && !ttsMuted
Log.i(
"LiveKitManager",
"LiveKit mic state -> enabled=$enabled (config=$lastEnableMic, detection=$detectionActive, ttsMuted=$ttsMuted)"
)
scope.launch {
runCatching {
currentRoom.localParticipant.setMicrophoneEnabled(enabled)
}.onFailure { e ->
Log.w("LiveKitManager", "LiveKit mic toggle failed: ${e.message}", e)
}
}
}
} }

View File

@@ -4,27 +4,46 @@ import android.util.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.BufferedReader import java.io.BufferedReader
import java.io.InputStreamReader import java.io.InputStreamReader
import java.util.concurrent.CopyOnWriteArrayList
object LogManager { object LogManager {
private val ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val mainScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var logcatJob: Job? = null private var logcatJob: Job? = null
private var logcatProcess: Process? = null private var logcatProcess: Process? = null
private val logListeners = mutableListOf<(String) -> Unit>() private val logListeners = CopyOnWriteArrayList<(String) -> Unit>()
private var logcatTags: List<String> = emptyList()
private var logcatMinPriority: Char = 'D'
fun configureLogcat(tags: List<String>, minPriority: Char = 'D') {
logcatTags = tags.map { it.trim() }.filter { it.isNotEmpty() }
logcatMinPriority = normalizePriority(minPriority)
if (logcatJob?.isActive == true) {
stopLogcatListener()
startLogcatListener()
}
}
fun startLogcatListener() { fun startLogcatListener() {
if (logcatJob?.isActive == true) { if (logcatJob?.isActive == true) {
return return
} }
logcatJob = CoroutineScope(Dispatchers.IO).launch { val command = buildLogcatCommand()
logcatJob = ioScope.launch {
try { try {
logcatProcess = ProcessBuilder("logcat", "-v", "time").start() logcatProcess = ProcessBuilder(command).start()
val reader = BufferedReader(InputStreamReader(logcatProcess!!.inputStream)) val reader = BufferedReader(InputStreamReader(logcatProcess!!.inputStream))
var line: String? var line: String?
while (reader.readLine().also { line = it } != null) { while (reader.readLine().also { line = it } != null) {
CoroutineScope(Dispatchers.Main).launch { if (logListeners.isNotEmpty()) {
updateLog(line!!) val text = line ?: continue
mainScope.launch {
updateLog(text)
}
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -52,4 +71,19 @@ object LogManager {
private fun updateLog(logLine: String) { private fun updateLog(logLine: String) {
logListeners.forEach { it(logLine) } logListeners.forEach { it(logLine) }
} }
private fun buildLogcatCommand(): List<String> {
if (logcatTags.isEmpty()) {
return listOf("logcat", "-v", "time")
}
val filters = logcatTags.map { "$it:$logcatMinPriority" } + "*:S"
return listOf("logcat", "-v", "time", "-s") + filters
}
private fun normalizePriority(priority: Char): Char {
return when (priority.uppercaseChar()) {
'V', 'D', 'I', 'W', 'E', 'F', 'S' -> priority.uppercaseChar()
else -> 'D'
}
}
} }

View File

@@ -10,6 +10,7 @@ import android.os.Bundle
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import android.view.WindowManager import android.view.WindowManager
import android.view.View
import android.util.Base64 import android.util.Base64
import android.graphics.drawable.GradientDrawable import android.graphics.drawable.GradientDrawable
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
@@ -56,50 +57,35 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
private var mqttManager: MqttManager? = null private var mqttManager: MqttManager? = null
private val mainScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val mainScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private lateinit var prefs: SharedPreferences private lateinit var prefs: SharedPreferences
private val specialStateKey = "special_state"
private lateinit var navCon: NavController private lateinit var navCon: NavController
private lateinit var permissionManager: PermissionManager private lateinit var permissionManager: PermissionManager
private var liveKitManager: LiveKitManager? = null private var liveKitManager: LiveKitManager? = null
private val liveKitUrlKey = "livekit_url"
private val liveKitRoomKey = "livekit_room"
private val liveKitTokenKey = "livekit_token"
private val liveKitEnabledKey = "livekit_enabled"
private val liveKitPermissionRequestCode = 2001
private val liveKitUrlDefault = "ws://192.168.2.236:7880"
private val liveKitApiKeyDefault = "devkey"
private val liveKitApiSecretDefault = "secret"
private val liveKitRoomDefault = "temi-room"
private var isLiveKitConnected = false private var isLiveKitConnected = false
private var isMqttConnected = false private var isMqttConnected = false
private var lastArrivalLocation: String? = null private var lastArrivalLocation: String? = null
private var lastArrivalAt: Long = 0L private var lastArrivalAt: Long = 0L
private var currentTask: String = "" 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 closeDoorJob: Job? = null
private var detectConfirmJob: Job? = null
private var idleConfirmJob: Job? = null
private var latestDetectionState: Int = IDLE
private var isDetectionStable = false
private val detectedConfirmDelayMs = 800L
private val idleConfirmDelayMs = 5000L
private var blinkJob: Job? = null private var blinkJob: Job? = null
private var telemetryJob: Job? = null private var networkErrorJob: Job? = null
private lateinit var telemetryManager: TelemetryManager
private var receptionLocation: String = "" private lateinit var taskController: TaskController
private var receptionText: String = ""
private var receptionDestination: String = ""
private var patrolRoute: List<String> = emptyList()
private var patrolIndex: Int = 0
private var patrolLoopsRemaining: Int = 1
private var patrolWaitingSeconds: Int = 3
private var patrolNonStop: Boolean = false
private var patrolMoveJob: Job? = null
private var isLeavingHomeBase: Boolean = false
private var latestBatteryLevel: Int? = null
private var latestBatteryCharging: Boolean? = null
private var latestBattery2Level: Int? = null
private var latestPosition: Position? = null
private var latestMovementType: String? = null
private var latestMovementStatus: String? = null
private var lastTelemetrySentAt: Long = 0L
private var lastBatteryLowWarningAt: Long = 0L
private val batteryLowThreshold = 20
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -107,6 +93,19 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
binding = ActivityMainBinding.inflate(layoutInflater) binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
LogManager.configureLogcat(
tags = listOf(
"MainActivity",
"MqttManager",
"LiveKitManager",
"SettingsActivity",
"PermissionManager",
"LogManager",
"TaskController",
"TelemetryManager"
),
minPriority = 'I'
)
LogManager.startLogcatListener() LogManager.startLogcatListener()
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN) window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN)
robot = Robot.getInstance() robot = Robot.getInstance()
@@ -114,25 +113,33 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
permissionManager = PermissionManager(robot) permissionManager = PermissionManager(robot)
if (savedInstanceState != null) { if (savedInstanceState != null) {
currentTask = savedInstanceState.getString("currentTask", "") ?: "" initialTask = savedInstanceState.getString("currentTask", "") ?: ""
receptionLocation = savedInstanceState.getString("receptionLocation", "") ?: "" initialReceptionLocation = savedInstanceState.getString("receptionLocation", "") ?: ""
receptionText = savedInstanceState.getString("receptionText", "") ?: "" initialReceptionText = savedInstanceState.getString("receptionText", "") ?: ""
receptionDestination = savedInstanceState.getString("receptionDestination", "") ?: "" initialReceptionDestination = savedInstanceState.getString("receptionDestination", "") ?: ""
initialNotificationLocation = savedInstanceState.getString("notificationLocation", "") ?: ""
initialNotificationText = savedInstanceState.getString("notificationText", "") ?: ""
lastArrivalLocation = savedInstanceState.getString("lastArrivalLocation") lastArrivalLocation = savedInstanceState.getString("lastArrivalLocation")
} }
if (currentTask == "special") { if (initialTask == "special") {
currentTask = "" initialTask = ""
} }
prefs = getSharedPreferences("app_prefs", Context.MODE_PRIVATE) prefs = getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
prefs.registerOnSharedPreferenceChangeListener(this) prefs.registerOnSharedPreferenceChangeListener(this)
liveKitManager = LiveKitManager(applicationContext) { status -> liveKitManager = LiveKitManager(
when (status) { applicationContext,
LiveKitStatus.Connected -> setLiveKitStatus(true) statusListener = { status ->
LiveKitStatus.Disconnected -> setLiveKitStatus(false) when (status) {
LiveKitStatus.Failed -> setLiveKitStatus(false) LiveKitStatus.Connected -> setLiveKitStatus(true)
LiveKitStatus.Disconnected -> setLiveKitStatus(false)
LiveKitStatus.Failed -> setLiveKitStatus(false)
}
},
onDataReceived = { payload, topic, participant ->
handleAsrPayload(payload, topic, participant)
} }
} )
if (lastArrivalLocation == null) { if (lastArrivalLocation == null) {
lastArrivalLocation = prefs.getString("current_location", null) lastArrivalLocation = prefs.getString("current_location", null)
} }
@@ -140,20 +147,53 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
startActivity(Intent(this, SettingsActivity::class.java)) startActivity(Intent(this, SettingsActivity::class.java))
} }
if (currentTask == "patrol") { taskController = TaskController(
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.ANGRY scope = mainScope,
} else { navController = navCon,
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.SMILE getLastArrivalLocation = { lastArrivalLocation },
} speak = { text ->
val ttsRequest = TtsRequest.create(text, false, language = TtsRequest.Language.ZH_CN)
robot.speak(ttsRequest)
},
setEmoji = { expression ->
binding.animatedEmojiView.currentExpression = expression
},
setReceptionButtonVisible = { visible ->
binding.btnReception.visibility = if (visible) android.view.View.VISIBLE else android.view.View.GONE
}
)
taskController.restoreState(
initialTask,
initialReceptionLocation,
initialReceptionText,
initialReceptionDestination,
initialNotificationLocation,
initialNotificationText
)
binding.btnReception.setOnClickListener { binding.btnReception.setOnClickListener {
val destination = receptionDestination val destination = taskController.confirmReception()
stopReceptionMode() if (destination.isNullOrBlank()) {
return@setOnClickListener
}
val ttsRequest = TtsRequest.create("接待任务确认,请跟我来", false, language = TtsRequest.Language.ZH_CN) val ttsRequest = TtsRequest.create("接待任务确认,请跟我来", false, language = TtsRequest.Language.ZH_CN)
robot.speak(ttsRequest) robot.speak(ttsRequest)
navCon.goTo(destination, false) navCon.goTo(destination, false)
} }
telemetryManager = TelemetryManager(
scope = mainScope,
taskProvider = { taskController.currentTask },
locationProvider = { lastArrivalLocation },
mqttConnectedProvider = { isMqttConnected },
liveKitConnectedProvider = { isLiveKitConnected },
publish = { topic, payload -> mqttManager?.publish(topic, payload) },
onLowBattery = { _ ->
val ttsRequest = TtsRequest.create("电量低,请及时充电。", false, language = TtsRequest.Language.ZH_CN)
robot.speak(ttsRequest)
}
)
updateMqttConnection() updateMqttConnection()
updateLiveKitStatusSnapshot() updateLiveKitStatusSnapshot()
} }
@@ -177,7 +217,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
} }
updateLiveKitConnection() updateLiveKitConnection()
startBlinking() startBlinking()
startTelemetry() telemetryManager.start()
} }
override fun onStop() { override fun onStop() {
@@ -194,7 +234,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
// mqttManager?.disconnect() // Keep MQTT alive in background/settings // mqttManager?.disconnect() // Keep MQTT alive in background/settings
liveKitManager?.disconnect() liveKitManager?.disconnect()
stopBlinking() stopBlinking()
stopTelemetry() telemetryManager.stop()
} }
override fun onDestroy() { override fun onDestroy() {
@@ -234,21 +274,26 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
TtsRequest.Status.STARTED -> { TtsRequest.Status.STARTED -> {
Log.i("MainActivity", "TTS started") Log.i("MainActivity", "TTS started")
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.TALKING binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.TALKING
liveKitManager?.setTtsMute(true)
} }
TtsRequest.Status.COMPLETED, TtsRequest.Status.COMPLETED,
TtsRequest.Status.CANCELED -> { TtsRequest.Status.CANCELED,
TtsRequest.Status.NOT_ALLOWED -> {
Log.i("MainActivity", "TTS finished: ${ttsRequest.speech}") Log.i("MainActivity", "TTS finished: ${ttsRequest.speech}")
if (currentTask == "patrol") { if (taskController.currentTask == "patrol") {
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.ANGRY binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.ANGRY
} else { } else {
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.SMILE binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.SMILE
} }
liveKitManager?.setTtsMute(false)
} }
TtsRequest.Status.ERROR -> { TtsRequest.Status.ERROR -> {
Log.e("MainActivity", "TTS error: ${ttsRequest.speech}") Log.e("MainActivity", "TTS error: ${ttsRequest.speech}")
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.SAD binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.SAD
liveKitManager?.setTtsMute(false)
showNetworkErrorBanner()
} }
else -> { /* PENDING, PROCESSING, NOT_ALLOWED */ } else -> { /* PENDING, PROCESSING */ }
} }
} }
@@ -256,36 +301,15 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
if (batteryData == null) { if (batteryData == null) {
return return
} }
latestBatteryLevel = batteryData.level telemetryManager.onBatteryStatusChanged(batteryData)
latestBatteryCharging = batteryData.isCharging
latestBattery2Level = batteryData.battery2Level
val now = System.currentTimeMillis()
if (batteryData.level <= batteryLowThreshold && batteryData.isCharging.not()) {
if (now - lastBatteryLowWarningAt > 10 * 60 * 1000L) {
lastBatteryLowWarningAt = now
val ttsRequest = TtsRequest.create("电量低,请及时充电。", false, language = TtsRequest.Language.ZH_CN)
robot.speak(ttsRequest)
publishEvent(
"battery_low",
JSONObject()
.put("level", batteryData.level)
.put("charging", batteryData.isCharging)
.put("battery2Level", batteryData.battery2Level)
)
}
}
publishStatusSnapshot("battery")
} }
override fun onMovementStatusChanged(type: String, status: String) { override fun onMovementStatusChanged(type: String, status: String) {
latestMovementType = type telemetryManager.onMovementStatusChanged(type, status)
latestMovementStatus = status
publishStatusSnapshot("movement")
} }
override fun onCurrentPositionChanged(position: Position) { override fun onCurrentPositionChanged(position: Position) {
latestPosition = position telemetryManager.onCurrentPositionChanged(position)
publishStatusSnapshot("position")
} }
override fun onGoToLocationStatusChanged(location: String, status: String, descriptionId: Int, description: String) { override fun onGoToLocationStatusChanged(location: String, status: String, descriptionId: Int, description: String) {
@@ -295,23 +319,29 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
return return
} }
if (isAbort) { if (isAbort) {
isLeavingHomeBase = false taskController.clearLeavingHomeBase()
endNonSpecialTask("goTo aborted: $location, status=$status") taskController.endNonSpecialTask("goTo aborted: $location, status=$status")
return return
} }
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (lastArrivalLocation == location && now - lastArrivalAt < 5000L) { if (lastArrivalLocation == location && now - lastArrivalAt < 5000L) {
return return
} }
isLeavingHomeBase = false taskController.clearLeavingHomeBase()
lastArrivalLocation = location lastArrivalLocation = location
lastArrivalAt = now lastArrivalAt = now
prefs.edit().putString("current_location", location).apply() prefs.edit().putString("current_location", location).apply()
if (currentTask == "patrol") { if (normalizeLocation(location) == "homebase") {
handlePatrolArrival(location) navCon.tiltAngle(20)
} }
if (isSpecialModeEnabled() && currentTask.isEmpty()) { if (taskController.currentTask == "patrol") {
Log.i("MainActivity", "Special task mode: arrival announcement skipped at $location.") taskController.handlePatrolArrival(location)
}
if (taskController.handleNotificationArrival(location)) {
return
}
if (isSpecialStateEnabled() && taskController.currentTask.isEmpty()) {
Log.i("MainActivity", "Special state: arrival announcement skipped at $location.")
return return
} }
val text = "已到达$location" val text = "已到达$location"
@@ -321,74 +351,89 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
} }
override fun onDetectionStateChanged(state: Int) { override fun onDetectionStateChanged(state: Int) {
if (currentTask == "patrol" && state == DETECTED) { latestDetectionState = state
val ttsRequest = TtsRequest.create("别妨碍我,我正在巡逻呢", false, language = TtsRequest.Language.ZH_CN) Log.i("MainActivity", "Detection state changed: $state")
robot.speak(ttsRequest) when (state) {
return DETECTED -> {
} idleConfirmJob?.cancel()
if (isDetectionStable || detectConfirmJob?.isActive == true) {
if (currentTask == "reception" && lastArrivalLocation == receptionLocation) { return
when (state) { }
DETECTED -> { detectConfirmJob = mainScope.launch {
if (binding.btnReception.visibility != android.view.View.VISIBLE) { delay(detectedConfirmDelayMs)
binding.btnReception.visibility = android.view.View.VISIBLE if (latestDetectionState == DETECTED && !isDetectionStable) {
val ttsRequest = TtsRequest.create(receptionText, false, language = TtsRequest.Language.ZH_CN) isDetectionStable = true
robot.speak(ttsRequest) handleStableDetectionStateChanged(DETECTED)
Log.i("MainActivity", "Reception: Person detected (new session) at $receptionLocation")
} }
} }
IDLE -> { }
binding.btnReception.visibility = android.view.View.GONE IDLE -> {
Log.i("MainActivity", "Reception: Person left (IDLE)") detectConfirmJob?.cancel()
if (!isDetectionStable) {
return
}
idleConfirmJob?.cancel()
idleConfirmJob = mainScope.launch {
delay(idleConfirmDelayMs)
if (latestDetectionState == IDLE && isDetectionStable) {
isDetectionStable = false
handleStableDetectionStateChanged(IDLE)
}
} }
} }
} }
}
// Home Base logic private fun handleStableDetectionStateChanged(state: Int) {
if (currentTask == "" && lastArrivalLocation?.lowercase() == "home base" && !isLeavingHomeBase) { Log.i("MainActivity", "Stable detection state: $state")
// Check if special task mode is enabled, if so, skip door logic liveKitManager?.setDetectionActive(state == DETECTED)
if (isSpecialModeEnabled() && currentTask.isEmpty()) { if (taskController.handleDetectionStateChanged(state)) {
Log.i("MainActivity", "Special task mode: Door logic skipped at Home Base.") Log.i("MainActivity", "what the f**k")
return return
} }
val isSpecialState = isSpecialStateEnabled()
val isIdleTask = taskController.currentTask.isEmpty() || taskController.currentTask == "speech"
val atHomeBase = normalizeLocation(lastArrivalLocation) == "homebase"
val canHandleDoor = isIdleTask && atHomeBase && !taskController.isLeavingHomeBase && !isSpecialState
if (canHandleDoor) {
when (state) { when (state) {
DETECTED -> { DETECTED -> {
closeDoorJob?.cancel() closeDoorJob?.cancel()
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.WINK binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.WINK
val ttsRequest = TtsRequest.create("正在为你开门,请稍等。", false, language = TtsRequest.Language.ZH_CN) val ttsRequest = TtsRequest.create("正在为你开门,请稍等。", false, language = TtsRequest.Language.ZH_CN)
robot.speak(ttsRequest) robot.speak(ttsRequest)
// mainScope.launch { mainScope.launch {
// HttpManager.workflow_execute( val result = HttpManager.workflow_execute(
// context = this@MainActivity, context = this@MainActivity,
// apiKey = "wf_865e80f5fc1a4a319474a21d47470863", apiKey = "wf_865e80f5fc1a4a319474a21d47470863",
// workflowId = "2031297462423851009", workflowId = "2031297462423851009",
// inputs = emptyMap<String, Any>() inputs = emptyMap<String, Any>()
// ) )
// } if (result == null) {
showNetworkErrorBanner()
}
}
} }
IDLE -> { IDLE -> {
closeDoorJob = mainScope.launch { closeDoorJob = mainScope.launch {
delay(5000)
val ttsRequest = TtsRequest.create("正准备关门,请小心被夹。", false, language = TtsRequest.Language.ZH_CN) val ttsRequest = TtsRequest.create("正准备关门,请小心被夹。", false, language = TtsRequest.Language.ZH_CN)
robot.speak(ttsRequest) robot.speak(ttsRequest)
// mainScope.launch { mainScope.launch {
// HttpManager.workflow_execute( val result = HttpManager.workflow_execute(
// context = this@MainActivity, context = this@MainActivity,
// apiKey = "wf_c02aa853371345dbb29572641d083c24", apiKey = "wf_c02aa853371345dbb29572641d083c24",
// workflowId = "2031634633458520065", workflowId = "2031634633458520065",
// inputs = emptyMap<String, Any>() inputs = emptyMap<String, Any>()
// ) )
// } if (result == null) {
showNetworkErrorBanner()
}
}
} }
} }
} }
} }
if (lastArrivalLocation?.lowercase() != "home base" && currentTask.isEmpty() && state == DETECTED) { if (isIdleTask && !atHomeBase && state == DETECTED && !isSpecialState) {
if (isSpecialModeEnabled()) {
Log.i("MainActivity", "Special task mode enabled (pref check), skipping greeting.")
return
}
val hour = java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY) val hour = java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY)
val greeting = when (hour) { val greeting = when (hour) {
in 6..11 -> "早上好" in 6..11 -> "早上好"
@@ -401,148 +446,37 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
} }
} }
private fun normalizeLocation(value: String?): String {
return value.orEmpty()
.trim()
.lowercase()
.replace(" ", "")
.replace("_", "")
.replace("-", "")
}
fun startReceptionMode(location: String, text: String, destination: String) { fun startReceptionMode(location: String, text: String, destination: String) {
setCurrentTask("reception") taskController.startReceptionMode(location, text, destination)
receptionLocation = location
receptionText = text
receptionDestination = destination
Log.i("MainActivity", "Reception mode started: location=$location, text=$text, dest=$destination")
if (lastArrivalLocation != location) {
navCon.goTo(location, false)
}
}
private fun stopReceptionMode() {
setCurrentTask("")
receptionLocation = ""
receptionText = ""
receptionDestination = ""
binding.btnReception.visibility = android.view.View.GONE
Log.i("MainActivity", "Reception mode stopped")
}
private fun endNonSpecialTask(reason: String) {
if (currentTask.isEmpty()) {
return
}
if (currentTask != "patrol" && currentTask != "reception") {
return
}
Log.i("MainActivity", "Ending task '$currentTask': $reason")
setCurrentTask("")
} }
fun startPatrolMode(route: List<String>, times: Int = 1, waiting: Int = 3, nonStop: Boolean = false) { fun startPatrolMode(route: List<String>, times: Int = 1, waiting: Int = 3, nonStop: Boolean = false) {
if (route.isEmpty()) { taskController.startPatrolMode(route, times, waiting, nonStop)
setCurrentTask("")
return
}
patrolRoute = route
patrolIndex = 0
patrolLoopsRemaining = times.coerceAtLeast(1)
patrolWaitingSeconds = waiting.coerceAtLeast(0)
patrolNonStop = nonStop
patrolMoveJob?.cancel()
setCurrentTask("patrol")
Log.i("MainActivity", "Patrol mode started: route=${route.joinToString()}")
moveToCurrentPatrolTarget()
} }
private fun handlePatrolArrival(location: String) { fun startNotificationMode(location: String, text: String) {
if (patrolRoute.isEmpty()) { taskController.startNotificationMode(location, text)
return
}
val matchIndex = patrolRoute.indexOfFirst { it.equals(location, ignoreCase = true) }
if (matchIndex == -1) {
return
}
if (matchIndex >= patrolIndex) {
patrolIndex = matchIndex + 1
}
if (patrolIndex >= patrolRoute.size) {
patrolLoopsRemaining -= 1
if (patrolLoopsRemaining <= 0) {
Log.i("MainActivity", "Patrol route completed: ${patrolRoute.joinToString()}")
setCurrentTask("")
return
}
patrolIndex = 0
}
scheduleNextPatrolMove()
}
private fun moveToCurrentPatrolTarget() {
if (currentTask != "patrol") {
return
}
val target = patrolRoute.getOrNull(patrolIndex) ?: return
if (lastArrivalLocation?.equals(target, ignoreCase = true) == true) {
patrolIndex += 1
if (patrolIndex >= patrolRoute.size) {
Log.i("MainActivity", "Patrol route completed: ${patrolRoute.joinToString()}")
setCurrentTask("")
return
}
moveToCurrentPatrolTarget()
return
}
Log.i("MainActivity", "Patrol moving to next target: $target")
if (lastArrivalLocation?.equals("home base", ignoreCase = true) == true &&
!target.equals("home base", ignoreCase = true)
) {
isLeavingHomeBase = true
}
navCon.goTo(target, false)
}
private fun scheduleNextPatrolMove() {
patrolMoveJob?.cancel()
if (patrolNonStop || patrolWaitingSeconds <= 0) {
moveToCurrentPatrolTarget()
return
}
patrolMoveJob = mainScope.launch {
delay(patrolWaitingSeconds * 1000L)
moveToCurrentPatrolTarget()
}
} }
fun setCurrentTask(task: String) { fun setCurrentTask(task: String) {
val finalTask = task taskController.setCurrentTask(task)
// Avoid re-setting the same task
if (currentTask == finalTask) {
return
}
currentTask = finalTask
Log.i("MainActivity", "Current task set to: '$finalTask'")
if (finalTask == "patrol") {
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.ANGRY
} else {
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.SMILE
}
if (finalTask != "patrol") {
patrolRoute = emptyList()
patrolIndex = 0
patrolLoopsRemaining = 1
patrolWaitingSeconds = 3
patrolNonStop = false
patrolMoveJob?.cancel()
patrolMoveJob = null
}
} }
fun markSpeechTaskActive() { fun markSpeechTaskActive() {
if (currentTask.isEmpty() || currentTask == "speech") { taskController.markSpeechTaskActive()
setCurrentTask("speech")
}
} }
fun clearSpeechTaskIfActive() { fun clearSpeechTaskIfActive() {
if (currentTask == "speech") { taskController.clearSpeechTaskIfActive()
setCurrentTask("")
}
} }
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
@@ -555,17 +489,24 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
lastArrivalLocation = sharedPreferences?.getString("current_location", null) lastArrivalLocation = sharedPreferences?.getString("current_location", null)
Log.i("MainActivity", "Current location updated manually: $lastArrivalLocation") Log.i("MainActivity", "Current location updated manually: $lastArrivalLocation")
} }
if (key == "special_task_mode") { if (key == specialStateKey) {
val isSpecial = sharedPreferences?.getBoolean("special_task_mode", false) == true val isSpecial = isSpecialStateEnabled()
Log.i("MainActivity", "Special mode pref changed: $isSpecial, currentTask: $currentTask") Log.i("MainActivity", "Special state pref changed: $isSpecial, currentTask: ${taskController.currentTask}")
} }
if (key == liveKitUrlKey || key == liveKitRoomKey || key == liveKitTokenKey || key == liveKitEnabledKey) { if (key == LiveKitManager.PREF_KEY_URL ||
key == LiveKitManager.PREF_KEY_ROOM ||
key == LiveKitManager.PREF_KEY_TOKEN ||
key == LiveKitManager.PREF_KEY_ENABLED
) {
updateLiveKitConnection() updateLiveKitConnection()
} }
} }
private fun isSpecialModeEnabled(): Boolean { private fun isSpecialStateEnabled(): Boolean {
return ::prefs.isInitialized && prefs.getBoolean("special_task_mode", false) if (!::prefs.isInitialized) {
return false
}
return prefs.getBoolean(specialStateKey, false)
} }
private fun updateMqttConnection() { private fun updateMqttConnection() {
@@ -585,14 +526,14 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
} }
private fun updateLiveKitConnection() { private fun updateLiveKitConnection() {
val enabled = prefs.getBoolean(liveKitEnabledKey, true) val enabled = prefs.getBoolean(LiveKitManager.PREF_KEY_ENABLED, true)
val url = resolveLiveKitUrl() val url = resolveLiveKitUrl()
val room = prefs.getString(liveKitRoomKey, liveKitRoomDefault).orEmpty() val room = prefs.getString(LiveKitManager.PREF_KEY_ROOM, LiveKitManager.DEFAULT_ROOM).orEmpty()
val savedToken = prefs.getString(liveKitTokenKey, "").orEmpty() val savedToken = prefs.getString(LiveKitManager.PREF_KEY_TOKEN, "").orEmpty()
val token = if (savedToken.isBlank()) { val token = if (savedToken.isBlank()) {
buildLiveKitToken( buildLiveKitToken(
apiKey = liveKitApiKeyDefault, apiKey = LiveKitManager.DEFAULT_API_KEY,
apiSecret = liveKitApiSecretDefault, apiSecret = LiveKitManager.DEFAULT_API_SECRET,
room = room, room = room,
identity = buildLiveKitIdentity() identity = buildLiveKitIdentity()
) )
@@ -630,7 +571,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
ActivityCompat.requestPermissions( ActivityCompat.requestPermissions(
this, this,
arrayOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA), arrayOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA),
liveKitPermissionRequestCode LiveKitManager.PERMISSION_REQUEST_CODE
) )
} }
@@ -640,7 +581,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
grantResults: IntArray grantResults: IntArray
) { ) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults) super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == liveKitPermissionRequestCode) { if (requestCode == LiveKitManager.PERMISSION_REQUEST_CODE) {
val granted = grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED } val granted = grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED }
if (granted) { if (granted) {
updateLiveKitConnection() updateLiveKitConnection()
@@ -698,9 +639,9 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
} }
private fun updateLiveKitStatusSnapshot() { private fun updateLiveKitStatusSnapshot() {
val enabled = prefs.getBoolean(liveKitEnabledKey, true) val enabled = prefs.getBoolean(LiveKitManager.PREF_KEY_ENABLED, true)
val url = resolveLiveKitUrl() val url = resolveLiveKitUrl()
val room = prefs.getString(liveKitRoomKey, liveKitRoomDefault).orEmpty() val room = prefs.getString(LiveKitManager.PREF_KEY_ROOM, LiveKitManager.DEFAULT_ROOM).orEmpty()
if (!enabled) { if (!enabled) {
setLiveKitStatus(false) setLiveKitStatus(false)
return return
@@ -725,7 +666,48 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
isMqttConnected = connected isMqttConnected = connected
updateConnectionIndicator() updateConnectionIndicator()
if (connected) { if (connected) {
publishStatusSnapshot("mqtt_connected", true) telemetryManager.publishStatusSnapshot("mqtt_connected", true)
}
}
private fun handleAsrPayload(payload: String, topic: String?, participant: String?) {
val text = extractAsrText(payload) ?: return
val topicLabel = topic ?: "default"
val participantLabel = participant ?: "unknown"
Log.i("MainActivity", "ASR received: topic=$topicLabel, participant=$participantLabel, text=$text")
val data = JSONObject()
.put("type", "asr")
.put("text", text)
.put("topic", topicLabel)
.put("participant", participantLabel)
.put("ts", System.currentTimeMillis())
mqttManager?.publish("robot/asr", data.toString())
}
private fun extractAsrText(payload: String): String? {
val trimmed = payload.trim()
if (trimmed.isEmpty()) {
return null
}
if (trimmed.startsWith("{")) {
return runCatching {
val obj = JSONObject(trimmed)
val text = obj.optString("text", "")
.ifBlank { obj.optString("transcript", "") }
.ifBlank { obj.optString("asr", "") }
.ifBlank { obj.optString("content", "") }
text.ifBlank { trimmed }
}.getOrDefault(trimmed)
}
return trimmed
}
private fun showNetworkErrorBanner() {
networkErrorJob?.cancel()
binding.tvNetworkError.visibility = View.VISIBLE
networkErrorJob = mainScope.launch {
delay(5000L)
binding.tvNetworkError.visibility = View.GONE
} }
} }
@@ -741,15 +723,11 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
} }
private fun resolveLiveKitUrl(): String { private fun resolveLiveKitUrl(): String {
val savedUrl = prefs.getString(liveKitUrlKey, "").orEmpty().trim() val savedUrl = prefs.getString(LiveKitManager.PREF_KEY_URL, "").orEmpty().trim()
if (savedUrl.isNotEmpty()) { if (savedUrl.isNotEmpty()) {
return savedUrl return savedUrl
} }
val ip = prefs.getString("network_ip", "").orEmpty().trim() return LiveKitManager.DEFAULT_URL
if (ip.isNotEmpty()) {
return "ws://$ip:7880"
}
return liveKitUrlDefault
} }
private fun startBlinking() { private fun startBlinking() {
@@ -774,92 +752,18 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
blinkJob?.cancel() blinkJob?.cancel()
} }
private fun startTelemetry() {
stopTelemetry()
telemetryJob = mainScope.launch {
while (isActive) {
publishStatusSnapshot("heartbeat", true)
delay(15000L)
}
}
}
private fun stopTelemetry() {
telemetryJob?.cancel()
telemetryJob = null
}
fun publishStatusSnapshot(reason: String, force: Boolean = false) { fun publishStatusSnapshot(reason: String, force: Boolean = false) {
val now = System.currentTimeMillis() telemetryManager.publishStatusSnapshot(reason, force)
if (!force && now - lastTelemetrySentAt < 5000L) {
return
}
lastTelemetrySentAt = now
val payload = JSONObject()
.put("type", "status")
.put("reason", reason)
.put("ts", now)
.put("task", currentTask)
.put("location", lastArrivalLocation ?: JSONObject.NULL)
.put("mqttConnected", isMqttConnected)
.put("liveKitConnected", isLiveKitConnected)
val batteryJson = JSONObject()
if (latestBatteryLevel != null) {
batteryJson.put("level", latestBatteryLevel)
}
if (latestBatteryCharging != null) {
batteryJson.put("charging", latestBatteryCharging)
}
if (latestBattery2Level != null) {
batteryJson.put("battery2Level", latestBattery2Level)
}
if (batteryJson.length() > 0) {
payload.put("battery", batteryJson)
}
val movementJson = JSONObject()
if (!latestMovementType.isNullOrBlank()) {
movementJson.put("type", latestMovementType)
}
if (!latestMovementStatus.isNullOrBlank()) {
movementJson.put("status", latestMovementStatus)
}
if (movementJson.length() > 0) {
payload.put("movement", movementJson)
}
val position = latestPosition
if (position != null) {
payload.put(
"position",
JSONObject()
.put("x", position.x)
.put("y", position.y)
.put("yaw", position.yaw)
.put("tiltAngle", position.tiltAngle)
.put("inMapArea", position.isInMapArea)
)
}
mqttManager?.publish("robot/status", payload.toString())
}
private fun publishEvent(event: String, data: JSONObject) {
val payload = JSONObject()
.put("type", "event")
.put("event", event)
.put("ts", System.currentTimeMillis())
.put("data", data)
mqttManager?.publish("robot/event", payload.toString())
} }
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
outState.putString("currentTask", currentTask) outState.putString("currentTask", taskController.currentTask)
outState.putString("receptionLocation", receptionLocation) outState.putString("receptionLocation", taskController.getReceptionLocation())
outState.putString("receptionText", receptionText) outState.putString("receptionText", taskController.getReceptionText())
outState.putString("receptionDestination", receptionDestination) outState.putString("receptionDestination", taskController.getReceptionDestination())
outState.putString("notificationLocation", taskController.getNotificationLocation())
outState.putString("notificationText", taskController.getNotificationText())
outState.putString("lastArrivalLocation", lastArrivalLocation) outState.putString("lastArrivalLocation", lastArrivalLocation)
} }

View File

@@ -46,6 +46,7 @@ class MqttManager(
private val ttsLanguageMap = mutableMapOf<TtsRequest, TtsRequest.Language>() private val ttsLanguageMap = mutableMapOf<TtsRequest, TtsRequest.Language>()
private var currentStreamSessionId: String? = null private var currentStreamSessionId: String? = null
private var currentStreamMessageId: String? = null private var currentStreamMessageId: String? = null
private var danceJob: Job? = null
init { init {
try { try {
@@ -271,7 +272,7 @@ class MqttManager(
when (action) { when (action) {
"recharge" -> { "recharge" -> {
speak("前往充电桩", "zh") speak("前往充电桩", "zh")
navController.recharge() navController.recharge()
} }
"goto" -> { "goto" -> {
val location = obj.optString("location", obj.optString("target", "")) val location = obj.optString("location", obj.optString("target", ""))
@@ -290,10 +291,36 @@ class MqttManager(
} }
processStreamText(text, lang) processStreamText(text, lang)
} }
"notification" -> {
val location = obj.optString("location", obj.optString("target", ""))
val text = obj.optString("text", obj.optString("content", ""))
scope.launch(Dispatchers.Main) {
(context as? MainActivity)?.startNotificationMode(location, text)
}
}
"repose" -> { "repose" -> {
val ok = navController.repose() val ok = navController.repose()
Log.i(TAG, "Repose command sent: $ok") Log.i(TAG, "Repose command sent: $ok")
} }
"turn" -> {
val degrees = obj.optInt("degrees", obj.optInt("angle", 0))
val speed = obj.optDouble("speed", 1.0).toFloat().coerceIn(0.0f, 1.0f)
if (degrees == 0) {
Log.w(TAG, "Turn ignored: degrees=0")
} else {
val ok = navController.turnBy(degrees, speed)
Log.i(TAG, "Turn command sent: degrees=$degrees, speed=$speed, result=$ok")
}
}
"tilt" -> {
val degrees = obj.optInt("degrees", obj.optInt("angle", 0))
val speed = obj.optDouble("speed", 1.0).toFloat().coerceIn(0.0f, 1.0f)
val ok = navController.tiltAngle(degrees, speed)
Log.i(TAG, "Tilt command sent: degrees=$degrees, speed=$speed, result=$ok")
}
"dance" -> {
startDance()
}
"stop" -> { "stop" -> {
navController.stop() navController.stop()
pauseTts() pauseTts()
@@ -317,7 +344,7 @@ class MqttManager(
speak("接到巡逻任务", "zh") speak("接到巡逻任务", "zh")
val flag = obj.optBoolean("flag", true) val flag = obj.optBoolean("flag", true)
val times = obj.optInt("times", 1) val times = obj.optInt("times", 1)
val waiting = obj.optInt("waiting", obj.optInt("wait", 3)) val waiting = obj.optInt("waiting", obj.optInt("wait", 5))
val nonStop = obj.optBoolean("nonStop", obj.optBoolean("non_stop", false)) val nonStop = obj.optBoolean("nonStop", obj.optBoolean("non_stop", false))
var patrolLocations: List<String> = emptyList() var patrolLocations: List<String> = emptyList()
if (flag) { if (flag) {
@@ -359,6 +386,45 @@ class MqttManager(
} }
} }
private fun startDance() {
danceJob?.cancel()
danceJob = scope.launch(Dispatchers.Main) {
val endAt = System.currentTimeMillis() + 20000L
val speed = 0.6f
navController.tiltAngle(20, speed)
while (isActive && System.currentTimeMillis() < endAt) {
when ((1..4).random()) {
1 -> {
val delta = listOf(-15, -10, -5, 5, 10, 15).random()
navController.tiltBy(delta, speed)
delay(500L)
}
2 -> {
val angle = listOf(-120, -90, -60, 60, 90, 120).random()
navController.turnBy(angle, speed)
delay(600L)
}
3 -> {
val forward = listOf(0.6f, 0.8f, 1.0f).random()
navController.skidJoy(forward, 0.0f)
delay(500L)
navController.skidJoy(0.0f, 0.0f)
delay(300L)
}
else -> {
val rotate = listOf(-1.0f, -0.8f, -0.6f, 0.6f, 0.8f, 1.0f).random()
navController.skidJoy(0.0f, rotate)
delay(500L)
navController.skidJoy(0.0f, 0.0f)
delay(300L)
}
}
}
navController.skidJoy(0.0f, 0.0f)
navController.tiltAngle(20, speed)
}
}
private fun processStreamText(text: String, langCode: String?) { private fun processStreamText(text: String, langCode: String?) {
lastStreamLangCode = langCode lastStreamLangCode = langCode
speechBuffer.append(text) speechBuffer.append(text)

View File

@@ -23,6 +23,26 @@ class NavController(private val robot: Robot) {
return true return true
} }
fun turnBy(degrees: Int, speed: Float = 1.0f): Boolean {
robot.turnBy(degrees, speed)
return true
}
fun tiltAngle(degrees: Int, speed: Float = 1.0f): Boolean {
robot.tiltAngle(degrees, speed)
return true
}
fun tiltBy(degrees: Int, speed: Float = 1.0f): Boolean {
robot.tiltBy(degrees, speed)
return true
}
fun skidJoy(x: Float, y: Float): Boolean {
robot.skidJoy(x, y)
return true
}
fun getAllLocations(): List<String> { fun getAllLocations(): List<String> {
return robot.locations return robot.locations
} }

View File

@@ -28,14 +28,9 @@ class SettingsActivity : AppCompatActivity() {
private lateinit var robot: Robot private lateinit var robot: Robot
private lateinit var locationAdapter: ArrayAdapter<String> private lateinit var locationAdapter: ArrayAdapter<String>
private val currentLocationKey = "current_location" private val currentLocationKey = "current_location"
private val specialStateKey = "special_state"
private lateinit var prefs: SharedPreferences private lateinit var prefs: SharedPreferences
private val liveKitUrlKey = "livekit_url"
private val liveKitRoomKey = "livekit_room"
private val liveKitTokenKey = "livekit_token"
private val liveKitEnabledKey = "livekit_enabled"
private val agentDempIdKey = "agent_demp_id" private val agentDempIdKey = "agent_demp_id"
private val liveKitUrlDefault = "ws://192.168.2.236:7880"
private val liveKitRoomDefault = "temi-room"
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -50,10 +45,10 @@ class SettingsActivity : AppCompatActivity() {
binding.etIpAddress.setText(savedIp) binding.etIpAddress.setText(savedIp)
val savedDempId = prefs.getString(agentDempIdKey, "") val savedDempId = prefs.getString(agentDempIdKey, "")
binding.etAgentDempId.setText(savedDempId) binding.etAgentDempId.setText(savedDempId)
val savedLiveKitUrl = prefs.getString(liveKitUrlKey, resolveLiveKitUrl()) val savedLiveKitUrl = prefs.getString(LiveKitManager.PREF_KEY_URL, resolveLiveKitUrl())
val savedLiveKitRoom = prefs.getString(liveKitRoomKey, liveKitRoomDefault) val savedLiveKitRoom = prefs.getString(LiveKitManager.PREF_KEY_ROOM, LiveKitManager.DEFAULT_ROOM)
val savedLiveKitToken = prefs.getString(liveKitTokenKey, "") val savedLiveKitToken = prefs.getString(LiveKitManager.PREF_KEY_TOKEN, "")
val isLiveKitEnabled = prefs.getBoolean(liveKitEnabledKey, true) val isLiveKitEnabled = prefs.getBoolean(LiveKitManager.PREF_KEY_ENABLED, true)
binding.etLiveKitUrl.setText(savedLiveKitUrl) binding.etLiveKitUrl.setText(savedLiveKitUrl)
binding.etLiveKitRoom.setText(savedLiveKitRoom) binding.etLiveKitRoom.setText(savedLiveKitRoom)
binding.etLiveKitToken.setText(savedLiveKitToken) binding.etLiveKitToken.setText(savedLiveKitToken)
@@ -84,7 +79,7 @@ class SettingsActivity : AppCompatActivity() {
} }
binding.switchLiveKitAuto.setOnCheckedChangeListener { _, isChecked -> binding.switchLiveKitAuto.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean(liveKitEnabledKey, isChecked).apply() prefs.edit().putBoolean(LiveKitManager.PREF_KEY_ENABLED, isChecked).apply()
} }
binding.btnLiveKitSave.setOnClickListener { binding.btnLiveKitSave.setOnClickListener {
@@ -94,10 +89,10 @@ class SettingsActivity : AppCompatActivity() {
val token = binding.etLiveKitToken.text?.toString()?.trim().orEmpty() val token = binding.etLiveKitToken.text?.toString()?.trim().orEmpty()
val enabled = binding.switchLiveKitAuto.isChecked val enabled = binding.switchLiveKitAuto.isChecked
prefs.edit() prefs.edit()
.putString(liveKitUrlKey, url) .putString(LiveKitManager.PREF_KEY_URL, url)
.putString(liveKitRoomKey, room) .putString(LiveKitManager.PREF_KEY_ROOM, room)
.putString(liveKitTokenKey, token) .putString(LiveKitManager.PREF_KEY_TOKEN, token)
.putBoolean(liveKitEnabledKey, enabled) .putBoolean(LiveKitManager.PREF_KEY_ENABLED, enabled)
.apply() .apply()
if (url.isBlank() || room.isBlank()) { if (url.isBlank() || room.isBlank()) {
Toast.makeText(this, getString(R.string.msg_livekit_cleared), Toast.LENGTH_SHORT).show() Toast.makeText(this, getString(R.string.msg_livekit_cleared), Toast.LENGTH_SHORT).show()
@@ -113,7 +108,7 @@ class SettingsActivity : AppCompatActivity() {
} }
setupRestartButton() setupRestartButton()
setupSpecialTaskSwitch() setupSpecialStateSwitch()
setupLocationSelector() setupLocationSelector()
} }
@@ -122,20 +117,22 @@ class SettingsActivity : AppCompatActivity() {
refreshLocationList() refreshLocationList()
} }
private fun setupSpecialTaskSwitch() { private fun setupSpecialStateSwitch() {
val isSpecialTaskMode = prefs.getBoolean("special_task_mode", false) val isSpecialState = prefs.getBoolean(specialStateKey, false)
binding.switchSpecialTask.setOnCheckedChangeListener(null) binding.switchSpecialTask.setOnCheckedChangeListener(null)
binding.switchSpecialTask.isChecked = isSpecialTaskMode binding.switchSpecialTask.isChecked = isSpecialState
updateStatusIndicator(isSpecialTaskMode) updateStatusIndicator(isSpecialState)
binding.switchSpecialTask.setOnCheckedChangeListener { _, isChecked -> binding.switchSpecialTask.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean("special_task_mode", isChecked).apply() prefs.edit()
.putBoolean(specialStateKey, isChecked)
.apply()
updateStatusIndicator(isChecked) updateStatusIndicator(isChecked)
Log.i("SettingsActivity", "Special Task Mode changed to: $isChecked") Log.i("SettingsActivity", "Special state changed to: $isChecked")
} }
} }
private fun updateStatusIndicator(isSpecialTaskMode: Boolean) { private fun updateStatusIndicator(isSpecialState: Boolean) {
val indicatorColor = if (isSpecialTaskMode) { val indicatorColor = if (isSpecialState) {
ContextCompat.getColor(this, android.R.color.holo_red_dark) ContextCompat.getColor(this, android.R.color.holo_red_dark)
} else { } else {
ContextCompat.getColor(this, android.R.color.holo_green_dark) ContextCompat.getColor(this, android.R.color.holo_green_dark)
@@ -144,6 +141,7 @@ class SettingsActivity : AppCompatActivity() {
indicatorDrawable.setColor(indicatorColor) indicatorDrawable.setColor(indicatorColor)
} }
private fun setupRestartButton() { private fun setupRestartButton() {
binding.btnRestart.setOnTouchListener { _, event -> binding.btnRestart.setOnTouchListener { _, event ->
when (event.action) { when (event.action) {
@@ -243,14 +241,10 @@ class SettingsActivity : AppCompatActivity() {
} }
private fun resolveLiveKitUrl(): String { private fun resolveLiveKitUrl(): String {
val savedUrl = prefs.getString(liveKitUrlKey, "").orEmpty().trim() val savedUrl = prefs.getString(LiveKitManager.PREF_KEY_URL, "").orEmpty().trim()
if (savedUrl.isNotEmpty()) { if (savedUrl.isNotEmpty()) {
return savedUrl return savedUrl
} }
val ip = prefs.getString("network_ip", "").orEmpty().trim() return LiveKitManager.DEFAULT_URL
if (ip.isNotEmpty()) {
return "ws://$ip:7880"
}
return liveKitUrlDefault
} }
} }

View File

@@ -0,0 +1,277 @@
package com.example.lzwcai_terminal_temi
import com.robotemi.sdk.listeners.OnDetectionStateChangedListener.Companion.DETECTED
import com.robotemi.sdk.listeners.OnDetectionStateChangedListener.Companion.IDLE
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class TaskController(
private val scope: CoroutineScope,
private val navController: NavController,
private val getLastArrivalLocation: () -> String?,
private val speak: (String) -> Unit,
private val setEmoji: (AnimatedEmojiView.Expression) -> Unit,
private val setReceptionButtonVisible: (Boolean) -> Unit
) {
var currentTask: String = ""
private set
var isLeavingHomeBase: Boolean = false
private set
private var receptionLocation: String = ""
private var receptionText: String = ""
private var receptionDestination: String = ""
private var notificationLocation: String = ""
private var notificationText: String = ""
private var isReceptionPromptVisible: Boolean = false
private var patrolRoute: List<String> = emptyList()
private var patrolIndex: Int = 0
private var patrolLoopsRemaining: Int = 1
private var patrolWaitingSeconds: Int = 3
private var patrolNonStop: Boolean = false
private var patrolMoveJob: Job? = null
fun restoreState(
task: String,
location: String,
text: String,
destination: String,
notificationLocation: String,
notificationText: String
) {
receptionLocation = location
receptionText = text
receptionDestination = destination
this.notificationLocation = notificationLocation
this.notificationText = notificationText
setCurrentTask(task)
}
fun getReceptionLocation(): String = receptionLocation
fun getReceptionText(): String = receptionText
fun getReceptionDestination(): String = receptionDestination
fun getNotificationLocation(): String = notificationLocation
fun getNotificationText(): String = notificationText
fun setCurrentTask(task: String) {
val finalTask = task.trim().lowercase()
if (currentTask == finalTask) {
return
}
currentTask = finalTask
if (finalTask == "patrol") {
setEmoji(AnimatedEmojiView.Expression.ANGRY)
} else {
setEmoji(AnimatedEmojiView.Expression.SMILE)
}
if (finalTask != "patrol") {
patrolRoute = emptyList()
patrolIndex = 0
patrolLoopsRemaining = 1
patrolWaitingSeconds = 3
patrolNonStop = false
patrolMoveJob?.cancel()
patrolMoveJob = null
}
if (finalTask != "reception") {
clearReceptionState()
}
if (finalTask != "notification") {
clearNotificationState()
}
}
fun markSpeechTaskActive() {
if (currentTask.isEmpty() || currentTask == "speech") {
setCurrentTask("speech")
}
}
fun clearSpeechTaskIfActive() {
if (currentTask == "speech") {
setCurrentTask("")
}
}
fun startReceptionMode(location: String, text: String, destination: String) {
setCurrentTask("reception")
receptionLocation = location
receptionText = text
receptionDestination = destination
isReceptionPromptVisible = false
setReceptionButtonVisible(false)
if (getLastArrivalLocation() != location) {
navController.goTo(location, false)
}
}
fun startNotificationMode(location: String, text: String) {
val target = location.trim()
val message = text.trim()
if (target.isEmpty()) {
setCurrentTask("")
return
}
setCurrentTask("notification")
notificationLocation = target
notificationText = message
if (getLastArrivalLocation()?.equals(target, ignoreCase = true) == true) {
if (message.isNotEmpty()) {
speak(message)
}
setCurrentTask("")
return
}
navController.goTo(target, false)
}
fun confirmReception(): String? {
val destination = receptionDestination.trim()
setCurrentTask("")
return destination.ifEmpty { null }
}
private fun clearReceptionState() {
receptionLocation = ""
receptionText = ""
receptionDestination = ""
isReceptionPromptVisible = false
setReceptionButtonVisible(false)
}
private fun clearNotificationState() {
notificationLocation = ""
notificationText = ""
}
fun handleDetectionStateChanged(state: Int): Boolean {
if (currentTask == "patrol" && state == DETECTED) {
speak("别妨碍我,我正在巡逻呢")
return true
}
if (currentTask == "reception" &&
getLastArrivalLocation()?.equals(receptionLocation, ignoreCase = true) == true
) {
when (state) {
DETECTED -> {
if (!isReceptionPromptVisible) {
isReceptionPromptVisible = true
setReceptionButtonVisible(true)
speak(receptionText)
}
}
IDLE -> {
isReceptionPromptVisible = false
setReceptionButtonVisible(false)
}
}
}
return currentTask == "reception"
}
fun endNonSpecialTask(reason: String) {
if (currentTask.isEmpty()) {
return
}
if (currentTask != "patrol" && currentTask != "reception" && currentTask != "notification") {
return
}
setCurrentTask("")
}
fun startPatrolMode(route: List<String>, times: Int = 1, waiting: Int = 3, nonStop: Boolean = false) {
if (route.isEmpty()) {
setCurrentTask("")
return
}
patrolRoute = route
patrolIndex = 0
patrolLoopsRemaining = times.coerceAtLeast(1)
patrolWaitingSeconds = waiting.coerceAtLeast(0)
patrolNonStop = nonStop
patrolMoveJob?.cancel()
setCurrentTask("patrol")
moveToCurrentPatrolTarget()
}
fun handlePatrolArrival(location: String) {
if (patrolRoute.isEmpty()) {
return
}
val matchIndex = patrolRoute.indexOfFirst { it.equals(location, ignoreCase = true) }
if (matchIndex == -1) {
return
}
if (matchIndex >= patrolIndex) {
patrolIndex = matchIndex + 1
}
if (patrolIndex >= patrolRoute.size) {
patrolLoopsRemaining -= 1
if (patrolLoopsRemaining <= 0) {
setCurrentTask("")
return
}
patrolIndex = 0
}
scheduleNextPatrolMove()
}
fun clearLeavingHomeBase() {
isLeavingHomeBase = false
}
fun handleNotificationArrival(location: String): Boolean {
if (currentTask != "notification") {
return false
}
if (!location.equals(notificationLocation, ignoreCase = true)) {
return false
}
if (notificationText.isNotEmpty()) {
speak(notificationText)
}
setCurrentTask("")
return true
}
private fun moveToCurrentPatrolTarget() {
if (currentTask != "patrol") {
return
}
val target = patrolRoute.getOrNull(patrolIndex) ?: return
if (getLastArrivalLocation()?.equals(target, ignoreCase = true) == true) {
patrolIndex += 1
if (patrolIndex >= patrolRoute.size) {
setCurrentTask("")
return
}
moveToCurrentPatrolTarget()
return
}
if (getLastArrivalLocation()?.equals("home base", ignoreCase = true) == true &&
!target.equals("home base", ignoreCase = true)
) {
isLeavingHomeBase = true
}
navController.goTo(target, false)
}
private fun scheduleNextPatrolMove() {
patrolMoveJob?.cancel()
if (patrolNonStop || patrolWaitingSeconds <= 0) {
moveToCurrentPatrolTarget()
return
}
patrolMoveJob = scope.launch {
delay(patrolWaitingSeconds * 1000L)
moveToCurrentPatrolTarget()
}
}
}

View File

@@ -0,0 +1,144 @@
package com.example.lzwcai_terminal_temi
import com.robotemi.sdk.BatteryData
import com.robotemi.sdk.navigation.model.Position
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.json.JSONObject
class TelemetryManager(
private val scope: CoroutineScope,
private val taskProvider: () -> String,
private val locationProvider: () -> String?,
private val mqttConnectedProvider: () -> Boolean,
private val liveKitConnectedProvider: () -> Boolean,
private val publish: (String, String) -> Unit,
private val onLowBattery: (BatteryData) -> Unit
) {
private var telemetryJob: Job? = null
private var latestBatteryLevel: Int? = null
private var latestBatteryCharging: Boolean? = null
private var latestBattery2Level: Int? = null
private var latestPosition: Position? = null
private var latestMovementType: String? = null
private var latestMovementStatus: String? = null
private var lastTelemetrySentAt: Long = 0L
private var lastBatteryLowWarningAt: Long = 0L
private val batteryLowThreshold = 20
fun start() {
stop()
telemetryJob = scope.launch {
while (isActive) {
publishStatusSnapshot("heartbeat", true)
delay(15000L)
}
}
}
fun stop() {
telemetryJob?.cancel()
telemetryJob = null
}
fun onBatteryStatusChanged(batteryData: BatteryData) {
latestBatteryLevel = batteryData.level
latestBatteryCharging = batteryData.isCharging
latestBattery2Level = batteryData.battery2Level
val now = System.currentTimeMillis()
if (batteryData.level <= batteryLowThreshold && !batteryData.isCharging) {
if (now - lastBatteryLowWarningAt > 10 * 60 * 1000L) {
lastBatteryLowWarningAt = now
onLowBattery(batteryData)
publishEvent(
"battery_low",
JSONObject()
.put("level", batteryData.level)
.put("charging", batteryData.isCharging)
.put("battery2Level", batteryData.battery2Level)
)
}
}
publishStatusSnapshot("battery")
}
fun onMovementStatusChanged(type: String, status: String) {
latestMovementType = type
latestMovementStatus = status
publishStatusSnapshot("movement")
}
fun onCurrentPositionChanged(position: Position) {
latestPosition = position
publishStatusSnapshot("position")
}
fun publishStatusSnapshot(reason: String, force: Boolean = false) {
val now = System.currentTimeMillis()
if (!force && now - lastTelemetrySentAt < 5000L) {
return
}
lastTelemetrySentAt = now
val payload = JSONObject()
.put("type", "status")
.put("reason", reason)
.put("ts", now)
.put("task", taskProvider())
.put("location", locationProvider() ?: JSONObject.NULL)
.put("mqttConnected", mqttConnectedProvider())
.put("liveKitConnected", liveKitConnectedProvider())
val batteryJson = JSONObject()
if (latestBatteryLevel != null) {
batteryJson.put("level", latestBatteryLevel)
}
if (latestBatteryCharging != null) {
batteryJson.put("charging", latestBatteryCharging)
}
if (latestBattery2Level != null) {
batteryJson.put("battery2Level", latestBattery2Level)
}
if (batteryJson.length() > 0) {
payload.put("battery", batteryJson)
}
val movementJson = JSONObject()
if (!latestMovementType.isNullOrBlank()) {
movementJson.put("type", latestMovementType)
}
if (!latestMovementStatus.isNullOrBlank()) {
movementJson.put("status", latestMovementStatus)
}
if (movementJson.length() > 0) {
payload.put("movement", movementJson)
}
val position = latestPosition
if (position != null) {
payload.put(
"position",
JSONObject()
.put("x", position.x)
.put("y", position.y)
.put("yaw", position.yaw)
.put("tiltAngle", position.tiltAngle)
.put("inMapArea", position.isInMapArea)
)
}
publish("robot/status", payload.toString())
}
private fun publishEvent(event: String, data: JSONObject) {
val payload = JSONObject()
.put("type", "event")
.put("event", event)
.put("ts", System.currentTimeMillis())
.put("data", data)
publish("robot/event", payload.toString())
}
}

View File

@@ -30,6 +30,27 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tvNetworkError"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:layout_marginTop="16dp"
android:background="@android:color/holo_red_dark"
android:gravity="center"
android:paddingStart="12dp"
android:paddingTop="8dp"
android:paddingEnd="12dp"
android:paddingBottom="8dp"
android:text="网络异常"
android:textColor="@android:color/white"
android:textSize="18sp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/statusIndicator" />
<com.example.lzwcai_terminal_temi.AnimatedEmojiView <com.example.lzwcai_terminal_temi.AnimatedEmojiView
android:id="@+id/animatedEmojiView" android:id="@+id/animatedEmojiView"
android:layout_width="0dp" android:layout_width="0dp"

View File

@@ -310,7 +310,7 @@
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="特殊任务模式" android:text="特殊状态"
android:textColor="@color/text_primary" android:textColor="@color/text_primary"
android:textSize="22sp" android:textSize="22sp"
android:textStyle="bold" /> android:textStyle="bold" />
@@ -319,7 +319,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:text="启用特定场景下的任务逻辑" android:text="启用特定场景下的行为逻辑"
android:textColor="@color/text_secondary" android:textColor="@color/text_secondary"
android:textSize="16sp" /> android:textSize="16sp" />
</LinearLayout> </LinearLayout>

View File

@@ -30,6 +30,27 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tvNetworkError"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:layout_marginTop="16dp"
android:background="@android:color/holo_red_dark"
android:gravity="center"
android:paddingStart="12dp"
android:paddingTop="8dp"
android:paddingEnd="12dp"
android:paddingBottom="8dp"
android:text="网络异常"
android:textColor="@android:color/white"
android:textSize="18sp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/statusIndicator" />
<com.example.lzwcai_terminal_temi.AnimatedEmojiView <com.example.lzwcai_terminal_temi.AnimatedEmojiView
android:id="@+id/animatedEmojiView" android:id="@+id/animatedEmojiView"
android:layout_width="0dp" android:layout_width="0dp"

View File

@@ -293,7 +293,7 @@
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="特殊任务模式" android:text="特殊状态"
android:textColor="@color/text_primary" android:textColor="@color/text_primary"
android:textSize="22sp" android:textSize="22sp"
android:textStyle="bold" /> android:textStyle="bold" />
@@ -302,7 +302,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:text="启用特定场景下的任务逻辑" android:text="启用特定场景下的行为逻辑"
android:textColor="@color/text_secondary" android:textColor="@color/text_secondary"
android:textSize="16sp" /> android:textSize="16sp" />
</LinearLayout> </LinearLayout>

View File

@@ -106,6 +106,15 @@
- 若为巡逻任务则推进巡逻索引 - 若为巡逻任务则推进巡逻索引
- 特殊任务模式下可跳过“已到达”播报 - 特殊任务模式下可跳过“已到达”播报
### 5.4 人体检测与录音逻辑
- 人体检测事件先做稳定判定(去抖)
- DETECTED延迟确认默认 0.8s),仍为 DETECTED 才进入“稳定检测”状态
- IDLE延迟确认默认 5s仍为 IDLE 才退出“稳定检测”状态
- 录音LiveKit仅在稳定状态切换时开启/关闭
- 稳定 DETECTED开启录音
- 稳定 IDLE关闭录音
- 特殊任务模式仅跳过门禁与问候,不影响录音逻辑
## 6. 表情与语音联动 ## 6. 表情与语音联动
- **TTS STARTED**:表情变为 TALKING - **TTS STARTED**:表情变为 TALKING
@@ -137,6 +146,7 @@
- 跳过 Home Base 的开门/关门语音逻辑 - 跳过 Home Base 的开门/关门语音逻辑
- 跳过检测到人时的问候语 - 跳过检测到人时的问候语
- 到达地点时不播报“已到达”(无任务状态下) - 到达地点时不播报“已到达”(无任务状态下)
- 录音仍按稳定检测状态开启/关闭
## 9. LiveKit 连接 ## 9. LiveKit 连接
@@ -157,4 +167,67 @@
## 12. 安全注意事项 ## 12. 安全注意事项
- MQTT 用户名/密码在代码内配置 - MQTT 用户名/密码在代码内配置
- LiveKit 默认 key/secret 也在代码内生成 token - LiveKit 默认 key/secret 也在代码内生成 token
- 建议正式环境将敏感信息迁移至安全配置源 - 建议正式环境将敏感信息迁移至安全配置源
## 13. 时序图与逻辑图
### 13.1 人体检测去抖与录音时序
```mermaid
sequenceDiagram
autonumber
participant Temi as Temi SDK
participant Main as MainActivity
participant LK as LiveKitManager
Temi->>Main: onDetectionStateChanged(DETECTED)
Main->>Main: 启动 DETECTED 确认延迟
Main->>Main: 状态稳定为 DETECTED
Main->>LK: setDetectionActive(true)
Temi->>Main: onDetectionStateChanged(IDLE)
Main->>Main: 启动 IDLE 确认延迟
Main->>Main: 状态稳定为 IDLE
Main->>LK: setDetectionActive(false)
```
### 13.2 稳定状态逻辑图(门禁/问候/录音)
```mermaid
flowchart TD
A[稳定状态变化] --> B{state == DETECTED?}
B -- 是 --> C[录音开启]
B -- 否 --> D[录音关闭]
C --> E{空任务?}
D --> E
E -- 否 --> H[结束]
E -- 是 --> F{在 Home Base?}
F -- 是 --> G{特殊任务模式?}
G -- 否 --> I[执行开门/关门逻辑]
G -- 是 --> H
F -- 否 --> J{特殊任务模式?}
J -- 否 --> K[执行问候语]
J -- 是 --> H
```
### 13.3 接待与巡逻任务流程图
```mermaid
flowchart TD
A[MQTT 指令进入] --> B{action}
B -- reception --> C[进入接待任务]
C --> D[导航到接待点]
D --> E{到达?}
E -- 否 --> D
E -- 是 --> F[检测到人提示确认]
F --> G{用户确认?}
G -- 否 --> F
G -- 是 --> H[播报确认语]
H --> I[导航到目的地]
I --> J[结束接待任务]
B -- patrol --> K[进入巡逻任务]
K --> L{随机/指定路线}
L --> M[导航到下一个点]
M --> N{到达?}
N -- 否 --> M
N -- 是 --> O[更新索引/剩余圈数]
O --> P{是否完成}
P -- 否 --> M
P -- 是 --> Q[结束巡逻任务]
```