feat: 新增机器人运动控制、任务管理及远程监控功能
- 扩展 NavController 支持旋转、倾斜及遥控操作 - 实现任务控制器统一管理接待、巡逻、通知等任务逻辑 - 新增遥测管理器定期上报状态并支持低电量预警 - 增强 LiveKit 管理器支持自动重连与麦克风状态联动 - 优化人体检测去抖逻辑并更新技术文档 - 调整设置界面文本描述并添加网络异常提示
This commit is contained in:
1
.idea/misc.xml
generated
1
.idea/misc.xml
generated
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -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<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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
75
technique.md
75
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
|
||||
- 建议正式环境将敏感信息迁移至安全配置源
|
||||
- 建议正式环境将敏感信息迁移至安全配置源
|
||||
|
||||
## 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[结束巡逻任务]
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user