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">
|
<project version="4">
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
enum class LiveKitStatus {
|
enum class LiveKitStatus {
|
||||||
@@ -20,12 +21,36 @@ enum class LiveKitStatus {
|
|||||||
Failed
|
Failed
|
||||||
}
|
}
|
||||||
|
|
||||||
class LiveKitManager(appContext: Context, private val statusListener: (LiveKitStatus) -> Unit) {
|
class LiveKitManager(
|
||||||
|
appContext: Context,
|
||||||
|
private val statusListener: (LiveKitStatus) -> Unit,
|
||||||
|
private val onDataReceived: (payload: String, topic: String?, participantIdentity: String?) -> Unit = { _, _, _ -> }
|
||||||
|
) {
|
||||||
|
|
||||||
private val context = appContext.applicationContext
|
private val context = appContext.applicationContext
|
||||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||||
private var room: Room? = null
|
private var room: Room? = null
|
||||||
private var eventsJob: Job? = null
|
private var eventsJob: Job? = null
|
||||||
|
private var reconnectJob: Job? = null
|
||||||
|
private var autoReconnectEnabled = false
|
||||||
|
private var lastUrl: String? = null
|
||||||
|
private var lastToken: String? = null
|
||||||
|
private var lastEnableMic: Boolean = false
|
||||||
|
private var lastEnableCamera: Boolean = false
|
||||||
|
private var ttsMuted: Boolean = false
|
||||||
|
private var detectionActive: Boolean = false
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val PREF_KEY_URL = "livekit_url"
|
||||||
|
const val PREF_KEY_ROOM = "livekit_room"
|
||||||
|
const val PREF_KEY_TOKEN = "livekit_token"
|
||||||
|
const val PREF_KEY_ENABLED = "livekit_enabled"
|
||||||
|
const val DEFAULT_URL = "ws://localhost:7880"
|
||||||
|
const val DEFAULT_ROOM = "temi-room"
|
||||||
|
const val DEFAULT_API_KEY = "devkey"
|
||||||
|
const val DEFAULT_API_SECRET = "secret"
|
||||||
|
const val PERMISSION_REQUEST_CODE = 2001
|
||||||
|
}
|
||||||
|
|
||||||
fun connect(url: String, token: String, enableMic: Boolean, enableCamera: Boolean) {
|
fun connect(url: String, token: String, enableMic: Boolean, enableCamera: Boolean) {
|
||||||
val finalUrl = url.trim()
|
val finalUrl = url.trim()
|
||||||
@@ -34,6 +59,13 @@ class LiveKitManager(appContext: Context, private val statusListener: (LiveKitSt
|
|||||||
Log.w("LiveKitManager", "LiveKit connect skipped: empty url or token.")
|
Log.w("LiveKitManager", "LiveKit connect skipped: empty url or token.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
autoReconnectEnabled = true
|
||||||
|
lastUrl = finalUrl
|
||||||
|
lastToken = finalToken
|
||||||
|
lastEnableMic = enableMic
|
||||||
|
lastEnableCamera = enableCamera
|
||||||
|
Log.i("LiveKitManager", "LiveKit connect requested: mic=$enableMic, camera=$enableCamera, url=$finalUrl")
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val currentRoom = room ?: LiveKit.create(context).also { room = it }
|
val currentRoom = room ?: LiveKit.create(context).also { room = it }
|
||||||
eventsJob?.cancel()
|
eventsJob?.cancel()
|
||||||
@@ -42,11 +74,19 @@ class LiveKitManager(appContext: Context, private val statusListener: (LiveKitSt
|
|||||||
when (event) {
|
when (event) {
|
||||||
is RoomEvent.Connected -> {
|
is RoomEvent.Connected -> {
|
||||||
Log.i("LiveKitManager", "LiveKit connected.")
|
Log.i("LiveKitManager", "LiveKit connected.")
|
||||||
|
reconnectJob?.cancel()
|
||||||
statusListener(LiveKitStatus.Connected)
|
statusListener(LiveKitStatus.Connected)
|
||||||
|
applyMicState()
|
||||||
}
|
}
|
||||||
is RoomEvent.Disconnected -> {
|
is RoomEvent.Disconnected -> {
|
||||||
Log.i("LiveKitManager", "LiveKit disconnected.")
|
Log.i("LiveKitManager", "LiveKit disconnected.")
|
||||||
statusListener(LiveKitStatus.Disconnected)
|
statusListener(LiveKitStatus.Disconnected)
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
is RoomEvent.DataReceived -> {
|
||||||
|
val payload = runCatching { event.data.toString(Charsets.UTF_8) }.getOrDefault("")
|
||||||
|
val participantIdentity = event.participant?.identity?.toString()
|
||||||
|
onDataReceived(payload, event.topic, participantIdentity)
|
||||||
}
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
@@ -62,12 +102,15 @@ class LiveKitManager(appContext: Context, private val statusListener: (LiveKitSt
|
|||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
Log.e("LiveKitManager", "LiveKit connect error: ${e.message}", e)
|
Log.e("LiveKitManager", "LiveKit connect error: ${e.message}", e)
|
||||||
statusListener(LiveKitStatus.Failed)
|
statusListener(LiveKitStatus.Failed)
|
||||||
|
scheduleReconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
|
autoReconnectEnabled = false
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
reconnectJob?.cancel()
|
||||||
eventsJob?.cancel()
|
eventsJob?.cancel()
|
||||||
runCatching { room?.disconnect() }
|
runCatching { room?.disconnect() }
|
||||||
statusListener(LiveKitStatus.Disconnected)
|
statusListener(LiveKitStatus.Disconnected)
|
||||||
@@ -75,10 +118,64 @@ class LiveKitManager(appContext: Context, private val statusListener: (LiveKitSt
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun release() {
|
fun release() {
|
||||||
|
autoReconnectEnabled = false
|
||||||
|
reconnectJob?.cancel()
|
||||||
eventsJob?.cancel()
|
eventsJob?.cancel()
|
||||||
runCatching { room?.disconnect() }
|
runCatching { room?.disconnect() }
|
||||||
room = null
|
room = null
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
statusListener(LiveKitStatus.Disconnected)
|
statusListener(LiveKitStatus.Disconnected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setTtsMute(active: Boolean) {
|
||||||
|
if (ttsMuted == active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ttsMuted = active
|
||||||
|
Log.i("LiveKitManager", "LiveKit TTS mute changed: $active")
|
||||||
|
applyMicState()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDetectionActive(active: Boolean) {
|
||||||
|
if (detectionActive == active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detectionActive = active
|
||||||
|
Log.i("LiveKitManager", "LiveKit detection active: $active")
|
||||||
|
applyMicState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleReconnect() {
|
||||||
|
if (!autoReconnectEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (reconnectJob?.isActive == true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val url = lastUrl
|
||||||
|
val token = lastToken
|
||||||
|
if (url.isNullOrBlank() || token.isNullOrBlank()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reconnectJob = scope.launch {
|
||||||
|
delay(5000L)
|
||||||
|
connect(url, token, lastEnableMic, lastEnableCamera)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyMicState() {
|
||||||
|
val currentRoom = room ?: return
|
||||||
|
val enabled = lastEnableMic && detectionActive && !ttsMuted
|
||||||
|
Log.i(
|
||||||
|
"LiveKitManager",
|
||||||
|
"LiveKit mic state -> enabled=$enabled (config=$lastEnableMic, detection=$detectionActive, ttsMuted=$ttsMuted)"
|
||||||
|
)
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
currentRoom.localParticipant.setMicrophoneEnabled(enabled)
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.w("LiveKitManager", "LiveKit mic toggle failed: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,27 +4,46 @@ import android.util.Log
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
object LogManager {
|
object LogManager {
|
||||||
|
private val ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
private val mainScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||||
private var logcatJob: Job? = null
|
private var logcatJob: Job? = null
|
||||||
private var logcatProcess: Process? = null
|
private var logcatProcess: Process? = null
|
||||||
private val logListeners = mutableListOf<(String) -> Unit>()
|
private val logListeners = CopyOnWriteArrayList<(String) -> Unit>()
|
||||||
|
private var logcatTags: List<String> = emptyList()
|
||||||
|
private var logcatMinPriority: Char = 'D'
|
||||||
|
|
||||||
|
fun configureLogcat(tags: List<String>, minPriority: Char = 'D') {
|
||||||
|
logcatTags = tags.map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
logcatMinPriority = normalizePriority(minPriority)
|
||||||
|
if (logcatJob?.isActive == true) {
|
||||||
|
stopLogcatListener()
|
||||||
|
startLogcatListener()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun startLogcatListener() {
|
fun startLogcatListener() {
|
||||||
if (logcatJob?.isActive == true) {
|
if (logcatJob?.isActive == true) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logcatJob = CoroutineScope(Dispatchers.IO).launch {
|
val command = buildLogcatCommand()
|
||||||
|
logcatJob = ioScope.launch {
|
||||||
try {
|
try {
|
||||||
logcatProcess = ProcessBuilder("logcat", "-v", "time").start()
|
logcatProcess = ProcessBuilder(command).start()
|
||||||
val reader = BufferedReader(InputStreamReader(logcatProcess!!.inputStream))
|
val reader = BufferedReader(InputStreamReader(logcatProcess!!.inputStream))
|
||||||
var line: String?
|
var line: String?
|
||||||
while (reader.readLine().also { line = it } != null) {
|
while (reader.readLine().also { line = it } != null) {
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
if (logListeners.isNotEmpty()) {
|
||||||
updateLog(line!!)
|
val text = line ?: continue
|
||||||
|
mainScope.launch {
|
||||||
|
updateLog(text)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -52,4 +71,19 @@ object LogManager {
|
|||||||
private fun updateLog(logLine: String) {
|
private fun updateLog(logLine: String) {
|
||||||
logListeners.forEach { it(logLine) }
|
logListeners.forEach { it(logLine) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun buildLogcatCommand(): List<String> {
|
||||||
|
if (logcatTags.isEmpty()) {
|
||||||
|
return listOf("logcat", "-v", "time")
|
||||||
|
}
|
||||||
|
val filters = logcatTags.map { "$it:$logcatMinPriority" } + "*:S"
|
||||||
|
return listOf("logcat", "-v", "time", "-s") + filters
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizePriority(priority: Char): Char {
|
||||||
|
return when (priority.uppercaseChar()) {
|
||||||
|
'V', 'D', 'I', 'W', 'E', 'F', 'S' -> priority.uppercaseChar()
|
||||||
|
else -> 'D'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import android.os.Bundle
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
|
import android.view.View
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.graphics.drawable.GradientDrawable
|
import android.graphics.drawable.GradientDrawable
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
@@ -56,50 +57,35 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
|
|||||||
private var mqttManager: MqttManager? = null
|
private var mqttManager: MqttManager? = null
|
||||||
private val mainScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
private val mainScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||||
private lateinit var prefs: SharedPreferences
|
private lateinit var prefs: SharedPreferences
|
||||||
|
private val specialStateKey = "special_state"
|
||||||
private lateinit var navCon: NavController
|
private lateinit var navCon: NavController
|
||||||
private lateinit var permissionManager: PermissionManager
|
private lateinit var permissionManager: PermissionManager
|
||||||
private var liveKitManager: LiveKitManager? = null
|
private var liveKitManager: LiveKitManager? = null
|
||||||
private val liveKitUrlKey = "livekit_url"
|
|
||||||
private val liveKitRoomKey = "livekit_room"
|
|
||||||
private val liveKitTokenKey = "livekit_token"
|
|
||||||
private val liveKitEnabledKey = "livekit_enabled"
|
|
||||||
private val liveKitPermissionRequestCode = 2001
|
|
||||||
private val liveKitUrlDefault = "ws://192.168.2.236:7880"
|
|
||||||
private val liveKitApiKeyDefault = "devkey"
|
|
||||||
private val liveKitApiSecretDefault = "secret"
|
|
||||||
private val liveKitRoomDefault = "temi-room"
|
|
||||||
private var isLiveKitConnected = false
|
private var isLiveKitConnected = false
|
||||||
private var isMqttConnected = false
|
private var isMqttConnected = false
|
||||||
|
|
||||||
private var lastArrivalLocation: String? = null
|
private var lastArrivalLocation: String? = null
|
||||||
private var lastArrivalAt: Long = 0L
|
private var lastArrivalAt: Long = 0L
|
||||||
|
|
||||||
private var currentTask: String = ""
|
private var initialTask: String = ""
|
||||||
|
private var initialReceptionLocation: String = ""
|
||||||
|
private var initialReceptionText: String = ""
|
||||||
|
private var initialReceptionDestination: String = ""
|
||||||
|
private var initialNotificationLocation: String = ""
|
||||||
|
private var initialNotificationText: String = ""
|
||||||
|
|
||||||
|
|
||||||
private var closeDoorJob: Job? = null
|
private var closeDoorJob: Job? = null
|
||||||
|
private var detectConfirmJob: Job? = null
|
||||||
|
private var idleConfirmJob: Job? = null
|
||||||
|
private var latestDetectionState: Int = IDLE
|
||||||
|
private var isDetectionStable = false
|
||||||
|
private val detectedConfirmDelayMs = 800L
|
||||||
|
private val idleConfirmDelayMs = 5000L
|
||||||
private var blinkJob: Job? = null
|
private var blinkJob: Job? = null
|
||||||
private var telemetryJob: Job? = null
|
private var networkErrorJob: Job? = null
|
||||||
|
private lateinit var telemetryManager: TelemetryManager
|
||||||
private var receptionLocation: String = ""
|
private lateinit var taskController: TaskController
|
||||||
private var receptionText: String = ""
|
|
||||||
private var receptionDestination: String = ""
|
|
||||||
private var patrolRoute: List<String> = emptyList()
|
|
||||||
private var patrolIndex: Int = 0
|
|
||||||
private var patrolLoopsRemaining: Int = 1
|
|
||||||
private var patrolWaitingSeconds: Int = 3
|
|
||||||
private var patrolNonStop: Boolean = false
|
|
||||||
private var patrolMoveJob: Job? = null
|
|
||||||
private var isLeavingHomeBase: Boolean = false
|
|
||||||
private var latestBatteryLevel: Int? = null
|
|
||||||
private var latestBatteryCharging: Boolean? = null
|
|
||||||
private var latestBattery2Level: Int? = null
|
|
||||||
private var latestPosition: Position? = null
|
|
||||||
private var latestMovementType: String? = null
|
|
||||||
private var latestMovementStatus: String? = null
|
|
||||||
private var lastTelemetrySentAt: Long = 0L
|
|
||||||
private var lastBatteryLowWarningAt: Long = 0L
|
|
||||||
private val batteryLowThreshold = 20
|
|
||||||
|
|
||||||
@SuppressLint("SetTextI18n")
|
@SuppressLint("SetTextI18n")
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -107,6 +93,19 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
|
|||||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
|
||||||
|
LogManager.configureLogcat(
|
||||||
|
tags = listOf(
|
||||||
|
"MainActivity",
|
||||||
|
"MqttManager",
|
||||||
|
"LiveKitManager",
|
||||||
|
"SettingsActivity",
|
||||||
|
"PermissionManager",
|
||||||
|
"LogManager",
|
||||||
|
"TaskController",
|
||||||
|
"TelemetryManager"
|
||||||
|
),
|
||||||
|
minPriority = 'I'
|
||||||
|
)
|
||||||
LogManager.startLogcatListener()
|
LogManager.startLogcatListener()
|
||||||
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN)
|
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN)
|
||||||
robot = Robot.getInstance()
|
robot = Robot.getInstance()
|
||||||
@@ -114,25 +113,33 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
|
|||||||
permissionManager = PermissionManager(robot)
|
permissionManager = PermissionManager(robot)
|
||||||
|
|
||||||
if (savedInstanceState != null) {
|
if (savedInstanceState != null) {
|
||||||
currentTask = savedInstanceState.getString("currentTask", "") ?: ""
|
initialTask = savedInstanceState.getString("currentTask", "") ?: ""
|
||||||
receptionLocation = savedInstanceState.getString("receptionLocation", "") ?: ""
|
initialReceptionLocation = savedInstanceState.getString("receptionLocation", "") ?: ""
|
||||||
receptionText = savedInstanceState.getString("receptionText", "") ?: ""
|
initialReceptionText = savedInstanceState.getString("receptionText", "") ?: ""
|
||||||
receptionDestination = savedInstanceState.getString("receptionDestination", "") ?: ""
|
initialReceptionDestination = savedInstanceState.getString("receptionDestination", "") ?: ""
|
||||||
|
initialNotificationLocation = savedInstanceState.getString("notificationLocation", "") ?: ""
|
||||||
|
initialNotificationText = savedInstanceState.getString("notificationText", "") ?: ""
|
||||||
lastArrivalLocation = savedInstanceState.getString("lastArrivalLocation")
|
lastArrivalLocation = savedInstanceState.getString("lastArrivalLocation")
|
||||||
}
|
}
|
||||||
if (currentTask == "special") {
|
if (initialTask == "special") {
|
||||||
currentTask = ""
|
initialTask = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
prefs = getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
|
prefs = getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
|
||||||
prefs.registerOnSharedPreferenceChangeListener(this)
|
prefs.registerOnSharedPreferenceChangeListener(this)
|
||||||
liveKitManager = LiveKitManager(applicationContext) { status ->
|
liveKitManager = LiveKitManager(
|
||||||
when (status) {
|
applicationContext,
|
||||||
LiveKitStatus.Connected -> setLiveKitStatus(true)
|
statusListener = { status ->
|
||||||
LiveKitStatus.Disconnected -> setLiveKitStatus(false)
|
when (status) {
|
||||||
LiveKitStatus.Failed -> setLiveKitStatus(false)
|
LiveKitStatus.Connected -> setLiveKitStatus(true)
|
||||||
|
LiveKitStatus.Disconnected -> setLiveKitStatus(false)
|
||||||
|
LiveKitStatus.Failed -> setLiveKitStatus(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDataReceived = { payload, topic, participant ->
|
||||||
|
handleAsrPayload(payload, topic, participant)
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
if (lastArrivalLocation == null) {
|
if (lastArrivalLocation == null) {
|
||||||
lastArrivalLocation = prefs.getString("current_location", null)
|
lastArrivalLocation = prefs.getString("current_location", null)
|
||||||
}
|
}
|
||||||
@@ -140,20 +147,53 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
|
|||||||
startActivity(Intent(this, SettingsActivity::class.java))
|
startActivity(Intent(this, SettingsActivity::class.java))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentTask == "patrol") {
|
taskController = TaskController(
|
||||||
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.ANGRY
|
scope = mainScope,
|
||||||
} else {
|
navController = navCon,
|
||||||
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.SMILE
|
getLastArrivalLocation = { lastArrivalLocation },
|
||||||
}
|
speak = { text ->
|
||||||
|
val ttsRequest = TtsRequest.create(text, false, language = TtsRequest.Language.ZH_CN)
|
||||||
|
robot.speak(ttsRequest)
|
||||||
|
},
|
||||||
|
setEmoji = { expression ->
|
||||||
|
binding.animatedEmojiView.currentExpression = expression
|
||||||
|
},
|
||||||
|
setReceptionButtonVisible = { visible ->
|
||||||
|
binding.btnReception.visibility = if (visible) android.view.View.VISIBLE else android.view.View.GONE
|
||||||
|
}
|
||||||
|
)
|
||||||
|
taskController.restoreState(
|
||||||
|
initialTask,
|
||||||
|
initialReceptionLocation,
|
||||||
|
initialReceptionText,
|
||||||
|
initialReceptionDestination,
|
||||||
|
initialNotificationLocation,
|
||||||
|
initialNotificationText
|
||||||
|
)
|
||||||
|
|
||||||
binding.btnReception.setOnClickListener {
|
binding.btnReception.setOnClickListener {
|
||||||
val destination = receptionDestination
|
val destination = taskController.confirmReception()
|
||||||
stopReceptionMode()
|
if (destination.isNullOrBlank()) {
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
val ttsRequest = TtsRequest.create("接待任务确认,请跟我来", false, language = TtsRequest.Language.ZH_CN)
|
val ttsRequest = TtsRequest.create("接待任务确认,请跟我来", false, language = TtsRequest.Language.ZH_CN)
|
||||||
robot.speak(ttsRequest)
|
robot.speak(ttsRequest)
|
||||||
navCon.goTo(destination, false)
|
navCon.goTo(destination, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
telemetryManager = TelemetryManager(
|
||||||
|
scope = mainScope,
|
||||||
|
taskProvider = { taskController.currentTask },
|
||||||
|
locationProvider = { lastArrivalLocation },
|
||||||
|
mqttConnectedProvider = { isMqttConnected },
|
||||||
|
liveKitConnectedProvider = { isLiveKitConnected },
|
||||||
|
publish = { topic, payload -> mqttManager?.publish(topic, payload) },
|
||||||
|
onLowBattery = { _ ->
|
||||||
|
val ttsRequest = TtsRequest.create("电量低,请及时充电。", false, language = TtsRequest.Language.ZH_CN)
|
||||||
|
robot.speak(ttsRequest)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
updateMqttConnection()
|
updateMqttConnection()
|
||||||
updateLiveKitStatusSnapshot()
|
updateLiveKitStatusSnapshot()
|
||||||
}
|
}
|
||||||
@@ -177,7 +217,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
|
|||||||
}
|
}
|
||||||
updateLiveKitConnection()
|
updateLiveKitConnection()
|
||||||
startBlinking()
|
startBlinking()
|
||||||
startTelemetry()
|
telemetryManager.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
@@ -194,7 +234,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
|
|||||||
// mqttManager?.disconnect() // Keep MQTT alive in background/settings
|
// mqttManager?.disconnect() // Keep MQTT alive in background/settings
|
||||||
liveKitManager?.disconnect()
|
liveKitManager?.disconnect()
|
||||||
stopBlinking()
|
stopBlinking()
|
||||||
stopTelemetry()
|
telemetryManager.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
@@ -234,21 +274,26 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
|
|||||||
TtsRequest.Status.STARTED -> {
|
TtsRequest.Status.STARTED -> {
|
||||||
Log.i("MainActivity", "TTS started")
|
Log.i("MainActivity", "TTS started")
|
||||||
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.TALKING
|
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.TALKING
|
||||||
|
liveKitManager?.setTtsMute(true)
|
||||||
}
|
}
|
||||||
TtsRequest.Status.COMPLETED,
|
TtsRequest.Status.COMPLETED,
|
||||||
TtsRequest.Status.CANCELED -> {
|
TtsRequest.Status.CANCELED,
|
||||||
|
TtsRequest.Status.NOT_ALLOWED -> {
|
||||||
Log.i("MainActivity", "TTS finished: ${ttsRequest.speech}")
|
Log.i("MainActivity", "TTS finished: ${ttsRequest.speech}")
|
||||||
if (currentTask == "patrol") {
|
if (taskController.currentTask == "patrol") {
|
||||||
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.ANGRY
|
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.ANGRY
|
||||||
} else {
|
} else {
|
||||||
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.SMILE
|
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.SMILE
|
||||||
}
|
}
|
||||||
|
liveKitManager?.setTtsMute(false)
|
||||||
}
|
}
|
||||||
TtsRequest.Status.ERROR -> {
|
TtsRequest.Status.ERROR -> {
|
||||||
Log.e("MainActivity", "TTS error: ${ttsRequest.speech}")
|
Log.e("MainActivity", "TTS error: ${ttsRequest.speech}")
|
||||||
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.SAD
|
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.SAD
|
||||||
|
liveKitManager?.setTtsMute(false)
|
||||||
|
showNetworkErrorBanner()
|
||||||
}
|
}
|
||||||
else -> { /* PENDING, PROCESSING, NOT_ALLOWED */ }
|
else -> { /* PENDING, PROCESSING */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,36 +301,15 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
|
|||||||
if (batteryData == null) {
|
if (batteryData == null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
latestBatteryLevel = batteryData.level
|
telemetryManager.onBatteryStatusChanged(batteryData)
|
||||||
latestBatteryCharging = batteryData.isCharging
|
|
||||||
latestBattery2Level = batteryData.battery2Level
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
if (batteryData.level <= batteryLowThreshold && batteryData.isCharging.not()) {
|
|
||||||
if (now - lastBatteryLowWarningAt > 10 * 60 * 1000L) {
|
|
||||||
lastBatteryLowWarningAt = now
|
|
||||||
val ttsRequest = TtsRequest.create("电量低,请及时充电。", false, language = TtsRequest.Language.ZH_CN)
|
|
||||||
robot.speak(ttsRequest)
|
|
||||||
publishEvent(
|
|
||||||
"battery_low",
|
|
||||||
JSONObject()
|
|
||||||
.put("level", batteryData.level)
|
|
||||||
.put("charging", batteryData.isCharging)
|
|
||||||
.put("battery2Level", batteryData.battery2Level)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
publishStatusSnapshot("battery")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMovementStatusChanged(type: String, status: String) {
|
override fun onMovementStatusChanged(type: String, status: String) {
|
||||||
latestMovementType = type
|
telemetryManager.onMovementStatusChanged(type, status)
|
||||||
latestMovementStatus = status
|
|
||||||
publishStatusSnapshot("movement")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCurrentPositionChanged(position: Position) {
|
override fun onCurrentPositionChanged(position: Position) {
|
||||||
latestPosition = position
|
telemetryManager.onCurrentPositionChanged(position)
|
||||||
publishStatusSnapshot("position")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onGoToLocationStatusChanged(location: String, status: String, descriptionId: Int, description: String) {
|
override fun onGoToLocationStatusChanged(location: String, status: String, descriptionId: Int, description: String) {
|
||||||
@@ -295,23 +319,29 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isAbort) {
|
if (isAbort) {
|
||||||
isLeavingHomeBase = false
|
taskController.clearLeavingHomeBase()
|
||||||
endNonSpecialTask("goTo aborted: $location, status=$status")
|
taskController.endNonSpecialTask("goTo aborted: $location, status=$status")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
if (lastArrivalLocation == location && now - lastArrivalAt < 5000L) {
|
if (lastArrivalLocation == location && now - lastArrivalAt < 5000L) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
isLeavingHomeBase = false
|
taskController.clearLeavingHomeBase()
|
||||||
lastArrivalLocation = location
|
lastArrivalLocation = location
|
||||||
lastArrivalAt = now
|
lastArrivalAt = now
|
||||||
prefs.edit().putString("current_location", location).apply()
|
prefs.edit().putString("current_location", location).apply()
|
||||||
if (currentTask == "patrol") {
|
if (normalizeLocation(location) == "homebase") {
|
||||||
handlePatrolArrival(location)
|
navCon.tiltAngle(20)
|
||||||
}
|
}
|
||||||
if (isSpecialModeEnabled() && currentTask.isEmpty()) {
|
if (taskController.currentTask == "patrol") {
|
||||||
Log.i("MainActivity", "Special task mode: arrival announcement skipped at $location.")
|
taskController.handlePatrolArrival(location)
|
||||||
|
}
|
||||||
|
if (taskController.handleNotificationArrival(location)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isSpecialStateEnabled() && taskController.currentTask.isEmpty()) {
|
||||||
|
Log.i("MainActivity", "Special state: arrival announcement skipped at $location.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val text = "已到达$location"
|
val text = "已到达$location"
|
||||||
@@ -321,74 +351,89 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetectionStateChanged(state: Int) {
|
override fun onDetectionStateChanged(state: Int) {
|
||||||
if (currentTask == "patrol" && state == DETECTED) {
|
latestDetectionState = state
|
||||||
val ttsRequest = TtsRequest.create("别妨碍我,我正在巡逻呢", false, language = TtsRequest.Language.ZH_CN)
|
Log.i("MainActivity", "Detection state changed: $state")
|
||||||
robot.speak(ttsRequest)
|
when (state) {
|
||||||
return
|
DETECTED -> {
|
||||||
}
|
idleConfirmJob?.cancel()
|
||||||
|
if (isDetectionStable || detectConfirmJob?.isActive == true) {
|
||||||
if (currentTask == "reception" && lastArrivalLocation == receptionLocation) {
|
return
|
||||||
when (state) {
|
}
|
||||||
DETECTED -> {
|
detectConfirmJob = mainScope.launch {
|
||||||
if (binding.btnReception.visibility != android.view.View.VISIBLE) {
|
delay(detectedConfirmDelayMs)
|
||||||
binding.btnReception.visibility = android.view.View.VISIBLE
|
if (latestDetectionState == DETECTED && !isDetectionStable) {
|
||||||
val ttsRequest = TtsRequest.create(receptionText, false, language = TtsRequest.Language.ZH_CN)
|
isDetectionStable = true
|
||||||
robot.speak(ttsRequest)
|
handleStableDetectionStateChanged(DETECTED)
|
||||||
Log.i("MainActivity", "Reception: Person detected (new session) at $receptionLocation")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IDLE -> {
|
}
|
||||||
binding.btnReception.visibility = android.view.View.GONE
|
IDLE -> {
|
||||||
Log.i("MainActivity", "Reception: Person left (IDLE)")
|
detectConfirmJob?.cancel()
|
||||||
|
if (!isDetectionStable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idleConfirmJob?.cancel()
|
||||||
|
idleConfirmJob = mainScope.launch {
|
||||||
|
delay(idleConfirmDelayMs)
|
||||||
|
if (latestDetectionState == IDLE && isDetectionStable) {
|
||||||
|
isDetectionStable = false
|
||||||
|
handleStableDetectionStateChanged(IDLE)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Home Base logic
|
private fun handleStableDetectionStateChanged(state: Int) {
|
||||||
if (currentTask == "" && lastArrivalLocation?.lowercase() == "home base" && !isLeavingHomeBase) {
|
Log.i("MainActivity", "Stable detection state: $state")
|
||||||
// Check if special task mode is enabled, if so, skip door logic
|
liveKitManager?.setDetectionActive(state == DETECTED)
|
||||||
if (isSpecialModeEnabled() && currentTask.isEmpty()) {
|
if (taskController.handleDetectionStateChanged(state)) {
|
||||||
Log.i("MainActivity", "Special task mode: Door logic skipped at Home Base.")
|
Log.i("MainActivity", "what the f**k")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val isSpecialState = isSpecialStateEnabled()
|
||||||
|
val isIdleTask = taskController.currentTask.isEmpty() || taskController.currentTask == "speech"
|
||||||
|
val atHomeBase = normalizeLocation(lastArrivalLocation) == "homebase"
|
||||||
|
val canHandleDoor = isIdleTask && atHomeBase && !taskController.isLeavingHomeBase && !isSpecialState
|
||||||
|
if (canHandleDoor) {
|
||||||
when (state) {
|
when (state) {
|
||||||
DETECTED -> {
|
DETECTED -> {
|
||||||
closeDoorJob?.cancel()
|
closeDoorJob?.cancel()
|
||||||
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.WINK
|
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.WINK
|
||||||
val ttsRequest = TtsRequest.create("正在为你开门,请稍等。", false, language = TtsRequest.Language.ZH_CN)
|
val ttsRequest = TtsRequest.create("正在为你开门,请稍等。", false, language = TtsRequest.Language.ZH_CN)
|
||||||
robot.speak(ttsRequest)
|
robot.speak(ttsRequest)
|
||||||
// mainScope.launch {
|
mainScope.launch {
|
||||||
// HttpManager.workflow_execute(
|
val result = HttpManager.workflow_execute(
|
||||||
// context = this@MainActivity,
|
context = this@MainActivity,
|
||||||
// apiKey = "wf_865e80f5fc1a4a319474a21d47470863",
|
apiKey = "wf_865e80f5fc1a4a319474a21d47470863",
|
||||||
// workflowId = "2031297462423851009",
|
workflowId = "2031297462423851009",
|
||||||
// inputs = emptyMap<String, Any>()
|
inputs = emptyMap<String, Any>()
|
||||||
// )
|
)
|
||||||
// }
|
if (result == null) {
|
||||||
|
showNetworkErrorBanner()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
IDLE -> {
|
IDLE -> {
|
||||||
closeDoorJob = mainScope.launch {
|
closeDoorJob = mainScope.launch {
|
||||||
delay(5000)
|
|
||||||
val ttsRequest = TtsRequest.create("正准备关门,请小心被夹。", false, language = TtsRequest.Language.ZH_CN)
|
val ttsRequest = TtsRequest.create("正准备关门,请小心被夹。", false, language = TtsRequest.Language.ZH_CN)
|
||||||
robot.speak(ttsRequest)
|
robot.speak(ttsRequest)
|
||||||
// mainScope.launch {
|
mainScope.launch {
|
||||||
// HttpManager.workflow_execute(
|
val result = HttpManager.workflow_execute(
|
||||||
// context = this@MainActivity,
|
context = this@MainActivity,
|
||||||
// apiKey = "wf_c02aa853371345dbb29572641d083c24",
|
apiKey = "wf_c02aa853371345dbb29572641d083c24",
|
||||||
// workflowId = "2031634633458520065",
|
workflowId = "2031634633458520065",
|
||||||
// inputs = emptyMap<String, Any>()
|
inputs = emptyMap<String, Any>()
|
||||||
// )
|
)
|
||||||
// }
|
if (result == null) {
|
||||||
|
showNetworkErrorBanner()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (lastArrivalLocation?.lowercase() != "home base" && currentTask.isEmpty() && state == DETECTED) {
|
if (isIdleTask && !atHomeBase && state == DETECTED && !isSpecialState) {
|
||||||
if (isSpecialModeEnabled()) {
|
|
||||||
Log.i("MainActivity", "Special task mode enabled (pref check), skipping greeting.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val hour = java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY)
|
val hour = java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY)
|
||||||
val greeting = when (hour) {
|
val greeting = when (hour) {
|
||||||
in 6..11 -> "早上好"
|
in 6..11 -> "早上好"
|
||||||
@@ -401,148 +446,37 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun normalizeLocation(value: String?): String {
|
||||||
|
return value.orEmpty()
|
||||||
|
.trim()
|
||||||
|
.lowercase()
|
||||||
|
.replace(" ", "")
|
||||||
|
.replace("_", "")
|
||||||
|
.replace("-", "")
|
||||||
|
}
|
||||||
|
|
||||||
fun startReceptionMode(location: String, text: String, destination: String) {
|
fun startReceptionMode(location: String, text: String, destination: String) {
|
||||||
setCurrentTask("reception")
|
taskController.startReceptionMode(location, text, destination)
|
||||||
receptionLocation = location
|
|
||||||
receptionText = text
|
|
||||||
receptionDestination = destination
|
|
||||||
Log.i("MainActivity", "Reception mode started: location=$location, text=$text, dest=$destination")
|
|
||||||
if (lastArrivalLocation != location) {
|
|
||||||
navCon.goTo(location, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stopReceptionMode() {
|
|
||||||
setCurrentTask("")
|
|
||||||
receptionLocation = ""
|
|
||||||
receptionText = ""
|
|
||||||
receptionDestination = ""
|
|
||||||
binding.btnReception.visibility = android.view.View.GONE
|
|
||||||
Log.i("MainActivity", "Reception mode stopped")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun endNonSpecialTask(reason: String) {
|
|
||||||
if (currentTask.isEmpty()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (currentTask != "patrol" && currentTask != "reception") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Log.i("MainActivity", "Ending task '$currentTask': $reason")
|
|
||||||
setCurrentTask("")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startPatrolMode(route: List<String>, times: Int = 1, waiting: Int = 3, nonStop: Boolean = false) {
|
fun startPatrolMode(route: List<String>, times: Int = 1, waiting: Int = 3, nonStop: Boolean = false) {
|
||||||
if (route.isEmpty()) {
|
taskController.startPatrolMode(route, times, waiting, nonStop)
|
||||||
setCurrentTask("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
patrolRoute = route
|
|
||||||
patrolIndex = 0
|
|
||||||
patrolLoopsRemaining = times.coerceAtLeast(1)
|
|
||||||
patrolWaitingSeconds = waiting.coerceAtLeast(0)
|
|
||||||
patrolNonStop = nonStop
|
|
||||||
patrolMoveJob?.cancel()
|
|
||||||
setCurrentTask("patrol")
|
|
||||||
Log.i("MainActivity", "Patrol mode started: route=${route.joinToString()}")
|
|
||||||
moveToCurrentPatrolTarget()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handlePatrolArrival(location: String) {
|
fun startNotificationMode(location: String, text: String) {
|
||||||
if (patrolRoute.isEmpty()) {
|
taskController.startNotificationMode(location, text)
|
||||||
return
|
|
||||||
}
|
|
||||||
val matchIndex = patrolRoute.indexOfFirst { it.equals(location, ignoreCase = true) }
|
|
||||||
if (matchIndex == -1) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (matchIndex >= patrolIndex) {
|
|
||||||
patrolIndex = matchIndex + 1
|
|
||||||
}
|
|
||||||
if (patrolIndex >= patrolRoute.size) {
|
|
||||||
patrolLoopsRemaining -= 1
|
|
||||||
if (patrolLoopsRemaining <= 0) {
|
|
||||||
Log.i("MainActivity", "Patrol route completed: ${patrolRoute.joinToString()}")
|
|
||||||
setCurrentTask("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
patrolIndex = 0
|
|
||||||
}
|
|
||||||
scheduleNextPatrolMove()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun moveToCurrentPatrolTarget() {
|
|
||||||
if (currentTask != "patrol") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val target = patrolRoute.getOrNull(patrolIndex) ?: return
|
|
||||||
if (lastArrivalLocation?.equals(target, ignoreCase = true) == true) {
|
|
||||||
patrolIndex += 1
|
|
||||||
if (patrolIndex >= patrolRoute.size) {
|
|
||||||
Log.i("MainActivity", "Patrol route completed: ${patrolRoute.joinToString()}")
|
|
||||||
setCurrentTask("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
moveToCurrentPatrolTarget()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Log.i("MainActivity", "Patrol moving to next target: $target")
|
|
||||||
if (lastArrivalLocation?.equals("home base", ignoreCase = true) == true &&
|
|
||||||
!target.equals("home base", ignoreCase = true)
|
|
||||||
) {
|
|
||||||
isLeavingHomeBase = true
|
|
||||||
}
|
|
||||||
navCon.goTo(target, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scheduleNextPatrolMove() {
|
|
||||||
patrolMoveJob?.cancel()
|
|
||||||
if (patrolNonStop || patrolWaitingSeconds <= 0) {
|
|
||||||
moveToCurrentPatrolTarget()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
patrolMoveJob = mainScope.launch {
|
|
||||||
delay(patrolWaitingSeconds * 1000L)
|
|
||||||
moveToCurrentPatrolTarget()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCurrentTask(task: String) {
|
fun setCurrentTask(task: String) {
|
||||||
val finalTask = task
|
taskController.setCurrentTask(task)
|
||||||
|
|
||||||
// Avoid re-setting the same task
|
|
||||||
if (currentTask == finalTask) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
currentTask = finalTask
|
|
||||||
Log.i("MainActivity", "Current task set to: '$finalTask'")
|
|
||||||
if (finalTask == "patrol") {
|
|
||||||
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.ANGRY
|
|
||||||
} else {
|
|
||||||
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.SMILE
|
|
||||||
}
|
|
||||||
if (finalTask != "patrol") {
|
|
||||||
patrolRoute = emptyList()
|
|
||||||
patrolIndex = 0
|
|
||||||
patrolLoopsRemaining = 1
|
|
||||||
patrolWaitingSeconds = 3
|
|
||||||
patrolNonStop = false
|
|
||||||
patrolMoveJob?.cancel()
|
|
||||||
patrolMoveJob = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun markSpeechTaskActive() {
|
fun markSpeechTaskActive() {
|
||||||
if (currentTask.isEmpty() || currentTask == "speech") {
|
taskController.markSpeechTaskActive()
|
||||||
setCurrentTask("speech")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearSpeechTaskIfActive() {
|
fun clearSpeechTaskIfActive() {
|
||||||
if (currentTask == "speech") {
|
taskController.clearSpeechTaskIfActive()
|
||||||
setCurrentTask("")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||||
@@ -555,17 +489,24 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
|
|||||||
lastArrivalLocation = sharedPreferences?.getString("current_location", null)
|
lastArrivalLocation = sharedPreferences?.getString("current_location", null)
|
||||||
Log.i("MainActivity", "Current location updated manually: $lastArrivalLocation")
|
Log.i("MainActivity", "Current location updated manually: $lastArrivalLocation")
|
||||||
}
|
}
|
||||||
if (key == "special_task_mode") {
|
if (key == specialStateKey) {
|
||||||
val isSpecial = sharedPreferences?.getBoolean("special_task_mode", false) == true
|
val isSpecial = isSpecialStateEnabled()
|
||||||
Log.i("MainActivity", "Special mode pref changed: $isSpecial, currentTask: $currentTask")
|
Log.i("MainActivity", "Special state pref changed: $isSpecial, currentTask: ${taskController.currentTask}")
|
||||||
}
|
}
|
||||||
if (key == liveKitUrlKey || key == liveKitRoomKey || key == liveKitTokenKey || key == liveKitEnabledKey) {
|
if (key == LiveKitManager.PREF_KEY_URL ||
|
||||||
|
key == LiveKitManager.PREF_KEY_ROOM ||
|
||||||
|
key == LiveKitManager.PREF_KEY_TOKEN ||
|
||||||
|
key == LiveKitManager.PREF_KEY_ENABLED
|
||||||
|
) {
|
||||||
updateLiveKitConnection()
|
updateLiveKitConnection()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isSpecialModeEnabled(): Boolean {
|
private fun isSpecialStateEnabled(): Boolean {
|
||||||
return ::prefs.isInitialized && prefs.getBoolean("special_task_mode", false)
|
if (!::prefs.isInitialized) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return prefs.getBoolean(specialStateKey, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateMqttConnection() {
|
private fun updateMqttConnection() {
|
||||||
@@ -585,14 +526,14 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun updateLiveKitConnection() {
|
private fun updateLiveKitConnection() {
|
||||||
val enabled = prefs.getBoolean(liveKitEnabledKey, true)
|
val enabled = prefs.getBoolean(LiveKitManager.PREF_KEY_ENABLED, true)
|
||||||
val url = resolveLiveKitUrl()
|
val url = resolveLiveKitUrl()
|
||||||
val room = prefs.getString(liveKitRoomKey, liveKitRoomDefault).orEmpty()
|
val room = prefs.getString(LiveKitManager.PREF_KEY_ROOM, LiveKitManager.DEFAULT_ROOM).orEmpty()
|
||||||
val savedToken = prefs.getString(liveKitTokenKey, "").orEmpty()
|
val savedToken = prefs.getString(LiveKitManager.PREF_KEY_TOKEN, "").orEmpty()
|
||||||
val token = if (savedToken.isBlank()) {
|
val token = if (savedToken.isBlank()) {
|
||||||
buildLiveKitToken(
|
buildLiveKitToken(
|
||||||
apiKey = liveKitApiKeyDefault,
|
apiKey = LiveKitManager.DEFAULT_API_KEY,
|
||||||
apiSecret = liveKitApiSecretDefault,
|
apiSecret = LiveKitManager.DEFAULT_API_SECRET,
|
||||||
room = room,
|
room = room,
|
||||||
identity = buildLiveKitIdentity()
|
identity = buildLiveKitIdentity()
|
||||||
)
|
)
|
||||||
@@ -630,7 +571,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
|
|||||||
ActivityCompat.requestPermissions(
|
ActivityCompat.requestPermissions(
|
||||||
this,
|
this,
|
||||||
arrayOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA),
|
arrayOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA),
|
||||||
liveKitPermissionRequestCode
|
LiveKitManager.PERMISSION_REQUEST_CODE
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,7 +581,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
|
|||||||
grantResults: IntArray
|
grantResults: IntArray
|
||||||
) {
|
) {
|
||||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||||
if (requestCode == liveKitPermissionRequestCode) {
|
if (requestCode == LiveKitManager.PERMISSION_REQUEST_CODE) {
|
||||||
val granted = grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED }
|
val granted = grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED }
|
||||||
if (granted) {
|
if (granted) {
|
||||||
updateLiveKitConnection()
|
updateLiveKitConnection()
|
||||||
@@ -698,9 +639,9 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun updateLiveKitStatusSnapshot() {
|
private fun updateLiveKitStatusSnapshot() {
|
||||||
val enabled = prefs.getBoolean(liveKitEnabledKey, true)
|
val enabled = prefs.getBoolean(LiveKitManager.PREF_KEY_ENABLED, true)
|
||||||
val url = resolveLiveKitUrl()
|
val url = resolveLiveKitUrl()
|
||||||
val room = prefs.getString(liveKitRoomKey, liveKitRoomDefault).orEmpty()
|
val room = prefs.getString(LiveKitManager.PREF_KEY_ROOM, LiveKitManager.DEFAULT_ROOM).orEmpty()
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
setLiveKitStatus(false)
|
setLiveKitStatus(false)
|
||||||
return
|
return
|
||||||
@@ -725,7 +666,48 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
|
|||||||
isMqttConnected = connected
|
isMqttConnected = connected
|
||||||
updateConnectionIndicator()
|
updateConnectionIndicator()
|
||||||
if (connected) {
|
if (connected) {
|
||||||
publishStatusSnapshot("mqtt_connected", true)
|
telemetryManager.publishStatusSnapshot("mqtt_connected", true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleAsrPayload(payload: String, topic: String?, participant: String?) {
|
||||||
|
val text = extractAsrText(payload) ?: return
|
||||||
|
val topicLabel = topic ?: "default"
|
||||||
|
val participantLabel = participant ?: "unknown"
|
||||||
|
Log.i("MainActivity", "ASR received: topic=$topicLabel, participant=$participantLabel, text=$text")
|
||||||
|
val data = JSONObject()
|
||||||
|
.put("type", "asr")
|
||||||
|
.put("text", text)
|
||||||
|
.put("topic", topicLabel)
|
||||||
|
.put("participant", participantLabel)
|
||||||
|
.put("ts", System.currentTimeMillis())
|
||||||
|
mqttManager?.publish("robot/asr", data.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractAsrText(payload: String): String? {
|
||||||
|
val trimmed = payload.trim()
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith("{")) {
|
||||||
|
return runCatching {
|
||||||
|
val obj = JSONObject(trimmed)
|
||||||
|
val text = obj.optString("text", "")
|
||||||
|
.ifBlank { obj.optString("transcript", "") }
|
||||||
|
.ifBlank { obj.optString("asr", "") }
|
||||||
|
.ifBlank { obj.optString("content", "") }
|
||||||
|
text.ifBlank { trimmed }
|
||||||
|
}.getOrDefault(trimmed)
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showNetworkErrorBanner() {
|
||||||
|
networkErrorJob?.cancel()
|
||||||
|
binding.tvNetworkError.visibility = View.VISIBLE
|
||||||
|
networkErrorJob = mainScope.launch {
|
||||||
|
delay(5000L)
|
||||||
|
binding.tvNetworkError.visibility = View.GONE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -741,15 +723,11 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveLiveKitUrl(): String {
|
private fun resolveLiveKitUrl(): String {
|
||||||
val savedUrl = prefs.getString(liveKitUrlKey, "").orEmpty().trim()
|
val savedUrl = prefs.getString(LiveKitManager.PREF_KEY_URL, "").orEmpty().trim()
|
||||||
if (savedUrl.isNotEmpty()) {
|
if (savedUrl.isNotEmpty()) {
|
||||||
return savedUrl
|
return savedUrl
|
||||||
}
|
}
|
||||||
val ip = prefs.getString("network_ip", "").orEmpty().trim()
|
return LiveKitManager.DEFAULT_URL
|
||||||
if (ip.isNotEmpty()) {
|
|
||||||
return "ws://$ip:7880"
|
|
||||||
}
|
|
||||||
return liveKitUrlDefault
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startBlinking() {
|
private fun startBlinking() {
|
||||||
@@ -774,92 +752,18 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
|
|||||||
blinkJob?.cancel()
|
blinkJob?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startTelemetry() {
|
|
||||||
stopTelemetry()
|
|
||||||
telemetryJob = mainScope.launch {
|
|
||||||
while (isActive) {
|
|
||||||
publishStatusSnapshot("heartbeat", true)
|
|
||||||
delay(15000L)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stopTelemetry() {
|
|
||||||
telemetryJob?.cancel()
|
|
||||||
telemetryJob = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun publishStatusSnapshot(reason: String, force: Boolean = false) {
|
fun publishStatusSnapshot(reason: String, force: Boolean = false) {
|
||||||
val now = System.currentTimeMillis()
|
telemetryManager.publishStatusSnapshot(reason, force)
|
||||||
if (!force && now - lastTelemetrySentAt < 5000L) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lastTelemetrySentAt = now
|
|
||||||
val payload = JSONObject()
|
|
||||||
.put("type", "status")
|
|
||||||
.put("reason", reason)
|
|
||||||
.put("ts", now)
|
|
||||||
.put("task", currentTask)
|
|
||||||
.put("location", lastArrivalLocation ?: JSONObject.NULL)
|
|
||||||
.put("mqttConnected", isMqttConnected)
|
|
||||||
.put("liveKitConnected", isLiveKitConnected)
|
|
||||||
|
|
||||||
val batteryJson = JSONObject()
|
|
||||||
if (latestBatteryLevel != null) {
|
|
||||||
batteryJson.put("level", latestBatteryLevel)
|
|
||||||
}
|
|
||||||
if (latestBatteryCharging != null) {
|
|
||||||
batteryJson.put("charging", latestBatteryCharging)
|
|
||||||
}
|
|
||||||
if (latestBattery2Level != null) {
|
|
||||||
batteryJson.put("battery2Level", latestBattery2Level)
|
|
||||||
}
|
|
||||||
if (batteryJson.length() > 0) {
|
|
||||||
payload.put("battery", batteryJson)
|
|
||||||
}
|
|
||||||
|
|
||||||
val movementJson = JSONObject()
|
|
||||||
if (!latestMovementType.isNullOrBlank()) {
|
|
||||||
movementJson.put("type", latestMovementType)
|
|
||||||
}
|
|
||||||
if (!latestMovementStatus.isNullOrBlank()) {
|
|
||||||
movementJson.put("status", latestMovementStatus)
|
|
||||||
}
|
|
||||||
if (movementJson.length() > 0) {
|
|
||||||
payload.put("movement", movementJson)
|
|
||||||
}
|
|
||||||
|
|
||||||
val position = latestPosition
|
|
||||||
if (position != null) {
|
|
||||||
payload.put(
|
|
||||||
"position",
|
|
||||||
JSONObject()
|
|
||||||
.put("x", position.x)
|
|
||||||
.put("y", position.y)
|
|
||||||
.put("yaw", position.yaw)
|
|
||||||
.put("tiltAngle", position.tiltAngle)
|
|
||||||
.put("inMapArea", position.isInMapArea)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
mqttManager?.publish("robot/status", payload.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun publishEvent(event: String, data: JSONObject) {
|
|
||||||
val payload = JSONObject()
|
|
||||||
.put("type", "event")
|
|
||||||
.put("event", event)
|
|
||||||
.put("ts", System.currentTimeMillis())
|
|
||||||
.put("data", data)
|
|
||||||
mqttManager?.publish("robot/event", payload.toString())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSaveInstanceState(outState: Bundle) {
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
super.onSaveInstanceState(outState)
|
super.onSaveInstanceState(outState)
|
||||||
outState.putString("currentTask", currentTask)
|
outState.putString("currentTask", taskController.currentTask)
|
||||||
outState.putString("receptionLocation", receptionLocation)
|
outState.putString("receptionLocation", taskController.getReceptionLocation())
|
||||||
outState.putString("receptionText", receptionText)
|
outState.putString("receptionText", taskController.getReceptionText())
|
||||||
outState.putString("receptionDestination", receptionDestination)
|
outState.putString("receptionDestination", taskController.getReceptionDestination())
|
||||||
|
outState.putString("notificationLocation", taskController.getNotificationLocation())
|
||||||
|
outState.putString("notificationText", taskController.getNotificationText())
|
||||||
outState.putString("lastArrivalLocation", lastArrivalLocation)
|
outState.putString("lastArrivalLocation", lastArrivalLocation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class MqttManager(
|
|||||||
private val ttsLanguageMap = mutableMapOf<TtsRequest, TtsRequest.Language>()
|
private val ttsLanguageMap = mutableMapOf<TtsRequest, TtsRequest.Language>()
|
||||||
private var currentStreamSessionId: String? = null
|
private var currentStreamSessionId: String? = null
|
||||||
private var currentStreamMessageId: String? = null
|
private var currentStreamMessageId: String? = null
|
||||||
|
private var danceJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
try {
|
try {
|
||||||
@@ -271,7 +272,7 @@ class MqttManager(
|
|||||||
when (action) {
|
when (action) {
|
||||||
"recharge" -> {
|
"recharge" -> {
|
||||||
speak("前往充电桩", "zh")
|
speak("前往充电桩", "zh")
|
||||||
navController.recharge()
|
navController.recharge()
|
||||||
}
|
}
|
||||||
"goto" -> {
|
"goto" -> {
|
||||||
val location = obj.optString("location", obj.optString("target", ""))
|
val location = obj.optString("location", obj.optString("target", ""))
|
||||||
@@ -290,10 +291,36 @@ class MqttManager(
|
|||||||
}
|
}
|
||||||
processStreamText(text, lang)
|
processStreamText(text, lang)
|
||||||
}
|
}
|
||||||
|
"notification" -> {
|
||||||
|
val location = obj.optString("location", obj.optString("target", ""))
|
||||||
|
val text = obj.optString("text", obj.optString("content", ""))
|
||||||
|
scope.launch(Dispatchers.Main) {
|
||||||
|
(context as? MainActivity)?.startNotificationMode(location, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
"repose" -> {
|
"repose" -> {
|
||||||
val ok = navController.repose()
|
val ok = navController.repose()
|
||||||
Log.i(TAG, "Repose command sent: $ok")
|
Log.i(TAG, "Repose command sent: $ok")
|
||||||
}
|
}
|
||||||
|
"turn" -> {
|
||||||
|
val degrees = obj.optInt("degrees", obj.optInt("angle", 0))
|
||||||
|
val speed = obj.optDouble("speed", 1.0).toFloat().coerceIn(0.0f, 1.0f)
|
||||||
|
if (degrees == 0) {
|
||||||
|
Log.w(TAG, "Turn ignored: degrees=0")
|
||||||
|
} else {
|
||||||
|
val ok = navController.turnBy(degrees, speed)
|
||||||
|
Log.i(TAG, "Turn command sent: degrees=$degrees, speed=$speed, result=$ok")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"tilt" -> {
|
||||||
|
val degrees = obj.optInt("degrees", obj.optInt("angle", 0))
|
||||||
|
val speed = obj.optDouble("speed", 1.0).toFloat().coerceIn(0.0f, 1.0f)
|
||||||
|
val ok = navController.tiltAngle(degrees, speed)
|
||||||
|
Log.i(TAG, "Tilt command sent: degrees=$degrees, speed=$speed, result=$ok")
|
||||||
|
}
|
||||||
|
"dance" -> {
|
||||||
|
startDance()
|
||||||
|
}
|
||||||
"stop" -> {
|
"stop" -> {
|
||||||
navController.stop()
|
navController.stop()
|
||||||
pauseTts()
|
pauseTts()
|
||||||
@@ -317,7 +344,7 @@ class MqttManager(
|
|||||||
speak("接到巡逻任务", "zh")
|
speak("接到巡逻任务", "zh")
|
||||||
val flag = obj.optBoolean("flag", true)
|
val flag = obj.optBoolean("flag", true)
|
||||||
val times = obj.optInt("times", 1)
|
val times = obj.optInt("times", 1)
|
||||||
val waiting = obj.optInt("waiting", obj.optInt("wait", 3))
|
val waiting = obj.optInt("waiting", obj.optInt("wait", 5))
|
||||||
val nonStop = obj.optBoolean("nonStop", obj.optBoolean("non_stop", false))
|
val nonStop = obj.optBoolean("nonStop", obj.optBoolean("non_stop", false))
|
||||||
var patrolLocations: List<String> = emptyList()
|
var patrolLocations: List<String> = emptyList()
|
||||||
if (flag) {
|
if (flag) {
|
||||||
@@ -359,6 +386,45 @@ class MqttManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startDance() {
|
||||||
|
danceJob?.cancel()
|
||||||
|
danceJob = scope.launch(Dispatchers.Main) {
|
||||||
|
val endAt = System.currentTimeMillis() + 20000L
|
||||||
|
val speed = 0.6f
|
||||||
|
navController.tiltAngle(20, speed)
|
||||||
|
while (isActive && System.currentTimeMillis() < endAt) {
|
||||||
|
when ((1..4).random()) {
|
||||||
|
1 -> {
|
||||||
|
val delta = listOf(-15, -10, -5, 5, 10, 15).random()
|
||||||
|
navController.tiltBy(delta, speed)
|
||||||
|
delay(500L)
|
||||||
|
}
|
||||||
|
2 -> {
|
||||||
|
val angle = listOf(-120, -90, -60, 60, 90, 120).random()
|
||||||
|
navController.turnBy(angle, speed)
|
||||||
|
delay(600L)
|
||||||
|
}
|
||||||
|
3 -> {
|
||||||
|
val forward = listOf(0.6f, 0.8f, 1.0f).random()
|
||||||
|
navController.skidJoy(forward, 0.0f)
|
||||||
|
delay(500L)
|
||||||
|
navController.skidJoy(0.0f, 0.0f)
|
||||||
|
delay(300L)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val rotate = listOf(-1.0f, -0.8f, -0.6f, 0.6f, 0.8f, 1.0f).random()
|
||||||
|
navController.skidJoy(0.0f, rotate)
|
||||||
|
delay(500L)
|
||||||
|
navController.skidJoy(0.0f, 0.0f)
|
||||||
|
delay(300L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
navController.skidJoy(0.0f, 0.0f)
|
||||||
|
navController.tiltAngle(20, speed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun processStreamText(text: String, langCode: String?) {
|
private fun processStreamText(text: String, langCode: String?) {
|
||||||
lastStreamLangCode = langCode
|
lastStreamLangCode = langCode
|
||||||
speechBuffer.append(text)
|
speechBuffer.append(text)
|
||||||
|
|||||||
@@ -23,6 +23,26 @@ class NavController(private val robot: Robot) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun turnBy(degrees: Int, speed: Float = 1.0f): Boolean {
|
||||||
|
robot.turnBy(degrees, speed)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tiltAngle(degrees: Int, speed: Float = 1.0f): Boolean {
|
||||||
|
robot.tiltAngle(degrees, speed)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tiltBy(degrees: Int, speed: Float = 1.0f): Boolean {
|
||||||
|
robot.tiltBy(degrees, speed)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun skidJoy(x: Float, y: Float): Boolean {
|
||||||
|
robot.skidJoy(x, y)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
fun getAllLocations(): List<String> {
|
fun getAllLocations(): List<String> {
|
||||||
return robot.locations
|
return robot.locations
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,14 +28,9 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
private lateinit var robot: Robot
|
private lateinit var robot: Robot
|
||||||
private lateinit var locationAdapter: ArrayAdapter<String>
|
private lateinit var locationAdapter: ArrayAdapter<String>
|
||||||
private val currentLocationKey = "current_location"
|
private val currentLocationKey = "current_location"
|
||||||
|
private val specialStateKey = "special_state"
|
||||||
private lateinit var prefs: SharedPreferences
|
private lateinit var prefs: SharedPreferences
|
||||||
private val liveKitUrlKey = "livekit_url"
|
|
||||||
private val liveKitRoomKey = "livekit_room"
|
|
||||||
private val liveKitTokenKey = "livekit_token"
|
|
||||||
private val liveKitEnabledKey = "livekit_enabled"
|
|
||||||
private val agentDempIdKey = "agent_demp_id"
|
private val agentDempIdKey = "agent_demp_id"
|
||||||
private val liveKitUrlDefault = "ws://192.168.2.236:7880"
|
|
||||||
private val liveKitRoomDefault = "temi-room"
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -50,10 +45,10 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
binding.etIpAddress.setText(savedIp)
|
binding.etIpAddress.setText(savedIp)
|
||||||
val savedDempId = prefs.getString(agentDempIdKey, "")
|
val savedDempId = prefs.getString(agentDempIdKey, "")
|
||||||
binding.etAgentDempId.setText(savedDempId)
|
binding.etAgentDempId.setText(savedDempId)
|
||||||
val savedLiveKitUrl = prefs.getString(liveKitUrlKey, resolveLiveKitUrl())
|
val savedLiveKitUrl = prefs.getString(LiveKitManager.PREF_KEY_URL, resolveLiveKitUrl())
|
||||||
val savedLiveKitRoom = prefs.getString(liveKitRoomKey, liveKitRoomDefault)
|
val savedLiveKitRoom = prefs.getString(LiveKitManager.PREF_KEY_ROOM, LiveKitManager.DEFAULT_ROOM)
|
||||||
val savedLiveKitToken = prefs.getString(liveKitTokenKey, "")
|
val savedLiveKitToken = prefs.getString(LiveKitManager.PREF_KEY_TOKEN, "")
|
||||||
val isLiveKitEnabled = prefs.getBoolean(liveKitEnabledKey, true)
|
val isLiveKitEnabled = prefs.getBoolean(LiveKitManager.PREF_KEY_ENABLED, true)
|
||||||
binding.etLiveKitUrl.setText(savedLiveKitUrl)
|
binding.etLiveKitUrl.setText(savedLiveKitUrl)
|
||||||
binding.etLiveKitRoom.setText(savedLiveKitRoom)
|
binding.etLiveKitRoom.setText(savedLiveKitRoom)
|
||||||
binding.etLiveKitToken.setText(savedLiveKitToken)
|
binding.etLiveKitToken.setText(savedLiveKitToken)
|
||||||
@@ -84,7 +79,7 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
binding.switchLiveKitAuto.setOnCheckedChangeListener { _, isChecked ->
|
binding.switchLiveKitAuto.setOnCheckedChangeListener { _, isChecked ->
|
||||||
prefs.edit().putBoolean(liveKitEnabledKey, isChecked).apply()
|
prefs.edit().putBoolean(LiveKitManager.PREF_KEY_ENABLED, isChecked).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.btnLiveKitSave.setOnClickListener {
|
binding.btnLiveKitSave.setOnClickListener {
|
||||||
@@ -94,10 +89,10 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
val token = binding.etLiveKitToken.text?.toString()?.trim().orEmpty()
|
val token = binding.etLiveKitToken.text?.toString()?.trim().orEmpty()
|
||||||
val enabled = binding.switchLiveKitAuto.isChecked
|
val enabled = binding.switchLiveKitAuto.isChecked
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString(liveKitUrlKey, url)
|
.putString(LiveKitManager.PREF_KEY_URL, url)
|
||||||
.putString(liveKitRoomKey, room)
|
.putString(LiveKitManager.PREF_KEY_ROOM, room)
|
||||||
.putString(liveKitTokenKey, token)
|
.putString(LiveKitManager.PREF_KEY_TOKEN, token)
|
||||||
.putBoolean(liveKitEnabledKey, enabled)
|
.putBoolean(LiveKitManager.PREF_KEY_ENABLED, enabled)
|
||||||
.apply()
|
.apply()
|
||||||
if (url.isBlank() || room.isBlank()) {
|
if (url.isBlank() || room.isBlank()) {
|
||||||
Toast.makeText(this, getString(R.string.msg_livekit_cleared), Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, getString(R.string.msg_livekit_cleared), Toast.LENGTH_SHORT).show()
|
||||||
@@ -113,7 +108,7 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupRestartButton()
|
setupRestartButton()
|
||||||
setupSpecialTaskSwitch()
|
setupSpecialStateSwitch()
|
||||||
setupLocationSelector()
|
setupLocationSelector()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,20 +117,22 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
refreshLocationList()
|
refreshLocationList()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupSpecialTaskSwitch() {
|
private fun setupSpecialStateSwitch() {
|
||||||
val isSpecialTaskMode = prefs.getBoolean("special_task_mode", false)
|
val isSpecialState = prefs.getBoolean(specialStateKey, false)
|
||||||
binding.switchSpecialTask.setOnCheckedChangeListener(null)
|
binding.switchSpecialTask.setOnCheckedChangeListener(null)
|
||||||
binding.switchSpecialTask.isChecked = isSpecialTaskMode
|
binding.switchSpecialTask.isChecked = isSpecialState
|
||||||
updateStatusIndicator(isSpecialTaskMode)
|
updateStatusIndicator(isSpecialState)
|
||||||
binding.switchSpecialTask.setOnCheckedChangeListener { _, isChecked ->
|
binding.switchSpecialTask.setOnCheckedChangeListener { _, isChecked ->
|
||||||
prefs.edit().putBoolean("special_task_mode", isChecked).apply()
|
prefs.edit()
|
||||||
|
.putBoolean(specialStateKey, isChecked)
|
||||||
|
.apply()
|
||||||
updateStatusIndicator(isChecked)
|
updateStatusIndicator(isChecked)
|
||||||
Log.i("SettingsActivity", "Special Task Mode changed to: $isChecked")
|
Log.i("SettingsActivity", "Special state changed to: $isChecked")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateStatusIndicator(isSpecialTaskMode: Boolean) {
|
private fun updateStatusIndicator(isSpecialState: Boolean) {
|
||||||
val indicatorColor = if (isSpecialTaskMode) {
|
val indicatorColor = if (isSpecialState) {
|
||||||
ContextCompat.getColor(this, android.R.color.holo_red_dark)
|
ContextCompat.getColor(this, android.R.color.holo_red_dark)
|
||||||
} else {
|
} else {
|
||||||
ContextCompat.getColor(this, android.R.color.holo_green_dark)
|
ContextCompat.getColor(this, android.R.color.holo_green_dark)
|
||||||
@@ -144,6 +141,7 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
indicatorDrawable.setColor(indicatorColor)
|
indicatorDrawable.setColor(indicatorColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun setupRestartButton() {
|
private fun setupRestartButton() {
|
||||||
binding.btnRestart.setOnTouchListener { _, event ->
|
binding.btnRestart.setOnTouchListener { _, event ->
|
||||||
when (event.action) {
|
when (event.action) {
|
||||||
@@ -243,14 +241,10 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveLiveKitUrl(): String {
|
private fun resolveLiveKitUrl(): String {
|
||||||
val savedUrl = prefs.getString(liveKitUrlKey, "").orEmpty().trim()
|
val savedUrl = prefs.getString(LiveKitManager.PREF_KEY_URL, "").orEmpty().trim()
|
||||||
if (savedUrl.isNotEmpty()) {
|
if (savedUrl.isNotEmpty()) {
|
||||||
return savedUrl
|
return savedUrl
|
||||||
}
|
}
|
||||||
val ip = prefs.getString("network_ip", "").orEmpty().trim()
|
return LiveKitManager.DEFAULT_URL
|
||||||
if (ip.isNotEmpty()) {
|
|
||||||
return "ws://$ip:7880"
|
|
||||||
}
|
|
||||||
return liveKitUrlDefault
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvNetworkError"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="24dp"
|
||||||
|
android:layout_marginEnd="24dp"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:background="@android:color/holo_red_dark"
|
||||||
|
android:gravity="center"
|
||||||
|
android:paddingStart="12dp"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
android:paddingEnd="12dp"
|
||||||
|
android:paddingBottom="8dp"
|
||||||
|
android:text="网络异常"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/statusIndicator" />
|
||||||
|
|
||||||
<com.example.lzwcai_terminal_temi.AnimatedEmojiView
|
<com.example.lzwcai_terminal_temi.AnimatedEmojiView
|
||||||
android:id="@+id/animatedEmojiView"
|
android:id="@+id/animatedEmojiView"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
|
|||||||
@@ -310,7 +310,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="特殊任务模式"
|
android:text="特殊状态"
|
||||||
android:textColor="@color/text_primary"
|
android:textColor="@color/text_primary"
|
||||||
android:textSize="22sp"
|
android:textSize="22sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
@@ -319,7 +319,7 @@
|
|||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
android:text="启用特定场景下的任务逻辑"
|
android:text="启用特定场景下的行为逻辑"
|
||||||
android:textColor="@color/text_secondary"
|
android:textColor="@color/text_secondary"
|
||||||
android:textSize="16sp" />
|
android:textSize="16sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -30,6 +30,27 @@
|
|||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvNetworkError"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="24dp"
|
||||||
|
android:layout_marginEnd="24dp"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:background="@android:color/holo_red_dark"
|
||||||
|
android:gravity="center"
|
||||||
|
android:paddingStart="12dp"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
android:paddingEnd="12dp"
|
||||||
|
android:paddingBottom="8dp"
|
||||||
|
android:text="网络异常"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/statusIndicator" />
|
||||||
|
|
||||||
<com.example.lzwcai_terminal_temi.AnimatedEmojiView
|
<com.example.lzwcai_terminal_temi.AnimatedEmojiView
|
||||||
android:id="@+id/animatedEmojiView"
|
android:id="@+id/animatedEmojiView"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
|
|||||||
@@ -293,7 +293,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="特殊任务模式"
|
android:text="特殊状态"
|
||||||
android:textColor="@color/text_primary"
|
android:textColor="@color/text_primary"
|
||||||
android:textSize="22sp"
|
android:textSize="22sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
@@ -302,7 +302,7 @@
|
|||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
android:text="启用特定场景下的任务逻辑"
|
android:text="启用特定场景下的行为逻辑"
|
||||||
android:textColor="@color/text_secondary"
|
android:textColor="@color/text_secondary"
|
||||||
android:textSize="16sp" />
|
android:textSize="16sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
75
technique.md
75
technique.md
@@ -106,6 +106,15 @@
|
|||||||
- 若为巡逻任务则推进巡逻索引
|
- 若为巡逻任务则推进巡逻索引
|
||||||
- 特殊任务模式下可跳过“已到达”播报
|
- 特殊任务模式下可跳过“已到达”播报
|
||||||
|
|
||||||
|
### 5.4 人体检测与录音逻辑
|
||||||
|
- 人体检测事件先做稳定判定(去抖)
|
||||||
|
- DETECTED:延迟确认(默认 0.8s),仍为 DETECTED 才进入“稳定检测”状态
|
||||||
|
- IDLE:延迟确认(默认 5s),仍为 IDLE 才退出“稳定检测”状态
|
||||||
|
- 录音(LiveKit)仅在稳定状态切换时开启/关闭
|
||||||
|
- 稳定 DETECTED:开启录音
|
||||||
|
- 稳定 IDLE:关闭录音
|
||||||
|
- 特殊任务模式仅跳过门禁与问候,不影响录音逻辑
|
||||||
|
|
||||||
## 6. 表情与语音联动
|
## 6. 表情与语音联动
|
||||||
|
|
||||||
- **TTS STARTED**:表情变为 TALKING
|
- **TTS STARTED**:表情变为 TALKING
|
||||||
@@ -137,6 +146,7 @@
|
|||||||
- 跳过 Home Base 的开门/关门语音逻辑
|
- 跳过 Home Base 的开门/关门语音逻辑
|
||||||
- 跳过检测到人时的问候语
|
- 跳过检测到人时的问候语
|
||||||
- 到达地点时不播报“已到达”(无任务状态下)
|
- 到达地点时不播报“已到达”(无任务状态下)
|
||||||
|
- 录音仍按稳定检测状态开启/关闭
|
||||||
|
|
||||||
## 9. LiveKit 连接
|
## 9. LiveKit 连接
|
||||||
|
|
||||||
@@ -157,4 +167,67 @@
|
|||||||
## 12. 安全注意事项
|
## 12. 安全注意事项
|
||||||
- MQTT 用户名/密码在代码内配置
|
- MQTT 用户名/密码在代码内配置
|
||||||
- LiveKit 默认 key/secret 也在代码内生成 token
|
- LiveKit 默认 key/secret 也在代码内生成 token
|
||||||
- 建议正式环境将敏感信息迁移至安全配置源
|
- 建议正式环境将敏感信息迁移至安全配置源
|
||||||
|
|
||||||
|
## 13. 时序图与逻辑图
|
||||||
|
|
||||||
|
### 13.1 人体检测去抖与录音时序
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant Temi as Temi SDK
|
||||||
|
participant Main as MainActivity
|
||||||
|
participant LK as LiveKitManager
|
||||||
|
Temi->>Main: onDetectionStateChanged(DETECTED)
|
||||||
|
Main->>Main: 启动 DETECTED 确认延迟
|
||||||
|
Main->>Main: 状态稳定为 DETECTED
|
||||||
|
Main->>LK: setDetectionActive(true)
|
||||||
|
Temi->>Main: onDetectionStateChanged(IDLE)
|
||||||
|
Main->>Main: 启动 IDLE 确认延迟
|
||||||
|
Main->>Main: 状态稳定为 IDLE
|
||||||
|
Main->>LK: setDetectionActive(false)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 13.2 稳定状态逻辑图(门禁/问候/录音)
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[稳定状态变化] --> B{state == DETECTED?}
|
||||||
|
B -- 是 --> C[录音开启]
|
||||||
|
B -- 否 --> D[录音关闭]
|
||||||
|
C --> E{空任务?}
|
||||||
|
D --> E
|
||||||
|
E -- 否 --> H[结束]
|
||||||
|
E -- 是 --> F{在 Home Base?}
|
||||||
|
F -- 是 --> G{特殊任务模式?}
|
||||||
|
G -- 否 --> I[执行开门/关门逻辑]
|
||||||
|
G -- 是 --> H
|
||||||
|
F -- 否 --> J{特殊任务模式?}
|
||||||
|
J -- 否 --> K[执行问候语]
|
||||||
|
J -- 是 --> H
|
||||||
|
```
|
||||||
|
|
||||||
|
### 13.3 接待与巡逻任务流程图
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[MQTT 指令进入] --> B{action}
|
||||||
|
B -- reception --> C[进入接待任务]
|
||||||
|
C --> D[导航到接待点]
|
||||||
|
D --> E{到达?}
|
||||||
|
E -- 否 --> D
|
||||||
|
E -- 是 --> F[检测到人提示确认]
|
||||||
|
F --> G{用户确认?}
|
||||||
|
G -- 否 --> F
|
||||||
|
G -- 是 --> H[播报确认语]
|
||||||
|
H --> I[导航到目的地]
|
||||||
|
I --> J[结束接待任务]
|
||||||
|
|
||||||
|
B -- patrol --> K[进入巡逻任务]
|
||||||
|
K --> L{随机/指定路线}
|
||||||
|
L --> M[导航到下一个点]
|
||||||
|
M --> N{到达?}
|
||||||
|
N -- 否 --> M
|
||||||
|
N -- 是 --> O[更新索引/剩余圈数]
|
||||||
|
O --> P{是否完成}
|
||||||
|
P -- 否 --> M
|
||||||
|
P -- 是 --> Q[结束巡逻任务]
|
||||||
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user