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">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<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.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)
}
}
}
}

View File

@@ -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<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() {
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<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.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<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
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<String, Any>()
// )
// }
mainScope.launch {
val result = HttpManager.workflow_execute(
context = this@MainActivity,
apiKey = "wf_865e80f5fc1a4a319474a21d47470863",
workflowId = "2031297462423851009",
inputs = emptyMap<String, Any>()
)
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<String, Any>()
// )
// }
mainScope.launch {
val result = HttpManager.workflow_execute(
context = this@MainActivity,
apiKey = "wf_c02aa853371345dbb29572641d083c24",
workflowId = "2031634633458520065",
inputs = emptyMap<String, Any>()
)
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<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")
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)
}

View File

@@ -46,6 +46,7 @@ class MqttManager(
private val ttsLanguageMap = mutableMapOf<TtsRequest, TtsRequest.Language>()
private var currentStreamSessionId: String? = null
private var currentStreamMessageId: String? = null
private var danceJob: Job? = null
init {
try {
@@ -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<String> = 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)

View File

@@ -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<String> {
return robot.locations
}

View File

@@ -28,14 +28,9 @@ class SettingsActivity : AppCompatActivity() {
private lateinit var robot: Robot
private lateinit var locationAdapter: ArrayAdapter<String>
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
}
}

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_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
android:id="@+id/animatedEmojiView"
android:layout_width="0dp"

View File

@@ -310,7 +310,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="特殊任务模式"
android:text="特殊状态"
android:textColor="@color/text_primary"
android:textSize="22sp"
android:textStyle="bold" />
@@ -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" />
</LinearLayout>

View File

@@ -30,6 +30,27 @@
app:layout_constraintStart_toStartOf="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
android:id="@+id/animatedEmojiView"
android:layout_width="0dp"

View File

@@ -293,7 +293,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="特殊任务模式"
android:text="特殊状态"
android:textColor="@color/text_primary"
android:textSize="22sp"
android:textStyle="bold" />
@@ -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" />
</LinearLayout>

View File

@@ -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 连接
@@ -158,3 +168,66 @@
- MQTT 用户名/密码在代码内配置
- 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[结束巡逻任务]
```