update
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -4,12 +4,14 @@ import android.util.Log
|
||||
import com.zklh.dronecontroller.core.diagnostics.DroneWarningItem
|
||||
import com.zklh.dronecontroller.core.diagnostics.DroneWarningState
|
||||
import com.zklh.dronecontroller.core.msdk.DroneSdkState
|
||||
import com.zklh.dronecontroller.core.telemetry.TelemetryCameraIdentity
|
||||
import com.zklh.dronecontroller.core.telemetry.TelemetrySnapshot
|
||||
import dji.v5.manager.diagnostic.WarningLevel
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -35,13 +37,19 @@ import org.json.JSONObject
|
||||
private const val CloudMqttLogTag = "ZklhCloudMqtt"
|
||||
private const val RcDomain = 2
|
||||
private const val DroneDomain = 0
|
||||
private const val RcPlus2Type = 174
|
||||
private const val Matrice4Type = 99
|
||||
private const val RcSubType = 0
|
||||
private const val Matrice4SubType = 1
|
||||
private const val Matrice4DeviceType = "0-99-1"
|
||||
private const val Matrice4CameraPayloadIndex = "89-0-0"
|
||||
private const val Matrice4LiveVideoIndex = "normal-0"
|
||||
private const val CloudRcType = 56
|
||||
private const val CloudRcPlusType = 119
|
||||
private const val CloudRcProType = 144
|
||||
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 CloudAccessType = "msdk"
|
||||
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 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(
|
||||
private val loginClient: CloudLoginClient = CloudLoginClient()
|
||||
) {
|
||||
) : CloudMissionProgressPublisher {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val _state = MutableStateFlow(CloudMqttState())
|
||||
val state: StateFlow<CloudMqttState> = _state.asStateFlow()
|
||||
@@ -369,24 +386,26 @@ class CloudMqttService(
|
||||
if (rcSn.isBlank()) return
|
||||
val subDevices = JSONArray()
|
||||
if (online && identity.aircraftSn.isNotBlank()) {
|
||||
val droneDescriptor = telemetry.cloudDroneDescriptor()
|
||||
subDevices.put(
|
||||
JSONObject()
|
||||
.put("sn", identity.aircraftSn)
|
||||
.put("domain", DroneDomain)
|
||||
.put("type", Matrice4Type)
|
||||
.put("sub_type", Matrice4SubType)
|
||||
.put("domain", droneDescriptor.domain)
|
||||
.put("type", droneDescriptor.type)
|
||||
.put("sub_type", droneDescriptor.subType)
|
||||
.put("index", "A")
|
||||
.put("thing_version", ThingVersion)
|
||||
)
|
||||
}
|
||||
val rcDescriptor = telemetry.cloudRcDescriptor()
|
||||
val payload = topicRequest(cloudSession)
|
||||
.put("method", "update_topo")
|
||||
.put(
|
||||
"data",
|
||||
JSONObject()
|
||||
.put("domain", RcDomain)
|
||||
.put("type", RcPlus2Type)
|
||||
.put("sub_type", RcSubType)
|
||||
.put("domain", rcDescriptor.domain)
|
||||
.put("type", rcDescriptor.type)
|
||||
.put("sub_type", rcDescriptor.subType)
|
||||
.put("thing_version", ThingVersion)
|
||||
.put("access_type", CloudAccessType)
|
||||
.put("sub_devices", subDevices)
|
||||
@@ -400,23 +419,30 @@ class CloudMqttService(
|
||||
val rcSn = identity.remoteControllerSn
|
||||
val aircraftSn = identity.aircraftSn
|
||||
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
|
||||
|
||||
val video = JSONObject()
|
||||
.put("video_index", Matrice4LiveVideoIndex)
|
||||
.put("video_index", DefaultLiveVideoIndex)
|
||||
.put("video_type", "normal")
|
||||
.put("switchable_video_types", JSONArray().put("normal"))
|
||||
val camera = JSONObject()
|
||||
.put("available_video_number", 1)
|
||||
.put("coexist_video_number_max", 1)
|
||||
.put("camera_index", Matrice4CameraPayloadIndex)
|
||||
.put("video_list", JSONArray().put(video))
|
||||
val cameras = JSONArray().apply {
|
||||
cameraIndexes.forEach { cameraIndex ->
|
||||
put(
|
||||
JSONObject()
|
||||
.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()
|
||||
.put("sn", aircraftSn)
|
||||
.put("available_video_number", 1)
|
||||
.put("available_video_number", cameraIndexes.size)
|
||||
.put("coexist_video_number_max", 1)
|
||||
.put("camera_list", JSONArray().put(camera))
|
||||
.put("camera_list", cameras)
|
||||
val payload = topicRequest(cloudSession)
|
||||
.put("gateway", rcSn)
|
||||
.put("method", "livestream_ability_update")
|
||||
@@ -426,7 +452,7 @@ class CloudMqttService(
|
||||
.put(
|
||||
"live_capacity",
|
||||
JSONObject()
|
||||
.put("available_video_number", 1)
|
||||
.put("available_video_number", cameraIndexes.size)
|
||||
.put("coexist_video_number_max", 1)
|
||||
.put("device_list", JSONArray().put(device))
|
||||
)
|
||||
@@ -453,8 +479,9 @@ class CloudMqttService(
|
||||
}
|
||||
val hmsItems = JSONArray().apply {
|
||||
val inTheSky = telemetry.isFlying || telemetry.motorsOn
|
||||
val droneDeviceType = telemetry.cloudDroneDescriptor().deviceType
|
||||
warningState.items
|
||||
.map { it.toHmsJson(inTheSky) }
|
||||
.map { it.toHmsJson(inTheSky, droneDeviceType) }
|
||||
.forEach { put(it) }
|
||||
}
|
||||
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) {
|
||||
if (message == hmsDebugKey) return
|
||||
hmsDebugKey = message
|
||||
@@ -583,9 +721,9 @@ class CloudMqttService(
|
||||
.put("firmware_version", telemetry.firmwareVersion)
|
||||
.put("battery", droneBatteryJson())
|
||||
.put("position_state", dronePositionStateJson())
|
||||
.put("payload", JSONArray().put(rcDronePayloadJson()))
|
||||
.put("payload", rcDronePayloadListJson())
|
||||
.put("storage", storageJson())
|
||||
.put("cameras", JSONArray().put(osdCameraJson()))
|
||||
.put("cameras", osdCameraListJson())
|
||||
.put("height_limit", telemetry.heightLimit)
|
||||
.put("distance_limit_status", rcDistanceLimitStatusJson())
|
||||
.put("track_id", "")
|
||||
@@ -660,10 +798,17 @@ class CloudMqttService(
|
||||
.put("total", telemetry.storageTotal.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()
|
||||
.put(JsonClassKey, OsdCameraClass)
|
||||
.put("payload_index", Matrice4CameraPayloadIndex)
|
||||
.put("payload_index", payloadIndex)
|
||||
.put("camera_mode", telemetry.cameraModeCode())
|
||||
.put("photo_state", telemetry.photoState)
|
||||
.put("recording_state", telemetry.recordingState)
|
||||
@@ -674,10 +819,17 @@ class CloudMqttService(
|
||||
.put("ir_zoom_factor", telemetry.irZoomFactor)
|
||||
.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()
|
||||
.put(JsonClassKey, RcDronePayloadClass)
|
||||
.put("payload_index", Matrice4CameraPayloadIndex)
|
||||
.put("payload_index", payloadIndex)
|
||||
.put("gimbal_pitch", telemetry.gimbalPitch.toFloat())
|
||||
.put("gimbal_roll", telemetry.gimbalRoll.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)
|
||||
return JSONObject()
|
||||
.put("code", hmsCode)
|
||||
.put("device_type", Matrice4DeviceType)
|
||||
.put("device_type", deviceType)
|
||||
.put("imminent", level.isHmsAlarm())
|
||||
.put("in_the_sky", inTheSky)
|
||||
.put("level", level.toHmsLevel())
|
||||
@@ -959,6 +1111,186 @@ private fun TelemetrySnapshot.droneOsdLongitude(): Double =
|
||||
private fun TelemetrySnapshot.droneOsdPositionSource(): String =
|
||||
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 =
|
||||
(gpsValid && locationValid) || hasUsableRtkPosition()
|
||||
|
||||
|
||||
@@ -2,21 +2,41 @@ package com.zklh.dronecontroller.core.flight
|
||||
|
||||
import com.zklh.dronecontroller.core.msdk.DjiCommandResult
|
||||
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.value.common.LocationCoordinate3D
|
||||
import dji.sdk.keyvalue.value.flightcontroller.LookAtInfo
|
||||
import dji.sdk.keyvalue.value.flightcontroller.LookAtMode
|
||||
import dji.v5.common.callback.CommonCallbacks
|
||||
import dji.v5.common.error.IDJIError
|
||||
import dji.v5.et.action
|
||||
import dji.v5.et.create
|
||||
import dji.v5.manager.aircraft.rtk.RTKCenter
|
||||
import kotlin.coroutines.resume
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
class FlightControlService {
|
||||
class FlightControlService(
|
||||
private val telemetryProvider: () -> TelemetrySnapshot = { TelemetrySnapshot() }
|
||||
) {
|
||||
suspend fun startTakeoff(): DjiCommandResult =
|
||||
if (!SafetyInterlock.FLIGHT_COMMANDS_ENABLED) {
|
||||
DjiCommandResult.failed(SafetyInterlock.LOCKED_MESSAGE)
|
||||
} 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 ->
|
||||
FlightControllerKey.KeyStartTakeoff.create().action({
|
||||
continuation.resume(DjiCommandResult.ok("自动起飞已开始"))
|
||||
@@ -24,6 +44,36 @@ class FlightControlService {
|
||||
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 =
|
||||
|
||||
@@ -29,6 +29,8 @@ data class FlyToStatus(
|
||||
val state: FlyToMissionState = FlyToMissionState.UNKNOWN,
|
||||
val mode: FlyToMode = FlyToMode.UNKNOWN,
|
||||
val height: Int = 0,
|
||||
val heightMin: Int = 1,
|
||||
val heightMax: Int = 1500,
|
||||
val targetLatitude: Double = 0.0,
|
||||
val targetLongitude: Double = 0.0,
|
||||
val targetAltitude: Double = 0.0,
|
||||
@@ -74,8 +76,12 @@ class FlyToService {
|
||||
|
||||
private val capabilityListener = object : IMissionCapabilityListener<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 {
|
||||
it.copy(
|
||||
heightMin = heightMin,
|
||||
heightMax = heightMax,
|
||||
heightRangeText = capability.heightRange?.toString().orEmpty()
|
||||
)
|
||||
}
|
||||
@@ -107,26 +113,24 @@ class FlyToService {
|
||||
DjiCommandResult.failed(SafetyInterlock.LOCKED_MESSAGE)
|
||||
} else {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val targetHeight = height.roundToInt().coerceAtLeast(1)
|
||||
val targetHeight = height.normalizedFlyToHeight()
|
||||
val safeTakeoffHeight = securityTakeoffHeight
|
||||
.coerceAtLeast(1)
|
||||
.coerceAtMost(targetHeight)
|
||||
val target = FlyToTarget().apply {
|
||||
targetLocation = LocationCoordinate3D(latitude, longitude, height)
|
||||
targetLocation = LocationCoordinate3D(latitude, longitude, targetHeight.toDouble())
|
||||
this.maxSpeed = maxSpeed
|
||||
this.securityTakeoffHeight = safeTakeoffHeight
|
||||
}
|
||||
val param = FlyToParam().apply {
|
||||
this.flyToMode = flyToMode
|
||||
this.height = targetHeight
|
||||
}
|
||||
Log.d(
|
||||
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(
|
||||
target,
|
||||
param,
|
||||
null,
|
||||
callback(
|
||||
continuation,
|
||||
"指点飞行已开始",
|
||||
@@ -138,7 +142,7 @@ class FlyToService {
|
||||
height = targetHeight,
|
||||
targetLatitude = latitude,
|
||||
targetLongitude = longitude,
|
||||
targetAltitude = height
|
||||
targetAltitude = targetHeight.toDouble()
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -172,18 +176,20 @@ class FlyToService {
|
||||
DjiCommandResult.failed(SafetyInterlock.LOCKED_MESSAGE)
|
||||
} else {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val targetHeight = height.roundToInt().coerceAtLeast(1)
|
||||
val targetHeight = height.normalizedFlyToHeight()
|
||||
val safeTakeoffHeight = securityTakeoffHeight
|
||||
.coerceAtLeast(1)
|
||||
.coerceAtMost(targetHeight)
|
||||
val target = FlyToTarget().apply {
|
||||
targetLocation = LocationCoordinate3D(latitude, longitude, height)
|
||||
targetLocation = LocationCoordinate3D(latitude, longitude, targetHeight.toDouble())
|
||||
this.maxSpeed = maxSpeed
|
||||
this.securityTakeoffHeight = safeTakeoffHeight
|
||||
}
|
||||
Log.d(
|
||||
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(
|
||||
target,
|
||||
@@ -195,7 +201,7 @@ class FlyToService {
|
||||
it.copy(
|
||||
targetLatitude = latitude,
|
||||
targetLongitude = longitude,
|
||||
targetAltitude = height
|
||||
targetAltitude = targetHeight.toDouble()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -214,7 +220,7 @@ class FlyToService {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val param = FlyToParam().apply {
|
||||
if (mode != null) flyToMode = mode
|
||||
if (height != null) this.height = height.coerceAtLeast(1)
|
||||
if (height != null) this.height = height.toDouble().normalizedFlyToHeight()
|
||||
}
|
||||
manager.updateMissionParam(
|
||||
param,
|
||||
@@ -234,10 +240,11 @@ class FlyToService {
|
||||
|
||||
suspend fun setFlyToHeight(height: Int): DjiCommandResult =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val param = FlyToParam().apply { this.height = height }
|
||||
val targetHeight = height.toDouble().normalizedFlyToHeight()
|
||||
val param = FlyToParam().apply { this.height = targetHeight }
|
||||
manager.updateMissionParam(
|
||||
param,
|
||||
callback(continuation, "指点飞行高度已更新:${height}m")
|
||||
callback(continuation, "指点飞行高度已更新:${targetHeight}m")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -255,7 +262,15 @@ class FlyToService {
|
||||
|
||||
override fun onFailure(error: IDJIError) {
|
||||
onFailure()
|
||||
Log.w(FlyToLogTag, "$successMessage failed: $error")
|
||||
continuation.resume(DjiCommandResult.failed(error))
|
||||
}
|
||||
}
|
||||
|
||||
private fun Double.normalizedFlyToHeight(): Int {
|
||||
val range = status.value
|
||||
return roundToInt()
|
||||
.coerceAtLeast(range.heightMin)
|
||||
.coerceAtMost(range.heightMax)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import dji.v5.manager.datacenter.livestream.LiveVideoBitrateMode
|
||||
import dji.v5.manager.datacenter.livestream.StreamQuality
|
||||
import dji.v5.manager.datacenter.livestream.settings.RtmpSettings
|
||||
import dji.v5.manager.interfaces.ICameraStreamManager
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -58,17 +59,35 @@ class LiveStreamingService {
|
||||
|
||||
private var listenerRegistered = false
|
||||
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 {
|
||||
override fun onLiveStreamStatusUpdate(status: LiveStreamStatus?) {
|
||||
val resolution = status?.resolution?.let { "${it.width}x${it.height}" }.orEmpty()
|
||||
val fps = status?.fps ?: 0
|
||||
val vbps = status?.vbps ?: 0
|
||||
_state.update {
|
||||
it.copy(
|
||||
streaming = status?.isStreaming ?: liveStreamManager().isStreaming,
|
||||
fps = status?.fps ?: 0,
|
||||
vbps = status?.vbps ?: 0,
|
||||
fps = fps,
|
||||
vbps = vbps,
|
||||
resolution = resolution,
|
||||
message = if (status?.isStreaming == true) "直播推流中" else it.message,
|
||||
message = when {
|
||||
status?.isStreaming != true -> it.message
|
||||
fps <= 0 || vbps <= 0 -> "直播已开启,但暂未收到视频帧"
|
||||
else -> "直播推流中"
|
||||
},
|
||||
lastError = null
|
||||
)
|
||||
}
|
||||
@@ -89,6 +108,7 @@ class LiveStreamingService {
|
||||
fun clear() {
|
||||
if (listenerRegistered) {
|
||||
runCatching { liveStreamManager().removeLiveStreamStatusListener(statusListener) }
|
||||
runCatching { cameraStreamManager().removeAvailableCameraUpdatedListener(cameraListener) }
|
||||
listenerRegistered = false
|
||||
}
|
||||
}
|
||||
@@ -163,7 +183,8 @@ class LiveStreamingService {
|
||||
if (!stopResult.success) return stopResult
|
||||
}
|
||||
|
||||
val cameraIndex = config.videoId.toCameraIndex()
|
||||
waitForAvailableCameraStreams()
|
||||
val cameraIndex = selectCameraIndex(config.videoId)
|
||||
runCatching {
|
||||
manager.cameraIndex = cameraIndex
|
||||
cameraStreamManager().enableStream(cameraIndex, true)
|
||||
@@ -183,7 +204,8 @@ class LiveStreamingService {
|
||||
|
||||
Log.d(
|
||||
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 {
|
||||
it.copy(
|
||||
@@ -215,12 +237,37 @@ class LiveStreamingService {
|
||||
private fun ensureListener() {
|
||||
if (listenerRegistered) return
|
||||
liveStreamManager().addLiveStreamStatusListener(statusListener)
|
||||
cameraStreamManager().addAvailableCameraUpdatedListener(cameraListener)
|
||||
listenerRegistered = true
|
||||
}
|
||||
|
||||
private fun liveStreamManager() = MediaDataCenter.getInstance().liveStreamManager
|
||||
|
||||
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? {
|
||||
@@ -379,9 +426,20 @@ private fun Int.toStreamQuality(): StreamQuality =
|
||||
else -> StreamQuality.HD
|
||||
}
|
||||
|
||||
private fun String.toCameraIndex(): ComponentIndexType =
|
||||
private fun String.toExplicitCameraIndex(): ComponentIndexType? =
|
||||
when {
|
||||
contains("/FPV", ignoreCase = true) -> ComponentIndexType.FPV
|
||||
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()
|
||||
|
||||
@@ -18,8 +18,10 @@ import dji.sdk.keyvalue.key.DJIGimbalKey
|
||||
import dji.sdk.keyvalue.key.DJIKey
|
||||
import dji.sdk.keyvalue.key.FlightControllerKey
|
||||
import dji.sdk.keyvalue.key.KeyTools
|
||||
import dji.sdk.keyvalue.key.PayloadKey
|
||||
import dji.sdk.keyvalue.key.ProductKey
|
||||
import dji.sdk.keyvalue.key.RemoteControllerKey
|
||||
import dji.sdk.keyvalue.value.camera.CameraType
|
||||
import dji.sdk.keyvalue.key.RtkMobileStationKey
|
||||
import dji.sdk.keyvalue.value.camera.CameraWorkMode
|
||||
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.RemoteControllerFlightMode
|
||||
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.RcGPSInfo
|
||||
import dji.sdk.keyvalue.value.remotecontroller.RemoteControllerType
|
||||
import dji.sdk.keyvalue.value.rtkbasestation.RTKReferenceStationSource
|
||||
import dji.sdk.keyvalue.value.rtkbasestation.RTKServiceState
|
||||
import dji.sdk.keyvalue.value.rtkmobilestation.RTKLocation
|
||||
@@ -64,6 +69,21 @@ import kotlinx.coroutines.flow.update
|
||||
private const val TelemetryLogTag = "ZklhTelemetry"
|
||||
private const val RTK_START_RETRY_INTERVAL_MS = 20_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(
|
||||
val latitude: Double = 0.0,
|
||||
@@ -119,6 +139,18 @@ data class TelemetrySnapshot(
|
||||
val windDirection: Int = 0,
|
||||
val windSpeed: 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 batteryPercentNeededToLandKnown: Boolean = false,
|
||||
val batteryPercentNeededToGoHome: Int = 14,
|
||||
@@ -198,7 +230,11 @@ class TelemetryRepository(
|
||||
-> {
|
||||
rtkStartInProgress = false
|
||||
rtkServiceStarted = false
|
||||
ensureRtkServiceStarted(lastRtkSystemState)
|
||||
if (AutoManageRtk) {
|
||||
ensureRtkServiceStarted(lastRtkSystemState)
|
||||
} else if (RestoreExistingNetworkRtkService) {
|
||||
ensureExistingNetworkRtkServiceStarted(lastRtkSystemState)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// READY/CONNECTING/PROCESSING are transitional states; keep the current flags.
|
||||
@@ -210,7 +246,11 @@ class TelemetryRepository(
|
||||
Log.w(TelemetryLogTag, "RTKNetworkServiceError=$error")
|
||||
rtkStartInProgress = false
|
||||
rtkServiceStarted = false
|
||||
ensureRtkServiceStarted(lastRtkSystemState)
|
||||
if (AutoManageRtk) {
|
||||
ensureRtkServiceStarted(lastRtkSystemState)
|
||||
} else if (RestoreExistingNetworkRtkService) {
|
||||
ensureExistingNetworkRtkServiceStarted(lastRtkSystemState)
|
||||
}
|
||||
}
|
||||
}
|
||||
private val _state = MutableStateFlow(TelemetrySnapshot())
|
||||
@@ -288,6 +328,42 @@ class TelemetryRepository(
|
||||
_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? ->
|
||||
val value = firmware.orEmpty()
|
||||
if (value.isNotBlank()) {
|
||||
@@ -502,6 +578,7 @@ class TelemetryRepository(
|
||||
listenRemoteControllerTelemetry()
|
||||
startAndroidLocationFallback()
|
||||
listenCameraTelemetry()
|
||||
listenAdditionalCameraIdentities()
|
||||
listenGimbalTelemetry()
|
||||
listenRtkTelemetry()
|
||||
}
|
||||
@@ -533,6 +610,7 @@ class TelemetryRepository(
|
||||
}
|
||||
|
||||
private fun listenCameraTelemetry(cameraIndex: ComponentIndexType = ComponentIndexType.LEFT_OR_MAIN) {
|
||||
listenCameraIdentity(cameraIndex)
|
||||
listenSafely("KeyCameraWorkMode", KeyTools.createKey(DJICameraKey.KeyCameraWorkMode, cameraIndex)) { mode: CameraWorkMode? ->
|
||||
_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() {
|
||||
listenSafely("KeyRcGPSInfo", KeyTools.createKey(RemoteControllerKey.KeyRcGPSInfo)) { info: RcGPSInfo? ->
|
||||
val location = info?.location
|
||||
@@ -671,7 +812,11 @@ class TelemetryRepository(
|
||||
}
|
||||
listenRtkStationTelemetry()
|
||||
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() {
|
||||
@@ -829,7 +974,32 @@ class TelemetryRepository(
|
||||
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?) {
|
||||
@@ -933,7 +1103,10 @@ class TelemetryRepository(
|
||||
)
|
||||
}
|
||||
|
||||
private fun startNetworkRtkService(source: RTKReferenceStationSource) {
|
||||
private fun startNetworkRtkService(
|
||||
source: RTKReferenceStationSource,
|
||||
setMaintainAccuracy: Boolean = true
|
||||
) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastRtkStartAtMs < 1_000L && source == lastRtkStartSource) {
|
||||
Log.d(TelemetryLogTag, "skip duplicate RTK network service source=$source")
|
||||
@@ -943,7 +1116,9 @@ class TelemetryRepository(
|
||||
rtkServiceStarted = false
|
||||
lastRtkStartAtMs = now
|
||||
lastRtkStartSource = source
|
||||
rtkCenter.setRTKMaintainAccuracyEnabled(true, null)
|
||||
if (setMaintainAccuracy) {
|
||||
rtkCenter.setRTKMaintainAccuracyEnabled(true, null)
|
||||
}
|
||||
Log.d(TelemetryLogTag, "start RTK network service source=$source coordinate=$DefaultNetworkRtkCoordinateSystem")
|
||||
when (source) {
|
||||
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 {
|
||||
val lat = latitude ?: return false
|
||||
val lon = longitude ?: return false
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -172,6 +172,7 @@ import com.zklh.dronecontroller.core.safety.SafetyInterlock
|
||||
import com.zklh.dronecontroller.core.simulator.SimulatorService
|
||||
import com.zklh.dronecontroller.core.telemetry.TelemetryRepository
|
||||
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.virtualstick.StickPosition
|
||||
import com.zklh.dronecontroller.core.virtualstick.VirtualStickService
|
||||
@@ -310,22 +311,22 @@ fun DroneControllerScreen() {
|
||||
val appContext = context.applicationContext
|
||||
val savedCloudLoginConfig = remember(appContext) { appContext.readSavedCloudLoginConfig() }
|
||||
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 waypointMission = remember { WaypointMissionService() }
|
||||
val cameraMedia = remember { CameraMediaService() }
|
||||
val flyToService = remember { FlyToService() }
|
||||
val liveStreaming = remember { LiveStreamingService() }
|
||||
val simulator = remember { SimulatorService() }
|
||||
val telemetryRepository = remember(context) { TelemetryRepository(context.applicationContext) }
|
||||
val warningRepository = remember { DroneWarningRepository() }
|
||||
val cloudLoginClient = remember { CloudLoginClient() }
|
||||
val cloudMqttService = remember { CloudMqttService(cloudLoginClient) }
|
||||
val cloudMediaUpload = remember(appContext, cloudMqttService) {
|
||||
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) {
|
||||
CloudCommandExecutor(
|
||||
flightControl = flightControl,
|
||||
@@ -335,7 +336,8 @@ fun DroneControllerScreen() {
|
||||
virtualStick = virtualStick,
|
||||
waypointMission = waypointMission,
|
||||
mediaUpload = cloudMediaUpload,
|
||||
telemetryProvider = { latestTelemetry }
|
||||
telemetryProvider = { latestTelemetry },
|
||||
progressPublisher = cloudMqttService
|
||||
)
|
||||
}
|
||||
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.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.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.Person, "A控", Color.Black)
|
||||
}
|
||||
@@ -3002,6 +3004,8 @@ private fun FlightTopBar(
|
||||
val batteryPercent = batteryPercentText(sdkState.productConnected, telemetry)
|
||||
val batteryVoltage = batteryVoltageText(sdkState.productConnected, telemetry)
|
||||
val alertCount = effectiveAlertCount(alertState)
|
||||
val rtkColor = rtkStatusColor(sdkState.productConnected, telemetry)
|
||||
val rtkCount = rtkSatelliteText(sdkState.productConnected, telemetry)
|
||||
|
||||
Column(modifier.background(PilotBlack)) {
|
||||
Row(
|
||||
@@ -3062,8 +3066,8 @@ private fun FlightTopBar(
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
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(if (sdkState.productConnected) "0" else "--", color = if (sdkState.productConnected) PilotDanger else Color(0xFF9CA3AF), fontSize = 15.sp, fontWeight = FontWeight.Black)
|
||||
Text("RTK", color = rtkColor, fontSize = 14.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))
|
||||
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 {
|
||||
val passiveMessage = rawMessage.isBlank() ||
|
||||
rawMessage.contains("等待设备") ||
|
||||
|
||||
Reference in New Issue
Block a user