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.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()

View File

@@ -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 =

View File

@@ -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)
}
}

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.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()

View File

@@ -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

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.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("等待设备") ||