diff --git a/.idea/misc.xml b/.idea/misc.xml index 74dd639..b2c751a 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,3 @@ - diff --git a/app/src/main/java/com/example/lzwcai_terminal_temi/LiveKitManager.kt b/app/src/main/java/com/example/lzwcai_terminal_temi/LiveKitManager.kt index 9053646..44194cf 100644 --- a/app/src/main/java/com/example/lzwcai_terminal_temi/LiveKitManager.kt +++ b/app/src/main/java/com/example/lzwcai_terminal_temi/LiveKitManager.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.launch enum class LiveKitStatus { @@ -20,12 +21,36 @@ enum class LiveKitStatus { 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 scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private var room: Room? = 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) { 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.") 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 { val currentRoom = room ?: LiveKit.create(context).also { room = it } eventsJob?.cancel() @@ -42,11 +74,19 @@ class LiveKitManager(appContext: Context, private val statusListener: (LiveKitSt when (event) { is RoomEvent.Connected -> { Log.i("LiveKitManager", "LiveKit connected.") + reconnectJob?.cancel() statusListener(LiveKitStatus.Connected) + applyMicState() } is RoomEvent.Disconnected -> { Log.i("LiveKitManager", "LiveKit 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 -> {} } @@ -62,12 +102,15 @@ class LiveKitManager(appContext: Context, private val statusListener: (LiveKitSt }.onFailure { e -> Log.e("LiveKitManager", "LiveKit connect error: ${e.message}", e) statusListener(LiveKitStatus.Failed) + scheduleReconnect() } } } fun disconnect() { + autoReconnectEnabled = false scope.launch { + reconnectJob?.cancel() eventsJob?.cancel() runCatching { room?.disconnect() } statusListener(LiveKitStatus.Disconnected) @@ -75,10 +118,64 @@ class LiveKitManager(appContext: Context, private val statusListener: (LiveKitSt } fun release() { + autoReconnectEnabled = false + reconnectJob?.cancel() eventsJob?.cancel() runCatching { room?.disconnect() } room = null scope.cancel() 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) + } + } + } } diff --git a/app/src/main/java/com/example/lzwcai_terminal_temi/LogManager.kt b/app/src/main/java/com/example/lzwcai_terminal_temi/LogManager.kt index 2877ffd..b21fb48 100644 --- a/app/src/main/java/com/example/lzwcai_terminal_temi/LogManager.kt +++ b/app/src/main/java/com/example/lzwcai_terminal_temi/LogManager.kt @@ -4,27 +4,46 @@ import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import java.io.BufferedReader import java.io.InputStreamReader +import java.util.concurrent.CopyOnWriteArrayList object LogManager { + private val ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val mainScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private var logcatJob: Job? = null private var logcatProcess: Process? = null - private val logListeners = mutableListOf<(String) -> Unit>() + private val logListeners = CopyOnWriteArrayList<(String) -> Unit>() + private var logcatTags: List = emptyList() + private var logcatMinPriority: Char = 'D' + + fun configureLogcat(tags: List, minPriority: Char = 'D') { + logcatTags = tags.map { it.trim() }.filter { it.isNotEmpty() } + logcatMinPriority = normalizePriority(minPriority) + if (logcatJob?.isActive == true) { + stopLogcatListener() + startLogcatListener() + } + } fun startLogcatListener() { if (logcatJob?.isActive == true) { return } - logcatJob = CoroutineScope(Dispatchers.IO).launch { + val command = buildLogcatCommand() + logcatJob = ioScope.launch { try { - logcatProcess = ProcessBuilder("logcat", "-v", "time").start() + logcatProcess = ProcessBuilder(command).start() val reader = BufferedReader(InputStreamReader(logcatProcess!!.inputStream)) var line: String? while (reader.readLine().also { line = it } != null) { - CoroutineScope(Dispatchers.Main).launch { - updateLog(line!!) + if (logListeners.isNotEmpty()) { + val text = line ?: continue + mainScope.launch { + updateLog(text) + } } } } catch (e: Exception) { @@ -52,4 +71,19 @@ object LogManager { private fun updateLog(logLine: String) { logListeners.forEach { it(logLine) } } + + private fun buildLogcatCommand(): List { + 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' + } + } } diff --git a/app/src/main/java/com/example/lzwcai_terminal_temi/MainActivity.kt b/app/src/main/java/com/example/lzwcai_terminal_temi/MainActivity.kt index 10e610d..98cc9f3 100644 --- a/app/src/main/java/com/example/lzwcai_terminal_temi/MainActivity.kt +++ b/app/src/main/java/com/example/lzwcai_terminal_temi/MainActivity.kt @@ -10,6 +10,7 @@ import android.os.Bundle import android.os.Build import android.util.Log import android.view.WindowManager +import android.view.View import android.util.Base64 import android.graphics.drawable.GradientDrawable import androidx.appcompat.app.AppCompatActivity @@ -56,50 +57,35 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG private var mqttManager: MqttManager? = null private val mainScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private lateinit var prefs: SharedPreferences + private val specialStateKey = "special_state" private lateinit var navCon: NavController private lateinit var permissionManager: PermissionManager 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 isMqttConnected = false private var lastArrivalLocation: String? = null 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 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 telemetryJob: Job? = null - - private var receptionLocation: String = "" - private var receptionText: String = "" - private var receptionDestination: String = "" - private var patrolRoute: List = 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 + private var networkErrorJob: Job? = null + private lateinit var telemetryManager: TelemetryManager + private lateinit var taskController: TaskController @SuppressLint("SetTextI18n") override fun onCreate(savedInstanceState: Bundle?) { @@ -107,6 +93,19 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) + LogManager.configureLogcat( + tags = listOf( + "MainActivity", + "MqttManager", + "LiveKitManager", + "SettingsActivity", + "PermissionManager", + "LogManager", + "TaskController", + "TelemetryManager" + ), + minPriority = 'I' + ) LogManager.startLogcatListener() window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN) robot = Robot.getInstance() @@ -114,25 +113,33 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG permissionManager = PermissionManager(robot) if (savedInstanceState != null) { - currentTask = savedInstanceState.getString("currentTask", "") ?: "" - receptionLocation = savedInstanceState.getString("receptionLocation", "") ?: "" - receptionText = savedInstanceState.getString("receptionText", "") ?: "" - receptionDestination = savedInstanceState.getString("receptionDestination", "") ?: "" + 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 (currentTask == "special") { - currentTask = "" + if (initialTask == "special") { + initialTask = "" } prefs = getSharedPreferences("app_prefs", Context.MODE_PRIVATE) prefs.registerOnSharedPreferenceChangeListener(this) - liveKitManager = LiveKitManager(applicationContext) { status -> - when (status) { - LiveKitStatus.Connected -> setLiveKitStatus(true) - LiveKitStatus.Disconnected -> setLiveKitStatus(false) - LiveKitStatus.Failed -> setLiveKitStatus(false) + liveKitManager = LiveKitManager( + applicationContext, + statusListener = { status -> + when (status) { + LiveKitStatus.Connected -> setLiveKitStatus(true) + LiveKitStatus.Disconnected -> setLiveKitStatus(false) + LiveKitStatus.Failed -> setLiveKitStatus(false) + } + }, + onDataReceived = { payload, topic, participant -> + handleAsrPayload(payload, topic, participant) } - } + ) if (lastArrivalLocation == null) { lastArrivalLocation = prefs.getString("current_location", null) } @@ -140,20 +147,53 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG startActivity(Intent(this, SettingsActivity::class.java)) } - if (currentTask == "patrol") { - binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.ANGRY - } else { - binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.SMILE - } + taskController = TaskController( + scope = mainScope, + navController = navCon, + 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 { - val destination = receptionDestination - stopReceptionMode() + val destination = taskController.confirmReception() + if (destination.isNullOrBlank()) { + return@setOnClickListener + } val ttsRequest = TtsRequest.create("接待任务确认,请跟我来", false, language = TtsRequest.Language.ZH_CN) robot.speak(ttsRequest) 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() updateLiveKitStatusSnapshot() } @@ -177,7 +217,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG } updateLiveKitConnection() startBlinking() - startTelemetry() + telemetryManager.start() } override fun onStop() { @@ -194,7 +234,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG // mqttManager?.disconnect() // Keep MQTT alive in background/settings liveKitManager?.disconnect() stopBlinking() - stopTelemetry() + telemetryManager.stop() } override fun onDestroy() { @@ -234,21 +274,26 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG TtsRequest.Status.STARTED -> { Log.i("MainActivity", "TTS started") binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.TALKING + liveKitManager?.setTtsMute(true) } TtsRequest.Status.COMPLETED, - TtsRequest.Status.CANCELED -> { + TtsRequest.Status.CANCELED, + TtsRequest.Status.NOT_ALLOWED -> { Log.i("MainActivity", "TTS finished: ${ttsRequest.speech}") - if (currentTask == "patrol") { + if (taskController.currentTask == "patrol") { binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.ANGRY } else { binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.SMILE } + liveKitManager?.setTtsMute(false) } TtsRequest.Status.ERROR -> { Log.e("MainActivity", "TTS error: ${ttsRequest.speech}") 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) { return } - latestBatteryLevel = batteryData.level - 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") + telemetryManager.onBatteryStatusChanged(batteryData) } override fun onMovementStatusChanged(type: String, status: String) { - latestMovementType = type - latestMovementStatus = status - publishStatusSnapshot("movement") + telemetryManager.onMovementStatusChanged(type, status) } override fun onCurrentPositionChanged(position: Position) { - latestPosition = position - publishStatusSnapshot("position") + telemetryManager.onCurrentPositionChanged(position) } override fun onGoToLocationStatusChanged(location: String, status: String, descriptionId: Int, description: String) { @@ -295,23 +319,29 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG return } if (isAbort) { - isLeavingHomeBase = false - endNonSpecialTask("goTo aborted: $location, status=$status") + taskController.clearLeavingHomeBase() + taskController.endNonSpecialTask("goTo aborted: $location, status=$status") return } val now = System.currentTimeMillis() if (lastArrivalLocation == location && now - lastArrivalAt < 5000L) { return } - isLeavingHomeBase = false + taskController.clearLeavingHomeBase() lastArrivalLocation = location lastArrivalAt = now prefs.edit().putString("current_location", location).apply() - if (currentTask == "patrol") { - handlePatrolArrival(location) + if (normalizeLocation(location) == "homebase") { + navCon.tiltAngle(20) } - if (isSpecialModeEnabled() && currentTask.isEmpty()) { - Log.i("MainActivity", "Special task mode: arrival announcement skipped at $location.") + if (taskController.currentTask == "patrol") { + taskController.handlePatrolArrival(location) + } + if (taskController.handleNotificationArrival(location)) { + return + } + if (isSpecialStateEnabled() && taskController.currentTask.isEmpty()) { + Log.i("MainActivity", "Special state: arrival announcement skipped at $location.") return } val text = "已到达$location" @@ -321,74 +351,89 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG } override fun onDetectionStateChanged(state: Int) { - if (currentTask == "patrol" && state == DETECTED) { - val ttsRequest = TtsRequest.create("别妨碍我,我正在巡逻呢", false, language = TtsRequest.Language.ZH_CN) - robot.speak(ttsRequest) - return - } - - if (currentTask == "reception" && lastArrivalLocation == receptionLocation) { - when (state) { - DETECTED -> { - if (binding.btnReception.visibility != android.view.View.VISIBLE) { - binding.btnReception.visibility = android.view.View.VISIBLE - val ttsRequest = TtsRequest.create(receptionText, false, language = TtsRequest.Language.ZH_CN) - robot.speak(ttsRequest) - Log.i("MainActivity", "Reception: Person detected (new session) at $receptionLocation") + latestDetectionState = state + Log.i("MainActivity", "Detection state changed: $state") + when (state) { + DETECTED -> { + idleConfirmJob?.cancel() + if (isDetectionStable || detectConfirmJob?.isActive == true) { + return + } + detectConfirmJob = mainScope.launch { + delay(detectedConfirmDelayMs) + if (latestDetectionState == DETECTED && !isDetectionStable) { + isDetectionStable = true + handleStableDetectionStateChanged(DETECTED) } } - IDLE -> { - binding.btnReception.visibility = android.view.View.GONE - Log.i("MainActivity", "Reception: Person left (IDLE)") + } + IDLE -> { + detectConfirmJob?.cancel() + if (!isDetectionStable) { + return + } + idleConfirmJob?.cancel() + idleConfirmJob = mainScope.launch { + delay(idleConfirmDelayMs) + if (latestDetectionState == IDLE && isDetectionStable) { + isDetectionStable = false + handleStableDetectionStateChanged(IDLE) + } } } } + } - // Home Base logic - if (currentTask == "" && lastArrivalLocation?.lowercase() == "home base" && !isLeavingHomeBase) { - // Check if special task mode is enabled, if so, skip door logic - if (isSpecialModeEnabled() && currentTask.isEmpty()) { - Log.i("MainActivity", "Special task mode: Door logic skipped at Home Base.") - return - } - + private fun handleStableDetectionStateChanged(state: Int) { + Log.i("MainActivity", "Stable detection state: $state") + liveKitManager?.setDetectionActive(state == DETECTED) + if (taskController.handleDetectionStateChanged(state)) { + Log.i("MainActivity", "what the f**k") + 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) { DETECTED -> { closeDoorJob?.cancel() binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.WINK val ttsRequest = TtsRequest.create("正在为你开门,请稍等。", false, language = TtsRequest.Language.ZH_CN) robot.speak(ttsRequest) - // mainScope.launch { - // HttpManager.workflow_execute( - // context = this@MainActivity, - // apiKey = "wf_865e80f5fc1a4a319474a21d47470863", - // workflowId = "2031297462423851009", - // inputs = emptyMap() - // ) - // } + mainScope.launch { + val result = HttpManager.workflow_execute( + context = this@MainActivity, + apiKey = "wf_865e80f5fc1a4a319474a21d47470863", + workflowId = "2031297462423851009", + inputs = emptyMap() + ) + if (result == null) { + showNetworkErrorBanner() + } + } } IDLE -> { closeDoorJob = mainScope.launch { - delay(5000) val ttsRequest = TtsRequest.create("正准备关门,请小心被夹。", false, language = TtsRequest.Language.ZH_CN) robot.speak(ttsRequest) - // mainScope.launch { - // HttpManager.workflow_execute( - // context = this@MainActivity, - // apiKey = "wf_c02aa853371345dbb29572641d083c24", - // workflowId = "2031634633458520065", - // inputs = emptyMap() - // ) - // } + mainScope.launch { + val result = HttpManager.workflow_execute( + context = this@MainActivity, + apiKey = "wf_c02aa853371345dbb29572641d083c24", + workflowId = "2031634633458520065", + inputs = emptyMap() + ) + if (result == null) { + showNetworkErrorBanner() + } + } } } } } - if (lastArrivalLocation?.lowercase() != "home base" && currentTask.isEmpty() && state == DETECTED) { - if (isSpecialModeEnabled()) { - Log.i("MainActivity", "Special task mode enabled (pref check), skipping greeting.") - return - } + if (isIdleTask && !atHomeBase && state == DETECTED && !isSpecialState) { val hour = java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY) val greeting = when (hour) { 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) { - setCurrentTask("reception") - 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("") + taskController.startReceptionMode(location, text, destination) } fun startPatrolMode(route: List, 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") - Log.i("MainActivity", "Patrol mode started: route=${route.joinToString()}") - moveToCurrentPatrolTarget() + taskController.startPatrolMode(route, times, waiting, nonStop) } - private 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) { - 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 startNotificationMode(location: String, text: String) { + taskController.startNotificationMode(location, text) } fun setCurrentTask(task: String) { - val finalTask = 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 - } + taskController.setCurrentTask(task) } fun markSpeechTaskActive() { - if (currentTask.isEmpty() || currentTask == "speech") { - setCurrentTask("speech") - } + taskController.markSpeechTaskActive() } fun clearSpeechTaskIfActive() { - if (currentTask == "speech") { - setCurrentTask("") - } + taskController.clearSpeechTaskIfActive() } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { @@ -555,17 +489,24 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG lastArrivalLocation = sharedPreferences?.getString("current_location", null) Log.i("MainActivity", "Current location updated manually: $lastArrivalLocation") } - if (key == "special_task_mode") { - val isSpecial = sharedPreferences?.getBoolean("special_task_mode", false) == true - Log.i("MainActivity", "Special mode pref changed: $isSpecial, currentTask: $currentTask") + if (key == specialStateKey) { + val isSpecial = isSpecialStateEnabled() + 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() } } - private fun isSpecialModeEnabled(): Boolean { - return ::prefs.isInitialized && prefs.getBoolean("special_task_mode", false) + private fun isSpecialStateEnabled(): Boolean { + if (!::prefs.isInitialized) { + return false + } + return prefs.getBoolean(specialStateKey, false) } private fun updateMqttConnection() { @@ -585,14 +526,14 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG } private fun updateLiveKitConnection() { - val enabled = prefs.getBoolean(liveKitEnabledKey, true) + val enabled = prefs.getBoolean(LiveKitManager.PREF_KEY_ENABLED, true) val url = resolveLiveKitUrl() - val room = prefs.getString(liveKitRoomKey, liveKitRoomDefault).orEmpty() - val savedToken = prefs.getString(liveKitTokenKey, "").orEmpty() + val room = prefs.getString(LiveKitManager.PREF_KEY_ROOM, LiveKitManager.DEFAULT_ROOM).orEmpty() + val savedToken = prefs.getString(LiveKitManager.PREF_KEY_TOKEN, "").orEmpty() val token = if (savedToken.isBlank()) { buildLiveKitToken( - apiKey = liveKitApiKeyDefault, - apiSecret = liveKitApiSecretDefault, + apiKey = LiveKitManager.DEFAULT_API_KEY, + apiSecret = LiveKitManager.DEFAULT_API_SECRET, room = room, identity = buildLiveKitIdentity() ) @@ -630,7 +571,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG ActivityCompat.requestPermissions( this, 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 ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) - if (requestCode == liveKitPermissionRequestCode) { + if (requestCode == LiveKitManager.PERMISSION_REQUEST_CODE) { val granted = grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED } if (granted) { updateLiveKitConnection() @@ -698,9 +639,9 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG } private fun updateLiveKitStatusSnapshot() { - val enabled = prefs.getBoolean(liveKitEnabledKey, true) + val enabled = prefs.getBoolean(LiveKitManager.PREF_KEY_ENABLED, true) val url = resolveLiveKitUrl() - val room = prefs.getString(liveKitRoomKey, liveKitRoomDefault).orEmpty() + val room = prefs.getString(LiveKitManager.PREF_KEY_ROOM, LiveKitManager.DEFAULT_ROOM).orEmpty() if (!enabled) { setLiveKitStatus(false) return @@ -725,7 +666,48 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG isMqttConnected = connected updateConnectionIndicator() 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 { - val savedUrl = prefs.getString(liveKitUrlKey, "").orEmpty().trim() + val savedUrl = prefs.getString(LiveKitManager.PREF_KEY_URL, "").orEmpty().trim() if (savedUrl.isNotEmpty()) { return savedUrl } - val ip = prefs.getString("network_ip", "").orEmpty().trim() - if (ip.isNotEmpty()) { - return "ws://$ip:7880" - } - return liveKitUrlDefault + return LiveKitManager.DEFAULT_URL } private fun startBlinking() { @@ -774,92 +752,18 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG 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) { - 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", 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()) + telemetryManager.publishStatusSnapshot(reason, force) } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - outState.putString("currentTask", currentTask) - outState.putString("receptionLocation", receptionLocation) - outState.putString("receptionText", receptionText) - outState.putString("receptionDestination", receptionDestination) + 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) } diff --git a/app/src/main/java/com/example/lzwcai_terminal_temi/MqttManager.kt b/app/src/main/java/com/example/lzwcai_terminal_temi/MqttManager.kt index 68cd468..c519c8c 100644 --- a/app/src/main/java/com/example/lzwcai_terminal_temi/MqttManager.kt +++ b/app/src/main/java/com/example/lzwcai_terminal_temi/MqttManager.kt @@ -46,6 +46,7 @@ class MqttManager( private val ttsLanguageMap = mutableMapOf() private var currentStreamSessionId: String? = null private var currentStreamMessageId: String? = null + private var danceJob: Job? = null init { try { @@ -271,7 +272,7 @@ class MqttManager( when (action) { "recharge" -> { speak("前往充电桩", "zh") - navController.recharge() + navController.recharge() } "goto" -> { val location = obj.optString("location", obj.optString("target", "")) @@ -290,10 +291,36 @@ class MqttManager( } 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" -> { val ok = navController.repose() 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" -> { navController.stop() pauseTts() @@ -317,7 +344,7 @@ class MqttManager( speak("接到巡逻任务", "zh") val flag = obj.optBoolean("flag", true) 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)) var patrolLocations: List = emptyList() 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?) { lastStreamLangCode = langCode speechBuffer.append(text) diff --git a/app/src/main/java/com/example/lzwcai_terminal_temi/NavController.kt b/app/src/main/java/com/example/lzwcai_terminal_temi/NavController.kt index f45a010..27fb493 100644 --- a/app/src/main/java/com/example/lzwcai_terminal_temi/NavController.kt +++ b/app/src/main/java/com/example/lzwcai_terminal_temi/NavController.kt @@ -23,6 +23,26 @@ class NavController(private val robot: Robot) { 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 { return robot.locations } diff --git a/app/src/main/java/com/example/lzwcai_terminal_temi/SettingsActivity.kt b/app/src/main/java/com/example/lzwcai_terminal_temi/SettingsActivity.kt index 46ae0b5..b8b943c 100644 --- a/app/src/main/java/com/example/lzwcai_terminal_temi/SettingsActivity.kt +++ b/app/src/main/java/com/example/lzwcai_terminal_temi/SettingsActivity.kt @@ -28,14 +28,9 @@ class SettingsActivity : AppCompatActivity() { private lateinit var robot: Robot private lateinit var locationAdapter: ArrayAdapter private val currentLocationKey = "current_location" + private val specialStateKey = "special_state" 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 liveKitUrlDefault = "ws://192.168.2.236:7880" - private val liveKitRoomDefault = "temi-room" override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -50,10 +45,10 @@ class SettingsActivity : AppCompatActivity() { binding.etIpAddress.setText(savedIp) val savedDempId = prefs.getString(agentDempIdKey, "") binding.etAgentDempId.setText(savedDempId) - val savedLiveKitUrl = prefs.getString(liveKitUrlKey, resolveLiveKitUrl()) - val savedLiveKitRoom = prefs.getString(liveKitRoomKey, liveKitRoomDefault) - val savedLiveKitToken = prefs.getString(liveKitTokenKey, "") - val isLiveKitEnabled = prefs.getBoolean(liveKitEnabledKey, true) + val savedLiveKitUrl = prefs.getString(LiveKitManager.PREF_KEY_URL, resolveLiveKitUrl()) + val savedLiveKitRoom = prefs.getString(LiveKitManager.PREF_KEY_ROOM, LiveKitManager.DEFAULT_ROOM) + val savedLiveKitToken = prefs.getString(LiveKitManager.PREF_KEY_TOKEN, "") + val isLiveKitEnabled = prefs.getBoolean(LiveKitManager.PREF_KEY_ENABLED, true) binding.etLiveKitUrl.setText(savedLiveKitUrl) binding.etLiveKitRoom.setText(savedLiveKitRoom) binding.etLiveKitToken.setText(savedLiveKitToken) @@ -84,7 +79,7 @@ class SettingsActivity : AppCompatActivity() { } binding.switchLiveKitAuto.setOnCheckedChangeListener { _, isChecked -> - prefs.edit().putBoolean(liveKitEnabledKey, isChecked).apply() + prefs.edit().putBoolean(LiveKitManager.PREF_KEY_ENABLED, isChecked).apply() } binding.btnLiveKitSave.setOnClickListener { @@ -94,10 +89,10 @@ class SettingsActivity : AppCompatActivity() { val token = binding.etLiveKitToken.text?.toString()?.trim().orEmpty() val enabled = binding.switchLiveKitAuto.isChecked prefs.edit() - .putString(liveKitUrlKey, url) - .putString(liveKitRoomKey, room) - .putString(liveKitTokenKey, token) - .putBoolean(liveKitEnabledKey, enabled) + .putString(LiveKitManager.PREF_KEY_URL, url) + .putString(LiveKitManager.PREF_KEY_ROOM, room) + .putString(LiveKitManager.PREF_KEY_TOKEN, token) + .putBoolean(LiveKitManager.PREF_KEY_ENABLED, enabled) .apply() if (url.isBlank() || room.isBlank()) { Toast.makeText(this, getString(R.string.msg_livekit_cleared), Toast.LENGTH_SHORT).show() @@ -113,7 +108,7 @@ class SettingsActivity : AppCompatActivity() { } setupRestartButton() - setupSpecialTaskSwitch() + setupSpecialStateSwitch() setupLocationSelector() } @@ -122,20 +117,22 @@ class SettingsActivity : AppCompatActivity() { refreshLocationList() } - private fun setupSpecialTaskSwitch() { - val isSpecialTaskMode = prefs.getBoolean("special_task_mode", false) + private fun setupSpecialStateSwitch() { + val isSpecialState = prefs.getBoolean(specialStateKey, false) binding.switchSpecialTask.setOnCheckedChangeListener(null) - binding.switchSpecialTask.isChecked = isSpecialTaskMode - updateStatusIndicator(isSpecialTaskMode) + binding.switchSpecialTask.isChecked = isSpecialState + updateStatusIndicator(isSpecialState) binding.switchSpecialTask.setOnCheckedChangeListener { _, isChecked -> - prefs.edit().putBoolean("special_task_mode", isChecked).apply() + prefs.edit() + .putBoolean(specialStateKey, isChecked) + .apply() updateStatusIndicator(isChecked) - Log.i("SettingsActivity", "Special Task Mode changed to: $isChecked") + Log.i("SettingsActivity", "Special state changed to: $isChecked") } } - private fun updateStatusIndicator(isSpecialTaskMode: Boolean) { - val indicatorColor = if (isSpecialTaskMode) { + private fun updateStatusIndicator(isSpecialState: Boolean) { + val indicatorColor = if (isSpecialState) { ContextCompat.getColor(this, android.R.color.holo_red_dark) } else { ContextCompat.getColor(this, android.R.color.holo_green_dark) @@ -144,6 +141,7 @@ class SettingsActivity : AppCompatActivity() { indicatorDrawable.setColor(indicatorColor) } + private fun setupRestartButton() { binding.btnRestart.setOnTouchListener { _, event -> when (event.action) { @@ -243,14 +241,10 @@ class SettingsActivity : AppCompatActivity() { } private fun resolveLiveKitUrl(): String { - val savedUrl = prefs.getString(liveKitUrlKey, "").orEmpty().trim() + val savedUrl = prefs.getString(LiveKitManager.PREF_KEY_URL, "").orEmpty().trim() if (savedUrl.isNotEmpty()) { return savedUrl } - val ip = prefs.getString("network_ip", "").orEmpty().trim() - if (ip.isNotEmpty()) { - return "ws://$ip:7880" - } - return liveKitUrlDefault + return LiveKitManager.DEFAULT_URL } } diff --git a/app/src/main/java/com/example/lzwcai_terminal_temi/TaskController.kt b/app/src/main/java/com/example/lzwcai_terminal_temi/TaskController.kt new file mode 100644 index 0000000..e0f3d14 --- /dev/null +++ b/app/src/main/java/com/example/lzwcai_terminal_temi/TaskController.kt @@ -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 = 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, 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() + } + } +} diff --git a/app/src/main/java/com/example/lzwcai_terminal_temi/TelemetryManager.kt b/app/src/main/java/com/example/lzwcai_terminal_temi/TelemetryManager.kt new file mode 100644 index 0000000..8105d3d --- /dev/null +++ b/app/src/main/java/com/example/lzwcai_terminal_temi/TelemetryManager.kt @@ -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()) + } +} diff --git a/app/src/main/res/layout-land/activity_main.xml b/app/src/main/res/layout-land/activity_main.xml index ec86bac..a45d2ab 100644 --- a/app/src/main/res/layout-land/activity_main.xml +++ b/app/src/main/res/layout-land/activity_main.xml @@ -30,6 +30,27 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> + + @@ -319,7 +319,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="8dp" - android:text="启用特定场景下的任务逻辑" + android:text="启用特定场景下的行为逻辑" android:textColor="@color/text_secondary" android:textSize="16sp" /> diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 4ce0d32..949f1f5 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -30,6 +30,27 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> + + @@ -302,7 +302,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="8dp" - android:text="启用特定场景下的任务逻辑" + android:text="启用特定场景下的行为逻辑" android:textColor="@color/text_secondary" android:textSize="16sp" /> diff --git a/technique.md b/technique.md index 9fcd54e..6e181a4 100644 --- a/technique.md +++ b/technique.md @@ -106,6 +106,15 @@ - 若为巡逻任务则推进巡逻索引 - 特殊任务模式下可跳过“已到达”播报 +### 5.4 人体检测与录音逻辑 +- 人体检测事件先做稳定判定(去抖) + - DETECTED:延迟确认(默认 0.8s),仍为 DETECTED 才进入“稳定检测”状态 + - IDLE:延迟确认(默认 5s),仍为 IDLE 才退出“稳定检测”状态 +- 录音(LiveKit)仅在稳定状态切换时开启/关闭 + - 稳定 DETECTED:开启录音 + - 稳定 IDLE:关闭录音 +- 特殊任务模式仅跳过门禁与问候,不影响录音逻辑 + ## 6. 表情与语音联动 - **TTS STARTED**:表情变为 TALKING @@ -137,6 +146,7 @@ - 跳过 Home Base 的开门/关门语音逻辑 - 跳过检测到人时的问候语 - 到达地点时不播报“已到达”(无任务状态下) +- 录音仍按稳定检测状态开启/关闭 ## 9. LiveKit 连接 @@ -157,4 +167,67 @@ ## 12. 安全注意事项 - MQTT 用户名/密码在代码内配置 - LiveKit 默认 key/secret 也在代码内生成 token -- 建议正式环境将敏感信息迁移至安全配置源 \ No newline at end of file +- 建议正式环境将敏感信息迁移至安全配置源 + +## 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[结束巡逻任务] +```