This commit is contained in:
2026-07-10 19:43:22 +08:00
parent 52b5861bed
commit a6ae1c9383
8 changed files with 1640 additions and 99 deletions

View File

@@ -4,12 +4,14 @@ import android.util.Log
import com.zklh.dronecontroller.core.diagnostics.DroneWarningItem import com.zklh.dronecontroller.core.diagnostics.DroneWarningItem
import com.zklh.dronecontroller.core.diagnostics.DroneWarningState import com.zklh.dronecontroller.core.diagnostics.DroneWarningState
import com.zklh.dronecontroller.core.msdk.DroneSdkState import com.zklh.dronecontroller.core.msdk.DroneSdkState
import com.zklh.dronecontroller.core.telemetry.TelemetryCameraIdentity
import com.zklh.dronecontroller.core.telemetry.TelemetrySnapshot import com.zklh.dronecontroller.core.telemetry.TelemetrySnapshot
import dji.v5.manager.diagnostic.WarningLevel import dji.v5.manager.diagnostic.WarningLevel
import java.util.UUID import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.atan2 import kotlin.math.atan2
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.roundToInt
import kotlin.math.sin import kotlin.math.sin
import kotlin.math.sqrt import kotlin.math.sqrt
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -35,13 +37,19 @@ import org.json.JSONObject
private const val CloudMqttLogTag = "ZklhCloudMqtt" private const val CloudMqttLogTag = "ZklhCloudMqtt"
private const val RcDomain = 2 private const val RcDomain = 2
private const val DroneDomain = 0 private const val DroneDomain = 0
private const val RcPlus2Type = 174
private const val Matrice4Type = 99
private const val RcSubType = 0 private const val RcSubType = 0
private const val Matrice4SubType = 1 private const val CloudRcType = 56
private const val Matrice4DeviceType = "0-99-1" private const val CloudRcPlusType = 119
private const val Matrice4CameraPayloadIndex = "89-0-0" private const val CloudRcProType = 144
private const val Matrice4LiveVideoIndex = "normal-0" private const val CloudRcPlus2Type = 174
private val KnownCloudRcTypes = setOf(CloudRcType, CloudRcPlusType, CloudRcProType, CloudRcPlus2Type)
private const val CloudMatrice4Type = 99
private const val CloudMatrice4DockType = 100
private const val CloudMatrice4ThermalPayloadType = 89
private const val CloudMatrice4DockThermalPayloadType = 99
private const val MsdkMatrice4SeriesProductType = 150
private const val MsdkMatrice4ThermalCameraType = 89
private const val DefaultLiveVideoIndex = "normal-0"
private const val ThingVersion = "1.2.0" private const val ThingVersion = "1.2.0"
private const val CloudAccessType = "msdk" private const val CloudAccessType = "msdk"
private const val OSD_MIN_INTERVAL_MS = 1_000L private const val OSD_MIN_INTERVAL_MS = 1_000L
@@ -61,9 +69,18 @@ private const val OsdCameraClass = "com.dji.sdk.cloudapi.device.OsdCamera"
private const val RcDronePayloadClass = "com.dji.sdk.cloudapi.device.RcDronePayload" private const val RcDronePayloadClass = "com.dji.sdk.cloudapi.device.RcDronePayload"
private const val EarthRadiusMeters = 6_371_000.0 private const val EarthRadiusMeters = 6_371_000.0
private data class CloudDeviceDescriptor(
val domain: Int,
val type: Int,
val subType: Int
) {
val deviceType: String
get() = "$domain-$type-$subType"
}
class CloudMqttService( class CloudMqttService(
private val loginClient: CloudLoginClient = CloudLoginClient() private val loginClient: CloudLoginClient = CloudLoginClient()
) { ) : CloudMissionProgressPublisher {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _state = MutableStateFlow(CloudMqttState()) private val _state = MutableStateFlow(CloudMqttState())
val state: StateFlow<CloudMqttState> = _state.asStateFlow() val state: StateFlow<CloudMqttState> = _state.asStateFlow()
@@ -369,24 +386,26 @@ class CloudMqttService(
if (rcSn.isBlank()) return if (rcSn.isBlank()) return
val subDevices = JSONArray() val subDevices = JSONArray()
if (online && identity.aircraftSn.isNotBlank()) { if (online && identity.aircraftSn.isNotBlank()) {
val droneDescriptor = telemetry.cloudDroneDescriptor()
subDevices.put( subDevices.put(
JSONObject() JSONObject()
.put("sn", identity.aircraftSn) .put("sn", identity.aircraftSn)
.put("domain", DroneDomain) .put("domain", droneDescriptor.domain)
.put("type", Matrice4Type) .put("type", droneDescriptor.type)
.put("sub_type", Matrice4SubType) .put("sub_type", droneDescriptor.subType)
.put("index", "A") .put("index", "A")
.put("thing_version", ThingVersion) .put("thing_version", ThingVersion)
) )
} }
val rcDescriptor = telemetry.cloudRcDescriptor()
val payload = topicRequest(cloudSession) val payload = topicRequest(cloudSession)
.put("method", "update_topo") .put("method", "update_topo")
.put( .put(
"data", "data",
JSONObject() JSONObject()
.put("domain", RcDomain) .put("domain", rcDescriptor.domain)
.put("type", RcPlus2Type) .put("type", rcDescriptor.type)
.put("sub_type", RcSubType) .put("sub_type", rcDescriptor.subType)
.put("thing_version", ThingVersion) .put("thing_version", ThingVersion)
.put("access_type", CloudAccessType) .put("access_type", CloudAccessType)
.put("sub_devices", subDevices) .put("sub_devices", subDevices)
@@ -400,23 +419,30 @@ class CloudMqttService(
val rcSn = identity.remoteControllerSn val rcSn = identity.remoteControllerSn
val aircraftSn = identity.aircraftSn val aircraftSn = identity.aircraftSn
if (rcSn.isBlank() || aircraftSn.isBlank()) return if (rcSn.isBlank() || aircraftSn.isBlank()) return
val key = "$rcSn/$aircraftSn/${cloudSession.workspaceId}" val cameraIndexes = telemetry.cloudCameraPayloadIndexes()
val key = "$rcSn/$aircraftSn/${cloudSession.workspaceId}/${cameraIndexes.joinToString(",")}"
if (!force && liveCapacityKey == key) return if (!force && liveCapacityKey == key) return
val video = JSONObject() val video = JSONObject()
.put("video_index", Matrice4LiveVideoIndex) .put("video_index", DefaultLiveVideoIndex)
.put("video_type", "normal") .put("video_type", "normal")
.put("switchable_video_types", JSONArray().put("normal")) .put("switchable_video_types", JSONArray().put("normal"))
val camera = JSONObject() val cameras = JSONArray().apply {
.put("available_video_number", 1) cameraIndexes.forEach { cameraIndex ->
.put("coexist_video_number_max", 1) put(
.put("camera_index", Matrice4CameraPayloadIndex) JSONObject()
.put("video_list", JSONArray().put(video)) .put("available_video_number", 1)
.put("coexist_video_number_max", 1)
.put("camera_index", cameraIndex)
.put("video_list", JSONArray().put(JSONObject(video.toString())))
)
}
}
val device = JSONObject() val device = JSONObject()
.put("sn", aircraftSn) .put("sn", aircraftSn)
.put("available_video_number", 1) .put("available_video_number", cameraIndexes.size)
.put("coexist_video_number_max", 1) .put("coexist_video_number_max", 1)
.put("camera_list", JSONArray().put(camera)) .put("camera_list", cameras)
val payload = topicRequest(cloudSession) val payload = topicRequest(cloudSession)
.put("gateway", rcSn) .put("gateway", rcSn)
.put("method", "livestream_ability_update") .put("method", "livestream_ability_update")
@@ -426,7 +452,7 @@ class CloudMqttService(
.put( .put(
"live_capacity", "live_capacity",
JSONObject() JSONObject()
.put("available_video_number", 1) .put("available_video_number", cameraIndexes.size)
.put("coexist_video_number_max", 1) .put("coexist_video_number_max", 1)
.put("device_list", JSONArray().put(device)) .put("device_list", JSONArray().put(device))
) )
@@ -453,8 +479,9 @@ class CloudMqttService(
} }
val hmsItems = JSONArray().apply { val hmsItems = JSONArray().apply {
val inTheSky = telemetry.isFlying || telemetry.motorsOn val inTheSky = telemetry.isFlying || telemetry.motorsOn
val droneDeviceType = telemetry.cloudDroneDescriptor().deviceType
warningState.items warningState.items
.map { it.toHmsJson(inTheSky) } .map { it.toHmsJson(inTheSky, droneDeviceType) }
.forEach { put(it) } .forEach { put(it) }
} }
val key = "${identity.remoteControllerSn}/${identity.aircraftSn}/${hmsItems.hmsSignature()}" val key = "${identity.remoteControllerSn}/${identity.aircraftSn}/${hmsItems.hmsSignature()}"
@@ -480,6 +507,117 @@ class CloudMqttService(
} }
} }
override fun publishFlightTaskProgress(
flightId: String,
status: String,
percent: Int,
waylineId: Int,
currentWaypointIndex: Int,
resultCode: Int,
resultMessage: String
) {
if (flightId.isBlank()) return
val data = JSONObject()
.put("result", cloudApiResult(resultCode, resultMessage))
.put(
"output",
JSONObject()
.put("status", status)
.put(
"progress",
JSONObject()
.put("percent", percent.coerceIn(0, 100))
.put("wayline_id", waylineId)
.put("waylineId", waylineId)
.put("current_waypoint_index", currentWaypointIndex)
.put("currentWaypointIndex", currentWaypointIndex)
)
.put(
"ext",
JSONObject()
.put("flight_id", flightId)
.put("flightId", flightId)
.put("media_count", 0)
.put("mediaCount", 0)
)
)
publishCloudEvent("flighttask_progress", flightId, data)
}
override fun publishPointFlightProgress(
method: String,
commandId: String,
status: String,
waypointIndex: Int?,
remainingDistance: Float?,
remainingTime: Float?,
resultCode: Int,
resultMessage: String
) {
val isTakeoffToPointProgress = method == "takeoff_to_point_progress"
val effectiveWaypointIndex = waypointIndex ?: if (isTakeoffToPointProgress) 1 else null
if (method.isBlank() || (commandId.isBlank() && effectiveWaypointIndex == null)) return
val data = JSONObject()
.put("status", status.toCloudApiEnumValue())
.put("result", resultCode)
if (commandId.isNotBlank()) {
if (isTakeoffToPointProgress) {
data.put("flight_id", commandId)
.put("flightId", commandId)
} else {
data.put("fly_to_id", commandId)
.put("flyToId", commandId)
}
}
effectiveWaypointIndex?.let {
data.put("way_point_index", it)
.put("wayPointIndex", it)
.put("waypoint_index", it)
.put("waypointIndex", it)
}
remainingDistance?.let {
data.put("remaining_distance", it)
.put("remainingDistance", it)
}
remainingTime?.let {
val cloudValue: Any = if (isTakeoffToPointProgress) {
it.roundToInt().coerceAtLeast(0)
} else {
it
}
data.put("remaining_time", cloudValue)
.put("remainingTime", cloudValue)
}
publishCloudEvent(method, commandId.ifBlank { "waypoint-$effectiveWaypointIndex" }, data)
}
private fun publishCloudEvent(method: String, bid: String, data: JSONObject): Boolean {
val cloudSession = session ?: return false
if (!isConnected()) return false
val identity = deviceIdentity
val rcSn = identity.remoteControllerSn
if (rcSn.isBlank()) return false
val fromSn = identity.aircraftSn.ifBlank { rcSn }
val topicSn = fromSn
val payload = topicRequest(cloudSession)
.put("bid", bid.ifBlank { UUID.randomUUID().toString() })
.put("method", method)
.put("gateway", rcSn)
.put("from", fromSn)
.put("need_reply", false)
.put("data", data)
return publish("thing/product/$topicSn/events", payload, qos = 1)
}
private fun cloudApiResult(code: Int, message: String): JSONObject =
JSONObject()
.put("code", code)
.put("msg", message)
.put("message", message)
private fun String.toCloudApiEnumValue(): String =
trim().lowercase()
private fun logHmsDebug(message: String) { private fun logHmsDebug(message: String) {
if (message == hmsDebugKey) return if (message == hmsDebugKey) return
hmsDebugKey = message hmsDebugKey = message
@@ -583,9 +721,9 @@ class CloudMqttService(
.put("firmware_version", telemetry.firmwareVersion) .put("firmware_version", telemetry.firmwareVersion)
.put("battery", droneBatteryJson()) .put("battery", droneBatteryJson())
.put("position_state", dronePositionStateJson()) .put("position_state", dronePositionStateJson())
.put("payload", JSONArray().put(rcDronePayloadJson())) .put("payload", rcDronePayloadListJson())
.put("storage", storageJson()) .put("storage", storageJson())
.put("cameras", JSONArray().put(osdCameraJson())) .put("cameras", osdCameraListJson())
.put("height_limit", telemetry.heightLimit) .put("height_limit", telemetry.heightLimit)
.put("distance_limit_status", rcDistanceLimitStatusJson()) .put("distance_limit_status", rcDistanceLimitStatusJson())
.put("track_id", "") .put("track_id", "")
@@ -660,10 +798,17 @@ class CloudMqttService(
.put("total", telemetry.storageTotal.toCloudStorageUnit()) .put("total", telemetry.storageTotal.toCloudStorageUnit())
.put("used", telemetry.storageUsed().toCloudStorageUnit()) .put("used", telemetry.storageUsed().toCloudStorageUnit())
private fun osdCameraJson(): JSONObject = private fun osdCameraListJson(): JSONArray =
JSONArray().apply {
telemetry.cloudCameraPayloadIndexes().forEach { payloadIndex ->
put(osdCameraJson(payloadIndex))
}
}
private fun osdCameraJson(payloadIndex: String): JSONObject =
JSONObject() JSONObject()
.put(JsonClassKey, OsdCameraClass) .put(JsonClassKey, OsdCameraClass)
.put("payload_index", Matrice4CameraPayloadIndex) .put("payload_index", payloadIndex)
.put("camera_mode", telemetry.cameraModeCode()) .put("camera_mode", telemetry.cameraModeCode())
.put("photo_state", telemetry.photoState) .put("photo_state", telemetry.photoState)
.put("recording_state", telemetry.recordingState) .put("recording_state", telemetry.recordingState)
@@ -674,10 +819,17 @@ class CloudMqttService(
.put("ir_zoom_factor", telemetry.irZoomFactor) .put("ir_zoom_factor", telemetry.irZoomFactor)
.put("screen_split_enable", false) .put("screen_split_enable", false)
private fun rcDronePayloadJson(): JSONObject = private fun rcDronePayloadListJson(): JSONArray =
JSONArray().apply {
telemetry.cloudCameraPayloadIndexes().forEach { payloadIndex ->
put(rcDronePayloadJson(payloadIndex))
}
}
private fun rcDronePayloadJson(payloadIndex: String): JSONObject =
JSONObject() JSONObject()
.put(JsonClassKey, RcDronePayloadClass) .put(JsonClassKey, RcDronePayloadClass)
.put("payload_index", Matrice4CameraPayloadIndex) .put("payload_index", payloadIndex)
.put("gimbal_pitch", telemetry.gimbalPitch.toFloat()) .put("gimbal_pitch", telemetry.gimbalPitch.toFloat())
.put("gimbal_roll", telemetry.gimbalRoll.toFloat()) .put("gimbal_roll", telemetry.gimbalRoll.toFloat())
.put("gimbal_yaw", telemetry.gimbalYaw.toFloat()) .put("gimbal_yaw", telemetry.gimbalYaw.toFloat())
@@ -824,11 +976,11 @@ class CloudMqttService(
} }
} }
private fun DroneWarningItem.toHmsJson(inTheSky: Boolean): JSONObject { private fun DroneWarningItem.toHmsJson(inTheSky: Boolean, deviceType: String): JSONObject {
val hmsCode = code.toHmsCodeOrFallback(message) val hmsCode = code.toHmsCodeOrFallback(message)
return JSONObject() return JSONObject()
.put("code", hmsCode) .put("code", hmsCode)
.put("device_type", Matrice4DeviceType) .put("device_type", deviceType)
.put("imminent", level.isHmsAlarm()) .put("imminent", level.isHmsAlarm())
.put("in_the_sky", inTheSky) .put("in_the_sky", inTheSky)
.put("level", level.toHmsLevel()) .put("level", level.toHmsLevel())
@@ -959,6 +1111,186 @@ private fun TelemetrySnapshot.droneOsdLongitude(): Double =
private fun TelemetrySnapshot.droneOsdPositionSource(): String = private fun TelemetrySnapshot.droneOsdPositionSource(): String =
if (hasUsableRtkPosition()) "RTK" else "FC" if (hasUsableRtkPosition()) "RTK" else "FC"
private fun TelemetrySnapshot.cloudRcDescriptor(): CloudDeviceDescriptor =
CloudDeviceDescriptor(
domain = RcDomain,
type = cloudApiRcType(),
subType = RcSubType
)
private fun TelemetrySnapshot.cloudApiRcType(): Int {
val rcText = remoteControllerType.uppercase()
return when {
groundDeviceIdentity in KnownCloudRcTypes -> groundDeviceIdentity
remoteControllerTypeValue in KnownCloudRcTypes -> remoteControllerTypeValue
groundDeviceIdentity == 120 || remoteControllerTypeValue == 120 -> CloudRcPlus2Type
rcText.contains("RC_PLUS_2") || rcText.contains("RC PLUS 2") || rcText.contains("RCPLUS2") -> CloudRcPlus2Type
rcText.contains("RC_PLUS") || rcText.contains("RC PLUS") || rcText.contains("RCPLUS") -> CloudRcPlusType
rcText.contains("RC_PRO") || rcText.contains("RC PRO") || rcText.contains("RCPRO") -> CloudRcProType
rcText.contains("RC") -> CloudRcType
else -> CloudRcPlus2Type
}
}
private fun TelemetrySnapshot.cloudDroneDescriptor(): CloudDeviceDescriptor {
val type = cloudApiDroneType()
return CloudDeviceDescriptor(
domain = DroneDomain,
type = type,
subType = cloudDroneSubType()
)
}
private fun TelemetrySnapshot.cloudApiDroneType(): Int {
val product = productType.uppercase()
val camera = cloudCameraText()
return when {
product.contains("MATRICE_350") || product.contains("M350") -> 89
product.contains("MATRICE_300") || product.contains("M300") -> 60
product.contains("M30") -> 67
product.contains("MAVIC_3_ENTERPRISE") -> 77
product.contains("MATRICE_400") || product.contains("M400") -> 103
product.contains("MATRICE_4TD") || product.contains("M4TD") || product.contains("4TD") -> CloudMatrice4DockType
product.contains("MATRICE_4D") || product.contains("M4D") || product.contains("4D") -> CloudMatrice4DockType
isMsdkMatrice4SeriesProduct(product) && isMatrice4ThermalCamera(camera) -> CloudMatrice4DockType
product.contains("MATRICE_4T") || product.contains("M4T") || product.contains("4T") -> CloudMatrice4Type
product.contains("MATRICE_4E") || product.contains("M4E") || product.contains("4E") -> CloudMatrice4Type
product.contains("MATRICE_4") -> CloudMatrice4Type
else -> 0
}
}
private fun TelemetrySnapshot.cloudDroneSubType(): Int {
val camera = cloudCameraText()
val product = productType.uppercase()
return when {
camera.contains("M4TD") || camera.contains("M4T") -> 1
camera.contains("M4D") || camera.contains("M4E") -> 0
camera.contains("M3TD") || camera.contains("M3T") -> 1
camera.contains("M30T") -> 1
product.contains("MATRICE_4TD") || product.contains("M4TD") || product.contains("4TD") -> 1
product.contains("MATRICE_4D") || product.contains("M4D") || product.contains("4D") -> 0
product.contains("MATRICE_4T") || product.contains("M4T") || product.contains("4T") -> 1
product.contains("MATRICE_4E") || product.contains("M4E") || product.contains("4E") -> 0
product.contains("MATRICE_4D") || product.contains("MATRICE_4") -> {
if (camera.contains("M4D") || camera.contains("M4E")) 0 else 1
}
product.contains("M30_SERIES") || product.contains("MAVIC_3_ENTERPRISE") -> {
if (camera.contains("M30") || camera.contains("M3E") || camera.contains("M3M")) 0 else 1
}
else -> 0
}
}
private fun TelemetrySnapshot.cloudCameraPayloadIndexes(): List<String> {
val detectedIndexes = cameraIdentities
.mapNotNull { it.cloudCameraPayloadIndex(productType) }
.distinct()
return detectedIndexes.ifEmpty { listOf(cloudCameraPayloadIndex()) }
}
private fun TelemetrySnapshot.cloudCameraPayloadIndex(): String {
val payloadType = cloudPayloadTypeFor(
cameraTypeValue = cameraTypeValue,
cameraText = cloudCameraText(),
productText = productType
) ?: fallbackCloudPayloadType()
return "${payloadType.type}-${payloadType.subType}-${cameraComponent.toCloudPayloadPositionIndex()}"
}
private fun TelemetryCameraIdentity.cloudCameraPayloadIndex(productType: String): String? {
val payloadType = cloudPayloadTypeFor(
cameraTypeValue = cameraTypeValue,
cameraText = listOf(cameraType, payloadCameraType).joinToString(" "),
productText = productType
) ?: return null
return "${payloadType.type}-${payloadType.subType}-${component.toCloudPayloadPositionIndex()}"
}
private fun TelemetrySnapshot.cloudCameraText(): String =
(
listOf(cameraType, payloadCameraType) +
cameraIdentities.flatMap { listOf(it.cameraType, it.payloadCameraType) }
)
.joinToString(" ")
.uppercase()
private fun TelemetrySnapshot.fallbackCloudPayloadType(): CloudPayloadType =
cloudPayloadTypeFor(cameraTypeValue = 0, cameraText = "", productText = productType)
?: CloudPayloadType(type = 0, subType = 0)
private data class CloudPayloadType(
val type: Int,
val subType: Int = 0
)
private fun cloudPayloadTypeFor(cameraTypeValue: Int, cameraText: String, productText: String): CloudPayloadType? {
val camera = cameraText.uppercase()
val product = productText.uppercase()
val mapped = when {
camera.contains("P1") || camera.contains("ZENMUSE_P1") -> CloudPayloadType(50, 65535)
camera.contains("H30T") || camera.contains("ZENMUSE_H30T") -> CloudPayloadType(83)
camera.contains("H30") || camera.contains("ZENMUSE_H30") -> CloudPayloadType(82)
camera.contains("H20N") || camera.contains("ZENMUSE_H20N") -> CloudPayloadType(61)
camera.contains("H20T") || camera.contains("ZENMUSE_H20T") -> CloudPayloadType(43)
camera.contains("H20") || camera.contains("ZENMUSE_H20") -> CloudPayloadType(42)
camera.contains("L1") || camera.contains("ZENMUSE_L1") -> CloudPayloadType(53)
camera.contains("L2") || camera.contains("ZENMUSE_L2") -> CloudPayloadType(84)
camera.contains("L3") || camera.contains("ZENMUSE_L3") -> CloudPayloadType(165)
camera.contains("M4TD") -> CloudPayloadType(99)
camera.contains("M4D") -> CloudPayloadType(98)
isMsdkMatrice4SeriesProductText(product) && isMatrice4ThermalCamera(camera) -> CloudPayloadType(CloudMatrice4DockThermalPayloadType)
camera.contains("M4T") -> CloudPayloadType(CloudMatrice4ThermalPayloadType)
camera.contains("M4E") -> CloudPayloadType(88)
camera.contains("M3TD") -> CloudPayloadType(81)
camera.contains("M3D") -> CloudPayloadType(80)
camera.contains("M3TA") -> CloudPayloadType(129)
camera.contains("M3T") -> CloudPayloadType(67)
camera.contains("M3E") -> CloudPayloadType(66)
camera.contains("M30T") -> CloudPayloadType(53)
camera == "M30" || camera.contains(" M30 ") -> CloudPayloadType(52)
product.contains("MATRICE_350") || product.contains("M350") -> CloudPayloadType(43)
product.contains("MATRICE_4TD") || product.contains("M4TD") || product.contains("4TD") -> CloudPayloadType(99)
product.contains("MATRICE_4D") || product.contains("M4D") || product.contains("4D") -> CloudPayloadType(98)
product.contains("MATRICE_4T") || product.contains("M4T") || product.contains("4T") -> CloudPayloadType(89)
product.contains("MATRICE_4E") || product.contains("M4E") || product.contains("4E") -> CloudPayloadType(88)
product.contains("MATRICE_4") -> CloudPayloadType(89)
product.contains("MAVIC_3_ENTERPRISE") -> CloudPayloadType(66)
product.contains("M30_SERIES") -> CloudPayloadType(53)
else -> null
}
if (mapped != null) {
return mapped
}
if (cameraTypeValue > 0) {
return CloudPayloadType(
type = cameraTypeValue,
subType = cameraText.cloudPayloadSubType()
)
}
return null
}
private fun TelemetrySnapshot.isMsdkMatrice4SeriesProduct(product: String): Boolean =
productTypeValue == MsdkMatrice4SeriesProductType || isMsdkMatrice4SeriesProductText(product)
private fun isMsdkMatrice4SeriesProductText(product: String): Boolean =
product.contains("MATRICE_4_SERIES")
private fun isMatrice4ThermalCamera(camera: String): Boolean =
camera.contains("M4TD") || camera.contains("M4T") || camera.contains(MsdkMatrice4ThermalCameraType.toString())
private fun String.cloudPayloadSubType(): Int =
if (uppercase().contains("P1")) 65535 else 0
private fun String.toCloudPayloadPositionIndex(): Int =
when (uppercase()) {
"RIGHT", "PORT_2" -> 1
"UP", "PORT_3" -> 2
"PORT_4" -> 3
else -> 0
}
private fun TelemetrySnapshot.hasFixedPosition(): Boolean = private fun TelemetrySnapshot.hasFixedPosition(): Boolean =
(gpsValid && locationValid) || hasUsableRtkPosition() (gpsValid && locationValid) || hasUsableRtkPosition()

View File

@@ -2,21 +2,41 @@ package com.zklh.dronecontroller.core.flight
import com.zklh.dronecontroller.core.msdk.DjiCommandResult import com.zklh.dronecontroller.core.msdk.DjiCommandResult
import com.zklh.dronecontroller.core.safety.SafetyInterlock import com.zklh.dronecontroller.core.safety.SafetyInterlock
import com.zklh.dronecontroller.core.telemetry.TelemetrySnapshot
import com.zklh.dronecontroller.core.telemetry.isRtkBlockingTakeoff
import dji.sdk.keyvalue.key.FlightControllerKey import dji.sdk.keyvalue.key.FlightControllerKey
import dji.sdk.keyvalue.value.common.LocationCoordinate3D import dji.sdk.keyvalue.value.common.LocationCoordinate3D
import dji.sdk.keyvalue.value.flightcontroller.LookAtInfo import dji.sdk.keyvalue.value.flightcontroller.LookAtInfo
import dji.sdk.keyvalue.value.flightcontroller.LookAtMode import dji.sdk.keyvalue.value.flightcontroller.LookAtMode
import dji.v5.common.callback.CommonCallbacks
import dji.v5.common.error.IDJIError import dji.v5.common.error.IDJIError
import dji.v5.et.action import dji.v5.et.action
import dji.v5.et.create import dji.v5.et.create
import dji.v5.manager.aircraft.rtk.RTKCenter
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
class FlightControlService { class FlightControlService(
private val telemetryProvider: () -> TelemetrySnapshot = { TelemetrySnapshot() }
) {
suspend fun startTakeoff(): DjiCommandResult = suspend fun startTakeoff(): DjiCommandResult =
if (!SafetyInterlock.FLIGHT_COMMANDS_ENABLED) { if (!SafetyInterlock.FLIGHT_COMMANDS_ENABLED) {
DjiCommandResult.failed(SafetyInterlock.LOCKED_MESSAGE) DjiCommandResult.failed(SafetyInterlock.LOCKED_MESSAGE)
} else { } else {
val rtkAdjustment = disableUnreadyRtkBeforeTakeoff()
if (rtkAdjustment?.success == false) {
rtkAdjustment
} else {
val takeoffResult = performStartTakeoff()
if (takeoffResult.success && rtkAdjustment != null) {
DjiCommandResult.ok("${rtkAdjustment.message}${takeoffResult.message}")
} else {
takeoffResult
}
}
}
private suspend fun performStartTakeoff(): DjiCommandResult =
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
FlightControllerKey.KeyStartTakeoff.create().action({ FlightControllerKey.KeyStartTakeoff.create().action({
continuation.resume(DjiCommandResult.ok("自动起飞已开始")) continuation.resume(DjiCommandResult.ok("自动起飞已开始"))
@@ -24,6 +44,36 @@ class FlightControlService {
continuation.resume(DjiCommandResult.failed(error)) continuation.resume(DjiCommandResult.failed(error))
}) })
} }
private suspend fun disableUnreadyRtkBeforeTakeoff(): DjiCommandResult? {
val telemetry = telemetryProvider()
if (!telemetry.isRtkBlockingTakeoff()) return null
if (telemetry.motorsOn || telemetry.isFlying) {
return DjiCommandResult.failed("RTK 已开启但尚未就绪,电机已起转,不能自动关闭 RTK")
}
val disableResult = setAircraftRtkModuleEnabled(false)
return if (disableResult.success) {
DjiCommandResult.ok("RTK 信号未就绪,已先关闭 RTK")
} else {
DjiCommandResult.failed("RTK 信号未就绪,自动关闭 RTK 失败:${disableResult.message}")
}
}
private suspend fun setAircraftRtkModuleEnabled(enabled: Boolean): DjiCommandResult =
suspendCancellableCoroutine { continuation ->
RTKCenter.getInstance().setAircraftRTKModuleEnabled(
enabled,
object : CommonCallbacks.CompletionCallback {
override fun onSuccess() {
continuation.resume(DjiCommandResult.ok(if (enabled) "RTK 已开启" else "RTK 已关闭"))
}
override fun onFailure(error: IDJIError) {
continuation.resume(DjiCommandResult.failed(error))
}
}
)
} }
suspend fun stopTakeoff(): DjiCommandResult = suspend fun stopTakeoff(): DjiCommandResult =

View File

@@ -29,6 +29,8 @@ data class FlyToStatus(
val state: FlyToMissionState = FlyToMissionState.UNKNOWN, val state: FlyToMissionState = FlyToMissionState.UNKNOWN,
val mode: FlyToMode = FlyToMode.UNKNOWN, val mode: FlyToMode = FlyToMode.UNKNOWN,
val height: Int = 0, val height: Int = 0,
val heightMin: Int = 1,
val heightMax: Int = 1500,
val targetLatitude: Double = 0.0, val targetLatitude: Double = 0.0,
val targetLongitude: Double = 0.0, val targetLongitude: Double = 0.0,
val targetAltitude: Double = 0.0, val targetAltitude: Double = 0.0,
@@ -74,8 +76,12 @@ class FlyToService {
private val capabilityListener = object : IMissionCapabilityListener<FlyToCapability> { private val capabilityListener = object : IMissionCapabilityListener<FlyToCapability> {
override fun onMissionCapabilityUpdate(capability: FlyToCapability) { override fun onMissionCapabilityUpdate(capability: FlyToCapability) {
val heightMin = capability.heightRange?.min?.roundToInt()?.coerceAtLeast(1) ?: 1
val heightMax = capability.heightRange?.max?.roundToInt()?.coerceAtLeast(heightMin) ?: 1500
_status.update { _status.update {
it.copy( it.copy(
heightMin = heightMin,
heightMax = heightMax,
heightRangeText = capability.heightRange?.toString().orEmpty() heightRangeText = capability.heightRange?.toString().orEmpty()
) )
} }
@@ -107,26 +113,24 @@ class FlyToService {
DjiCommandResult.failed(SafetyInterlock.LOCKED_MESSAGE) DjiCommandResult.failed(SafetyInterlock.LOCKED_MESSAGE)
} else { } else {
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
val targetHeight = height.roundToInt().coerceAtLeast(1) val targetHeight = height.normalizedFlyToHeight()
val safeTakeoffHeight = securityTakeoffHeight val safeTakeoffHeight = securityTakeoffHeight
.coerceAtLeast(1) .coerceAtLeast(1)
.coerceAtMost(targetHeight) .coerceAtMost(targetHeight)
val target = FlyToTarget().apply { val target = FlyToTarget().apply {
targetLocation = LocationCoordinate3D(latitude, longitude, height) targetLocation = LocationCoordinate3D(latitude, longitude, targetHeight.toDouble())
this.maxSpeed = maxSpeed this.maxSpeed = maxSpeed
this.securityTakeoffHeight = safeTakeoffHeight this.securityTakeoffHeight = safeTakeoffHeight
} }
val param = FlyToParam().apply {
this.flyToMode = flyToMode
this.height = targetHeight
}
Log.d( Log.d(
FlyToLogTag, FlyToLogTag,
"startFlyTo lat=$latitude lon=$longitude height=$height mode=$flyToMode maxSpeed=$maxSpeed securityTakeoffHeight=$safeTakeoffHeight" "startFlyTo lat=$latitude lon=$longitude requestedHeight=$height targetHeight=$targetHeight " +
"mode=$flyToMode maxSpeed=$maxSpeed securityTakeoffHeight=$safeTakeoffHeight " +
"heightRange=${status.value.heightMin}..${status.value.heightMax}"
) )
manager.startMission( manager.startMission(
target, target,
param, null,
callback( callback(
continuation, continuation,
"指点飞行已开始", "指点飞行已开始",
@@ -138,7 +142,7 @@ class FlyToService {
height = targetHeight, height = targetHeight,
targetLatitude = latitude, targetLatitude = latitude,
targetLongitude = longitude, targetLongitude = longitude,
targetAltitude = height targetAltitude = targetHeight.toDouble()
) )
} }
}, },
@@ -172,18 +176,20 @@ class FlyToService {
DjiCommandResult.failed(SafetyInterlock.LOCKED_MESSAGE) DjiCommandResult.failed(SafetyInterlock.LOCKED_MESSAGE)
} else { } else {
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
val targetHeight = height.roundToInt().coerceAtLeast(1) val targetHeight = height.normalizedFlyToHeight()
val safeTakeoffHeight = securityTakeoffHeight val safeTakeoffHeight = securityTakeoffHeight
.coerceAtLeast(1) .coerceAtLeast(1)
.coerceAtMost(targetHeight) .coerceAtMost(targetHeight)
val target = FlyToTarget().apply { val target = FlyToTarget().apply {
targetLocation = LocationCoordinate3D(latitude, longitude, height) targetLocation = LocationCoordinate3D(latitude, longitude, targetHeight.toDouble())
this.maxSpeed = maxSpeed this.maxSpeed = maxSpeed
this.securityTakeoffHeight = safeTakeoffHeight this.securityTakeoffHeight = safeTakeoffHeight
} }
Log.d( Log.d(
FlyToLogTag, FlyToLogTag,
"updateFlyToTarget lat=$latitude lon=$longitude height=$height maxSpeed=$maxSpeed securityTakeoffHeight=$safeTakeoffHeight" "updateFlyToTarget lat=$latitude lon=$longitude requestedHeight=$height targetHeight=$targetHeight " +
"maxSpeed=$maxSpeed securityTakeoffHeight=$safeTakeoffHeight " +
"heightRange=${status.value.heightMin}..${status.value.heightMax}"
) )
manager.updateMissionTarget( manager.updateMissionTarget(
target, target,
@@ -195,7 +201,7 @@ class FlyToService {
it.copy( it.copy(
targetLatitude = latitude, targetLatitude = latitude,
targetLongitude = longitude, targetLongitude = longitude,
targetAltitude = height targetAltitude = targetHeight.toDouble()
) )
} }
} }
@@ -214,7 +220,7 @@ class FlyToService {
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
val param = FlyToParam().apply { val param = FlyToParam().apply {
if (mode != null) flyToMode = mode if (mode != null) flyToMode = mode
if (height != null) this.height = height.coerceAtLeast(1) if (height != null) this.height = height.toDouble().normalizedFlyToHeight()
} }
manager.updateMissionParam( manager.updateMissionParam(
param, param,
@@ -234,10 +240,11 @@ class FlyToService {
suspend fun setFlyToHeight(height: Int): DjiCommandResult = suspend fun setFlyToHeight(height: Int): DjiCommandResult =
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
val param = FlyToParam().apply { this.height = height } val targetHeight = height.toDouble().normalizedFlyToHeight()
val param = FlyToParam().apply { this.height = targetHeight }
manager.updateMissionParam( manager.updateMissionParam(
param, param,
callback(continuation, "指点飞行高度已更新:${height}m") callback(continuation, "指点飞行高度已更新:${targetHeight}m")
) )
} }
@@ -255,7 +262,15 @@ class FlyToService {
override fun onFailure(error: IDJIError) { override fun onFailure(error: IDJIError) {
onFailure() onFailure()
Log.w(FlyToLogTag, "$successMessage failed: $error")
continuation.resume(DjiCommandResult.failed(error)) continuation.resume(DjiCommandResult.failed(error))
} }
} }
private fun Double.normalizedFlyToHeight(): Int {
val range = status.value
return roundToInt()
.coerceAtLeast(range.heightMin)
.coerceAtMost(range.heightMax)
}
} }

View File

@@ -15,6 +15,7 @@ import dji.v5.manager.datacenter.livestream.LiveVideoBitrateMode
import dji.v5.manager.datacenter.livestream.StreamQuality import dji.v5.manager.datacenter.livestream.StreamQuality
import dji.v5.manager.datacenter.livestream.settings.RtmpSettings import dji.v5.manager.datacenter.livestream.settings.RtmpSettings
import dji.v5.manager.interfaces.ICameraStreamManager import dji.v5.manager.interfaces.ICameraStreamManager
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -58,17 +59,35 @@ class LiveStreamingService {
private var listenerRegistered = false private var listenerRegistered = false
private var lastConfig: LiveStartConfig? = null private var lastConfig: LiveStartConfig? = null
private var availableCameraIndexes: List<ComponentIndexType> = emptyList()
private val cameraListener = object : ICameraStreamManager.AvailableCameraUpdatedListener {
override fun onAvailableCameraUpdated(cameras: List<ComponentIndexType>) {
availableCameraIndexes = cameras
Log.d(LiveStreamLogTag, "available camera streams=$cameras")
}
override fun onCameraStreamEnableUpdate(enableMap: Map<ComponentIndexType, Boolean>) {
Log.d(LiveStreamLogTag, "camera stream enable=$enableMap")
}
}
private val statusListener = object : LiveStreamStatusListener { private val statusListener = object : LiveStreamStatusListener {
override fun onLiveStreamStatusUpdate(status: LiveStreamStatus?) { override fun onLiveStreamStatusUpdate(status: LiveStreamStatus?) {
val resolution = status?.resolution?.let { "${it.width}x${it.height}" }.orEmpty() val resolution = status?.resolution?.let { "${it.width}x${it.height}" }.orEmpty()
val fps = status?.fps ?: 0
val vbps = status?.vbps ?: 0
_state.update { _state.update {
it.copy( it.copy(
streaming = status?.isStreaming ?: liveStreamManager().isStreaming, streaming = status?.isStreaming ?: liveStreamManager().isStreaming,
fps = status?.fps ?: 0, fps = fps,
vbps = status?.vbps ?: 0, vbps = vbps,
resolution = resolution, resolution = resolution,
message = if (status?.isStreaming == true) "直播推流中" else it.message, message = when {
status?.isStreaming != true -> it.message
fps <= 0 || vbps <= 0 -> "直播已开启,但暂未收到视频帧"
else -> "直播推流中"
},
lastError = null lastError = null
) )
} }
@@ -89,6 +108,7 @@ class LiveStreamingService {
fun clear() { fun clear() {
if (listenerRegistered) { if (listenerRegistered) {
runCatching { liveStreamManager().removeLiveStreamStatusListener(statusListener) } runCatching { liveStreamManager().removeLiveStreamStatusListener(statusListener) }
runCatching { cameraStreamManager().removeAvailableCameraUpdatedListener(cameraListener) }
listenerRegistered = false listenerRegistered = false
} }
} }
@@ -163,7 +183,8 @@ class LiveStreamingService {
if (!stopResult.success) return stopResult if (!stopResult.success) return stopResult
} }
val cameraIndex = config.videoId.toCameraIndex() waitForAvailableCameraStreams()
val cameraIndex = selectCameraIndex(config.videoId)
runCatching { runCatching {
manager.cameraIndex = cameraIndex manager.cameraIndex = cameraIndex
cameraStreamManager().enableStream(cameraIndex, true) cameraStreamManager().enableStream(cameraIndex, true)
@@ -183,7 +204,8 @@ class LiveStreamingService {
Log.d( Log.d(
LiveStreamLogTag, LiveStreamLogTag,
"start live stream manual=$manual url=${config.url} videoId=${config.videoId} quality=${config.quality} camera=$cameraIndex" "start live stream manual=$manual url=${config.url} videoId=${config.videoId} " +
"quality=${config.quality} camera=$cameraIndex available=$availableCameraIndexes"
) )
_state.update { _state.update {
it.copy( it.copy(
@@ -215,12 +237,37 @@ class LiveStreamingService {
private fun ensureListener() { private fun ensureListener() {
if (listenerRegistered) return if (listenerRegistered) return
liveStreamManager().addLiveStreamStatusListener(statusListener) liveStreamManager().addLiveStreamStatusListener(statusListener)
cameraStreamManager().addAvailableCameraUpdatedListener(cameraListener)
listenerRegistered = true listenerRegistered = true
} }
private fun liveStreamManager() = MediaDataCenter.getInstance().liveStreamManager private fun liveStreamManager() = MediaDataCenter.getInstance().liveStreamManager
private fun cameraStreamManager() = MediaDataCenter.getInstance().cameraStreamManager private fun cameraStreamManager() = MediaDataCenter.getInstance().cameraStreamManager
private suspend fun waitForAvailableCameraStreams(timeoutMs: Long = 1_500L) {
val startAt = System.currentTimeMillis()
while (availableCameraIndexes.isEmpty() && System.currentTimeMillis() - startAt < timeoutMs) {
delay(100L)
}
}
private fun selectCameraIndex(videoId: String): ComponentIndexType {
val explicitIndex = videoId.toExplicitCameraIndex()
val available = availableCameraIndexes
if (explicitIndex != null && (available.isEmpty() || available.contains(explicitIndex))) {
return explicitIndex
}
if (videoId.hasCloudPayloadIndex() && available.isNotEmpty()) {
return available.firstNonFpvOrFirst()
}
return when {
available.contains(ComponentIndexType.LEFT_OR_MAIN) -> ComponentIndexType.LEFT_OR_MAIN
available.isNotEmpty() -> available.firstNonFpvOrFirst()
explicitIndex != null -> explicitIndex
else -> ComponentIndexType.LEFT_OR_MAIN
}
}
} }
private fun JSONObject.toLiveStartConfig(): LiveStartConfig? { private fun JSONObject.toLiveStartConfig(): LiveStartConfig? {
@@ -379,9 +426,20 @@ private fun Int.toStreamQuality(): StreamQuality =
else -> StreamQuality.HD else -> StreamQuality.HD
} }
private fun String.toCameraIndex(): ComponentIndexType = private fun String.toExplicitCameraIndex(): ComponentIndexType? =
when { when {
contains("/FPV", ignoreCase = true) -> ComponentIndexType.FPV contains("/FPV", ignoreCase = true) -> ComponentIndexType.FPV
contains("/right", ignoreCase = true) -> ComponentIndexType.RIGHT contains("/right", ignoreCase = true) -> ComponentIndexType.RIGHT
else -> ComponentIndexType.LEFT_OR_MAIN contains("/up", ignoreCase = true) -> ComponentIndexType.UP
contains("/port_1", ignoreCase = true) || contains("/port1", ignoreCase = true) -> ComponentIndexType.PORT_1
contains("/port_2", ignoreCase = true) || contains("/port2", ignoreCase = true) -> ComponentIndexType.PORT_2
contains("/port_3", ignoreCase = true) || contains("/port3", ignoreCase = true) -> ComponentIndexType.PORT_3
contains("/port_4", ignoreCase = true) || contains("/port4", ignoreCase = true) -> ComponentIndexType.PORT_4
else -> null
} }
private fun String.hasCloudPayloadIndex(): Boolean =
Regex("""\d+-\d+-\d+""").containsMatchIn(this)
private fun List<ComponentIndexType>.firstNonFpvOrFirst(): ComponentIndexType =
firstOrNull { it != ComponentIndexType.FPV } ?: first()

View File

@@ -18,8 +18,10 @@ import dji.sdk.keyvalue.key.DJIGimbalKey
import dji.sdk.keyvalue.key.DJIKey import dji.sdk.keyvalue.key.DJIKey
import dji.sdk.keyvalue.key.FlightControllerKey import dji.sdk.keyvalue.key.FlightControllerKey
import dji.sdk.keyvalue.key.KeyTools import dji.sdk.keyvalue.key.KeyTools
import dji.sdk.keyvalue.key.PayloadKey
import dji.sdk.keyvalue.key.ProductKey import dji.sdk.keyvalue.key.ProductKey
import dji.sdk.keyvalue.key.RemoteControllerKey import dji.sdk.keyvalue.key.RemoteControllerKey
import dji.sdk.keyvalue.value.camera.CameraType
import dji.sdk.keyvalue.key.RtkMobileStationKey import dji.sdk.keyvalue.key.RtkMobileStationKey
import dji.sdk.keyvalue.value.camera.CameraWorkMode import dji.sdk.keyvalue.value.camera.CameraWorkMode
import dji.sdk.keyvalue.value.camera.CameraMode import dji.sdk.keyvalue.value.camera.CameraMode
@@ -37,8 +39,11 @@ import dji.sdk.keyvalue.value.flightcontroller.GPSSignalLevel
import dji.sdk.keyvalue.value.flightcontroller.HeightAboveSeaLevelMsg import dji.sdk.keyvalue.value.flightcontroller.HeightAboveSeaLevelMsg
import dji.sdk.keyvalue.value.flightcontroller.RemoteControllerFlightMode import dji.sdk.keyvalue.value.flightcontroller.RemoteControllerFlightMode
import dji.sdk.keyvalue.value.flightcontroller.WindDirection import dji.sdk.keyvalue.value.flightcontroller.WindDirection
import dji.sdk.keyvalue.value.payload.PayloadCameraType
import dji.sdk.keyvalue.value.product.ProductType
import dji.sdk.keyvalue.value.remotecontroller.BatteryInfo import dji.sdk.keyvalue.value.remotecontroller.BatteryInfo
import dji.sdk.keyvalue.value.remotecontroller.RcGPSInfo import dji.sdk.keyvalue.value.remotecontroller.RcGPSInfo
import dji.sdk.keyvalue.value.remotecontroller.RemoteControllerType
import dji.sdk.keyvalue.value.rtkbasestation.RTKReferenceStationSource import dji.sdk.keyvalue.value.rtkbasestation.RTKReferenceStationSource
import dji.sdk.keyvalue.value.rtkbasestation.RTKServiceState import dji.sdk.keyvalue.value.rtkbasestation.RTKServiceState
import dji.sdk.keyvalue.value.rtkmobilestation.RTKLocation import dji.sdk.keyvalue.value.rtkmobilestation.RTKLocation
@@ -64,6 +69,21 @@ import kotlinx.coroutines.flow.update
private const val TelemetryLogTag = "ZklhTelemetry" private const val TelemetryLogTag = "ZklhTelemetry"
private const val RTK_START_RETRY_INTERVAL_MS = 20_000L private const val RTK_START_RETRY_INTERVAL_MS = 20_000L
private const val RTK_BOOTSTRAP_RETRY_INTERVAL_MS = 15_000L private const val RTK_BOOTSTRAP_RETRY_INTERVAL_MS = 15_000L
// Telemetry must not change the aircraft RTK configuration. In particular, an
// enabled-but-unfixed M350 RTK module prevents waypoint takeoff.
private const val AutoManageRtk = false
// Restore only the already-selected network RTK service. This mirrors the
// recovery performed by DJI Pilot without force-enabling RTK, changing its
// source, or changing the aircraft's maintain-accuracy setting.
private const val RestoreExistingNetworkRtkService = true
data class TelemetryCameraIdentity(
val component: String,
val cameraType: String = "",
val cameraTypeValue: Int = 0,
val payloadCameraType: String = "",
val payloadCameraTypeValue: Int = 0
)
data class TelemetrySnapshot( data class TelemetrySnapshot(
val latitude: Double = 0.0, val latitude: Double = 0.0,
@@ -119,6 +139,18 @@ data class TelemetrySnapshot(
val windDirection: Int = 0, val windDirection: Int = 0,
val windSpeed: Int = 0, val windSpeed: Int = 0,
val remainingFlightTime: Int = 0, val remainingFlightTime: Int = 0,
val productType: String = "",
val productTypeValue: Int = 0,
val remoteControllerType: String = "",
val remoteControllerTypeValue: Int = 0,
val groundDeviceIdentity: Int = 0,
val uavDeviceIdentity: Int = 0,
val cameraType: String = "",
val cameraTypeValue: Int = 0,
val payloadCameraType: String = "",
val payloadCameraTypeValue: Int = 0,
val cameraComponent: String = ComponentIndexType.LEFT_OR_MAIN.name,
val cameraIdentities: List<TelemetryCameraIdentity> = emptyList(),
val batteryPercentNeededToLand: Int = 7, val batteryPercentNeededToLand: Int = 7,
val batteryPercentNeededToLandKnown: Boolean = false, val batteryPercentNeededToLandKnown: Boolean = false,
val batteryPercentNeededToGoHome: Int = 14, val batteryPercentNeededToGoHome: Int = 14,
@@ -198,7 +230,11 @@ class TelemetryRepository(
-> { -> {
rtkStartInProgress = false rtkStartInProgress = false
rtkServiceStarted = false rtkServiceStarted = false
ensureRtkServiceStarted(lastRtkSystemState) if (AutoManageRtk) {
ensureRtkServiceStarted(lastRtkSystemState)
} else if (RestoreExistingNetworkRtkService) {
ensureExistingNetworkRtkServiceStarted(lastRtkSystemState)
}
} }
else -> { else -> {
// READY/CONNECTING/PROCESSING are transitional states; keep the current flags. // READY/CONNECTING/PROCESSING are transitional states; keep the current flags.
@@ -210,7 +246,11 @@ class TelemetryRepository(
Log.w(TelemetryLogTag, "RTKNetworkServiceError=$error") Log.w(TelemetryLogTag, "RTKNetworkServiceError=$error")
rtkStartInProgress = false rtkStartInProgress = false
rtkServiceStarted = false rtkServiceStarted = false
ensureRtkServiceStarted(lastRtkSystemState) if (AutoManageRtk) {
ensureRtkServiceStarted(lastRtkSystemState)
} else if (RestoreExistingNetworkRtkService) {
ensureExistingNetworkRtkServiceStarted(lastRtkSystemState)
}
} }
} }
private val _state = MutableStateFlow(TelemetrySnapshot()) private val _state = MutableStateFlow(TelemetrySnapshot())
@@ -288,6 +328,42 @@ class TelemetryRepository(
_state.update { it.copy(firmwareVersion = value, error = null) } _state.update { it.copy(firmwareVersion = value, error = null) }
} }
} }
listenSafely("KeyProductType", KeyTools.createKey(ProductKey.KeyProductType)) { productType: ProductType? ->
val value = productType?.name.orEmpty()
if (value.isNotBlank()) {
val typeValue = productType?.value() ?: 0
_state.update { it.copy(productType = value, productTypeValue = typeValue, error = null) }
Log.d(TelemetryLogTag, "KeyProductType=$value value=$typeValue")
}
}
listenSafely("KeyRemoteControllerType", KeyTools.createKey(RemoteControllerKey.KeyRemoteControllerType)) { type: RemoteControllerType? ->
val value = type?.name.orEmpty()
if (value.isNotBlank()) {
val typeValue = type?.value() ?: 0
_state.update {
it.copy(
remoteControllerType = value,
remoteControllerTypeValue = typeValue,
error = null
)
}
Log.d(TelemetryLogTag, "KeyRemoteControllerType=$value value=$typeValue")
}
}
listenSafely("KeyGroundDeviceIdentity", KeyTools.createKey(RemoteControllerKey.KeyGroundDeviceIdentity)) { value: Int? ->
val identity = value ?: 0
if (identity > 0) {
_state.update { it.copy(groundDeviceIdentity = identity, error = null) }
Log.d(TelemetryLogTag, "KeyGroundDeviceIdentity=$identity")
}
}
listenSafely("KeyUAVDeviceIdentity", KeyTools.createKey(RemoteControllerKey.KeyUAVDeviceIdentity)) { value: Int? ->
val identity = value ?: 0
if (identity > 0) {
_state.update { it.copy(uavDeviceIdentity = identity, error = null) }
Log.d(TelemetryLogTag, "KeyUAVDeviceIdentity=$identity")
}
}
listenSafely("KeyFirmwareVersion", KeyTools.createKey(FlightControllerKey.KeyFirmwareVersion)) { firmware: String? -> listenSafely("KeyFirmwareVersion", KeyTools.createKey(FlightControllerKey.KeyFirmwareVersion)) { firmware: String? ->
val value = firmware.orEmpty() val value = firmware.orEmpty()
if (value.isNotBlank()) { if (value.isNotBlank()) {
@@ -502,6 +578,7 @@ class TelemetryRepository(
listenRemoteControllerTelemetry() listenRemoteControllerTelemetry()
startAndroidLocationFallback() startAndroidLocationFallback()
listenCameraTelemetry() listenCameraTelemetry()
listenAdditionalCameraIdentities()
listenGimbalTelemetry() listenGimbalTelemetry()
listenRtkTelemetry() listenRtkTelemetry()
} }
@@ -533,6 +610,7 @@ class TelemetryRepository(
} }
private fun listenCameraTelemetry(cameraIndex: ComponentIndexType = ComponentIndexType.LEFT_OR_MAIN) { private fun listenCameraTelemetry(cameraIndex: ComponentIndexType = ComponentIndexType.LEFT_OR_MAIN) {
listenCameraIdentity(cameraIndex)
listenSafely("KeyCameraWorkMode", KeyTools.createKey(DJICameraKey.KeyCameraWorkMode, cameraIndex)) { mode: CameraWorkMode? -> listenSafely("KeyCameraWorkMode", KeyTools.createKey(DJICameraKey.KeyCameraWorkMode, cameraIndex)) { mode: CameraWorkMode? ->
_state.update { it.copy(cameraWorkMode = mode?.name.orEmpty(), error = null) } _state.update { it.copy(cameraWorkMode = mode?.name.orEmpty(), error = null) }
} }
@@ -595,6 +673,69 @@ class TelemetryRepository(
} }
} }
private fun listenAdditionalCameraIdentities() {
listOf(
ComponentIndexType.RIGHT,
ComponentIndexType.UP,
ComponentIndexType.PORT_1,
ComponentIndexType.PORT_2,
ComponentIndexType.PORT_3,
ComponentIndexType.PORT_4
).forEach { listenCameraIdentity(it) }
}
private fun listenCameraIdentity(cameraIndex: ComponentIndexType) {
listenSafely("KeyCameraType[$cameraIndex]", KeyTools.createKey(CameraKey.KeyCameraType, cameraIndex)) { type: CameraType? ->
val value = type?.name.orEmpty()
if (value.isBlank() || value == CameraType.NOT_SUPPORTED.name) return@listenSafely
val typeValue = type?.value() ?: 0
updateCameraIdentity(cameraIndex, cameraType = value, cameraTypeValue = typeValue)
Log.d(TelemetryLogTag, "KeyCameraType camera=$cameraIndex type=$value value=$typeValue")
}
listenSafely(
"KeyPayloadCameraType[$cameraIndex]",
KeyTools.createKey(PayloadKey.KeyPayloadCameraType, cameraIndex)
) { type: PayloadCameraType? ->
val value = type?.name.orEmpty()
if (value.isBlank() || value == PayloadCameraType.UNKNOWN.name) return@listenSafely
val typeValue = type?.value() ?: 0
updateCameraIdentity(cameraIndex, payloadCameraType = value, payloadCameraTypeValue = typeValue)
Log.d(TelemetryLogTag, "KeyPayloadCameraType camera=$cameraIndex type=$value value=$typeValue")
}
}
private fun updateCameraIdentity(
cameraIndex: ComponentIndexType,
cameraType: String? = null,
cameraTypeValue: Int? = null,
payloadCameraType: String? = null,
payloadCameraTypeValue: Int? = null
) {
val component = cameraIndex.name
_state.update { snapshot ->
val identities = snapshot.cameraIdentities
.associateBy { it.component }
.toMutableMap()
val previous = identities[component] ?: TelemetryCameraIdentity(component = component)
val updated = previous.copy(
cameraType = cameraType ?: previous.cameraType,
cameraTypeValue = cameraTypeValue?.takeIf { it > 0 } ?: previous.cameraTypeValue,
payloadCameraType = payloadCameraType ?: previous.payloadCameraType,
payloadCameraTypeValue = payloadCameraTypeValue?.takeIf { it > 0 } ?: previous.payloadCameraTypeValue
)
identities[component] = updated
snapshot.copy(
cameraType = updated.cameraType.ifBlank { snapshot.cameraType },
cameraTypeValue = updated.cameraTypeValue.takeIf { it > 0 } ?: snapshot.cameraTypeValue,
payloadCameraType = updated.payloadCameraType.ifBlank { snapshot.payloadCameraType },
payloadCameraTypeValue = updated.payloadCameraTypeValue.takeIf { it > 0 } ?: snapshot.payloadCameraTypeValue,
cameraComponent = component,
cameraIdentities = identities.values.sortedBy { it.component.cameraComponentSortOrder() },
error = null
)
}
}
private fun listenRemoteControllerTelemetry() { private fun listenRemoteControllerTelemetry() {
listenSafely("KeyRcGPSInfo", KeyTools.createKey(RemoteControllerKey.KeyRcGPSInfo)) { info: RcGPSInfo? -> listenSafely("KeyRcGPSInfo", KeyTools.createKey(RemoteControllerKey.KeyRcGPSInfo)) { info: RcGPSInfo? ->
val location = info?.location val location = info?.location
@@ -671,7 +812,11 @@ class TelemetryRepository(
} }
listenRtkStationTelemetry() listenRtkStationTelemetry()
listenRtkMobileStationKeys() listenRtkMobileStationKeys()
bootstrapAllPositioning("telemetry_start") if (AutoManageRtk) {
bootstrapAllPositioning("telemetry_start")
} else {
Log.d(TelemetryLogTag, "RTK telemetry is observe-only; skip automatic RTK enable/source/service changes")
}
} }
private fun listenRtkStationTelemetry() { private fun listenRtkStationTelemetry() {
@@ -829,7 +974,32 @@ class TelemetryRepository(
error = null error = null
) )
} }
ensureRtkServiceStarted(state) if (AutoManageRtk) {
ensureRtkServiceStarted(state)
} else if (RestoreExistingNetworkRtkService) {
ensureExistingNetworkRtkServiceStarted(state)
}
}
/**
* Only restart the network service selected by the operator/Pilot. This
* must never enable RTK, change reference source, or force accuracy mode:
* those operations can make an M350 reject takeoff while RTK is unfixed.
*/
private fun ensureExistingNetworkRtkServiceStarted(state: RTKSystemState?) {
if (state?.isRTKEnabled != true || state.rtkHealthy) {
if (state?.rtkHealthy == true) {
rtkStartInProgress = false
rtkServiceStarted = true
}
return
}
val source = state.rtkReferenceStationSource ?: return
if (!source.isNetworkRtkSource() || rtkServiceStarted) return
val now = System.currentTimeMillis()
if (rtkStartInProgress || now - lastRtkStartAtMs < RTK_START_RETRY_INTERVAL_MS) return
Log.d(TelemetryLogTag, "restore existing RTK network service source=$source")
startNetworkRtkService(source, setMaintainAccuracy = false)
} }
private fun ensureRtkServiceStarted(state: RTKSystemState?) { private fun ensureRtkServiceStarted(state: RTKSystemState?) {
@@ -933,7 +1103,10 @@ class TelemetryRepository(
) )
} }
private fun startNetworkRtkService(source: RTKReferenceStationSource) { private fun startNetworkRtkService(
source: RTKReferenceStationSource,
setMaintainAccuracy: Boolean = true
) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (now - lastRtkStartAtMs < 1_000L && source == lastRtkStartSource) { if (now - lastRtkStartAtMs < 1_000L && source == lastRtkStartSource) {
Log.d(TelemetryLogTag, "skip duplicate RTK network service source=$source") Log.d(TelemetryLogTag, "skip duplicate RTK network service source=$source")
@@ -943,7 +1116,9 @@ class TelemetryRepository(
rtkServiceStarted = false rtkServiceStarted = false
lastRtkStartAtMs = now lastRtkStartAtMs = now
lastRtkStartSource = source lastRtkStartSource = source
rtkCenter.setRTKMaintainAccuracyEnabled(true, null) if (setMaintainAccuracy) {
rtkCenter.setRTKMaintainAccuracyEnabled(true, null)
}
Log.d(TelemetryLogTag, "start RTK network service source=$source coordinate=$DefaultNetworkRtkCoordinateSystem") Log.d(TelemetryLogTag, "start RTK network service source=$source coordinate=$DefaultNetworkRtkCoordinateSystem")
when (source) { when (source) {
RTKReferenceStationSource.QX_NETWORK_SERVICE -> { RTKReferenceStationSource.QX_NETWORK_SERVICE -> {
@@ -1096,6 +1271,18 @@ class TelemetryRepository(
} }
private fun String.cameraComponentSortOrder(): Int =
when (uppercase()) {
ComponentIndexType.LEFT_OR_MAIN.name -> 0
ComponentIndexType.RIGHT.name -> 1
ComponentIndexType.UP.name -> 2
ComponentIndexType.PORT_1.name -> 3
ComponentIndexType.PORT_2.name -> 4
ComponentIndexType.PORT_3.name -> 5
ComponentIndexType.PORT_4.name -> 6
else -> 100
}
private fun isValidCoordinate(latitude: Double?, longitude: Double?): Boolean { private fun isValidCoordinate(latitude: Double?, longitude: Double?): Boolean {
val lat = latitude ?: return false val lat = latitude ?: return false
val lon = longitude ?: return false val lon = longitude ?: return false

View File

@@ -0,0 +1,24 @@
package com.zklh.dronecontroller.core.telemetry
import java.util.Locale
fun TelemetrySnapshot.hasReliableRtkPosition(): Boolean =
rtkLocationValid &&
(rtkHealthy || rtkFusionDataUsable || rtkPositioningSolution.isSolvedRtkSolution())
fun TelemetrySnapshot.isRtkBlockingTakeoff(): Boolean =
rtkEnabled && !hasReliableRtkPosition()
fun TelemetrySnapshot.isRtkBlockingWaypointMission(): Boolean =
isRtkBlockingTakeoff()
private fun String.isSolvedRtkSolution(): Boolean {
val value = trim()
.replace("-", "_")
.replace(".", "_")
.lowercase(Locale.US)
return value == "fixed_point" ||
value == "float_point" ||
value == "fixed" ||
value == "float"
}

View File

@@ -172,6 +172,7 @@ import com.zklh.dronecontroller.core.safety.SafetyInterlock
import com.zklh.dronecontroller.core.simulator.SimulatorService import com.zklh.dronecontroller.core.simulator.SimulatorService
import com.zklh.dronecontroller.core.telemetry.TelemetryRepository import com.zklh.dronecontroller.core.telemetry.TelemetryRepository
import com.zklh.dronecontroller.core.telemetry.TelemetrySnapshot import com.zklh.dronecontroller.core.telemetry.TelemetrySnapshot
import com.zklh.dronecontroller.core.telemetry.hasReliableRtkPosition
import com.zklh.dronecontroller.core.video.DjiVideoPreviewService import com.zklh.dronecontroller.core.video.DjiVideoPreviewService
import com.zklh.dronecontroller.core.virtualstick.StickPosition import com.zklh.dronecontroller.core.virtualstick.StickPosition
import com.zklh.dronecontroller.core.virtualstick.VirtualStickService import com.zklh.dronecontroller.core.virtualstick.VirtualStickService
@@ -310,22 +311,22 @@ fun DroneControllerScreen() {
val appContext = context.applicationContext val appContext = context.applicationContext
val savedCloudLoginConfig = remember(appContext) { appContext.readSavedCloudLoginConfig() } val savedCloudLoginConfig = remember(appContext) { appContext.readSavedCloudLoginConfig() }
val sdkState by DroneSdkManager.state.collectAsState() val sdkState by DroneSdkManager.state.collectAsState()
val flightControl = remember { FlightControlService() } val telemetryRepository = remember(context) { TelemetryRepository(context.applicationContext) }
val telemetry by telemetryRepository.state.collectAsState()
val latestTelemetry by rememberUpdatedState(telemetry)
val flightControl = remember { FlightControlService { latestTelemetry } }
val virtualStick = remember { VirtualStickService() } val virtualStick = remember { VirtualStickService() }
val waypointMission = remember { WaypointMissionService() } val waypointMission = remember { WaypointMissionService() }
val cameraMedia = remember { CameraMediaService() } val cameraMedia = remember { CameraMediaService() }
val flyToService = remember { FlyToService() } val flyToService = remember { FlyToService() }
val liveStreaming = remember { LiveStreamingService() } val liveStreaming = remember { LiveStreamingService() }
val simulator = remember { SimulatorService() } val simulator = remember { SimulatorService() }
val telemetryRepository = remember(context) { TelemetryRepository(context.applicationContext) }
val warningRepository = remember { DroneWarningRepository() } val warningRepository = remember { DroneWarningRepository() }
val cloudLoginClient = remember { CloudLoginClient() } val cloudLoginClient = remember { CloudLoginClient() }
val cloudMqttService = remember { CloudMqttService(cloudLoginClient) } val cloudMqttService = remember { CloudMqttService(cloudLoginClient) }
val cloudMediaUpload = remember(appContext, cloudMqttService) { val cloudMediaUpload = remember(appContext, cloudMqttService) {
CloudMediaUploadService(appContext) { cloudMqttService.state.value.session } CloudMediaUploadService(appContext) { cloudMqttService.state.value.session }
} }
val telemetry by telemetryRepository.state.collectAsState()
val latestTelemetry by rememberUpdatedState(telemetry)
val cloudCommandExecutor = remember(flightControl, cameraMedia, flyToService, liveStreaming, virtualStick, waypointMission, cloudMediaUpload) { val cloudCommandExecutor = remember(flightControl, cameraMedia, flyToService, liveStreaming, virtualStick, waypointMission, cloudMediaUpload) {
CloudCommandExecutor( CloudCommandExecutor(
flightControl = flightControl, flightControl = flightControl,
@@ -335,7 +336,8 @@ fun DroneControllerScreen() {
virtualStick = virtualStick, virtualStick = virtualStick,
waypointMission = waypointMission, waypointMission = waypointMission,
mediaUpload = cloudMediaUpload, mediaUpload = cloudMediaUpload,
telemetryProvider = { latestTelemetry } telemetryProvider = { latestTelemetry },
progressPublisher = cloudMqttService
) )
} }
val stickStatus by virtualStick.status.collectAsState() val stickStatus by virtualStick.status.collectAsState()
@@ -2209,7 +2211,7 @@ private fun PreflightScreen(
StatusChip(Icons.Filled.BatteryFull, batteryStatusText(sdkState.productConnected, telemetry), if (sdkState.productConnected) Color.Black else PilotMuted) StatusChip(Icons.Filled.BatteryFull, batteryStatusText(sdkState.productConnected, telemetry), if (sdkState.productConnected) Color.Black else PilotMuted)
StatusChip(Icons.Filled.Radio, if (sdkState.productConnected) "100%" else "--", if (sdkState.productConnected) Color.Black else PilotMuted) StatusChip(Icons.Filled.Radio, if (sdkState.productConnected) "100%" else "--", if (sdkState.productConnected) Color.Black else PilotMuted)
StatusChip(Icons.Filled.Home, if (sdkState.productConnected) "未设置" else "--", if (sdkState.productConnected) PilotOrange else PilotMuted) StatusChip(Icons.Filled.Home, if (sdkState.productConnected) "未设置" else "--", if (sdkState.productConnected) PilotOrange else PilotMuted)
StatusChip(Icons.Filled.Warning, if (sdkState.productConnected) "RTK 未连接" else "RTK --", if (sdkState.productConnected) PilotDanger else PilotMuted) StatusChip(Icons.Filled.Warning, rtkStatusText(sdkState.productConnected, telemetry), rtkStatusColor(sdkState.productConnected, telemetry))
StatusChip(Icons.Filled.UploadFile, if (sdkState.productConnected) "39.8G" else "--", if (sdkState.productConnected) Color.Black else PilotMuted) StatusChip(Icons.Filled.UploadFile, if (sdkState.productConnected) "39.8G" else "--", if (sdkState.productConnected) Color.Black else PilotMuted)
StatusChip(Icons.Filled.Person, "A控", Color.Black) StatusChip(Icons.Filled.Person, "A控", Color.Black)
} }
@@ -3002,6 +3004,8 @@ private fun FlightTopBar(
val batteryPercent = batteryPercentText(sdkState.productConnected, telemetry) val batteryPercent = batteryPercentText(sdkState.productConnected, telemetry)
val batteryVoltage = batteryVoltageText(sdkState.productConnected, telemetry) val batteryVoltage = batteryVoltageText(sdkState.productConnected, telemetry)
val alertCount = effectiveAlertCount(alertState) val alertCount = effectiveAlertCount(alertState)
val rtkColor = rtkStatusColor(sdkState.productConnected, telemetry)
val rtkCount = rtkSatelliteText(sdkState.productConnected, telemetry)
Column(modifier.background(PilotBlack)) { Column(modifier.background(PilotBlack)) {
Row( Row(
@@ -3062,8 +3066,8 @@ private fun FlightTopBar(
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(44.dp)) { Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(44.dp)) {
Text("RTK", color = if (sdkState.productConnected) PilotDanger else Color(0xFF9CA3AF), fontSize = 14.sp, fontWeight = FontWeight.Black) Text("RTK", color = rtkColor, fontSize = 14.sp, fontWeight = FontWeight.Black)
Text(if (sdkState.productConnected) "0" else "--", color = if (sdkState.productConnected) PilotDanger else Color(0xFF9CA3AF), fontSize = 15.sp, fontWeight = FontWeight.Black) Text(rtkCount, color = rtkColor, fontSize = 15.sp, fontWeight = FontWeight.Black)
} }
Text(if (sdkState.productLinkConnected) "RC 在线" else "RC --", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.Black, modifier = Modifier.width(72.dp)) Text(if (sdkState.productLinkConnected) "RC 在线" else "RC --", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.Black, modifier = Modifier.width(72.dp))
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.width(68.dp)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.width(68.dp)) {
@@ -4240,6 +4244,31 @@ private fun batteryVoltageText(connected: Boolean, telemetry: TelemetrySnapshot)
"--" "--"
} }
private fun rtkStatusText(connected: Boolean, telemetry: TelemetrySnapshot): String =
when {
!connected -> "RTK --"
!telemetry.rtkEnabled -> "RTK 关"
telemetry.hasReliableRtkPosition() -> "RTK 正常"
telemetry.rtkWorking || telemetry.rtkBeingUsed || telemetry.rtkSatelliteCount > 0 -> "RTK 收敛"
else -> "RTK 未就绪"
}
private fun rtkStatusColor(connected: Boolean, telemetry: TelemetrySnapshot): Color =
when {
!connected -> PilotMuted
!telemetry.rtkEnabled -> PilotMuted
telemetry.hasReliableRtkPosition() -> PilotGreen
telemetry.rtkWorking || telemetry.rtkBeingUsed || telemetry.rtkSatelliteCount > 0 -> PilotOrange
else -> PilotDanger
}
private fun rtkSatelliteText(connected: Boolean, telemetry: TelemetrySnapshot): String =
when {
!connected -> "--"
!telemetry.rtkEnabled -> ""
else -> telemetry.rtkSatelliteCount.coerceAtLeast(0).toString()
}
private fun effectiveCommandMessage(sdkState: DroneSdkState, rawMessage: String): String { private fun effectiveCommandMessage(sdkState: DroneSdkState, rawMessage: String): String {
val passiveMessage = rawMessage.isBlank() || val passiveMessage = rawMessage.isBlank() ||
rawMessage.contains("等待设备") || rawMessage.contains("等待设备") ||