This commit is contained in:
2026-07-11 17:52:25 +08:00
parent a6ae1c9383
commit e284fc10a4
8 changed files with 769 additions and 132 deletions

View File

@@ -130,7 +130,6 @@ dependencies {
implementation deps.legacySupport implementation deps.legacySupport
implementation deps.lifecycleViewModel implementation deps.lifecycleViewModel
implementation deps.lifecycleLiveData implementation deps.lifecycleLiveData
implementation deps.leakcanary
implementation deps.glide implementation deps.glide
implementation deps.dynamicanimation implementation deps.dynamicanimation
implementation deps.expandedit implementation deps.expandedit

View File

@@ -1,18 +1,23 @@
package com.zklh.dronecontroller.core.cloud package com.zklh.dronecontroller.core.cloud
import android.util.Log import android.util.Log
import com.zklh.dronecontroller.core.diagnostics.DroneWarningItem
import com.zklh.dronecontroller.core.diagnostics.DroneWarningState
import com.zklh.dronecontroller.core.flight.FlightControlService import com.zklh.dronecontroller.core.flight.FlightControlService
import com.zklh.dronecontroller.core.flight.FlyToService import com.zklh.dronecontroller.core.flight.FlyToService
import com.zklh.dronecontroller.core.gimbal.GimbalControlService import com.zklh.dronecontroller.core.gimbal.GimbalControlService
import com.zklh.dronecontroller.core.livestream.LiveStreamingService import com.zklh.dronecontroller.core.livestream.LiveStreamingService
import com.zklh.dronecontroller.core.media.CameraControlService import com.zklh.dronecontroller.core.media.CameraControlService
import com.zklh.dronecontroller.core.media.CameraMediaService import com.zklh.dronecontroller.core.media.CameraMediaService
import com.zklh.dronecontroller.core.mission.MissionDroneInfo
import com.zklh.dronecontroller.core.mission.MissionFinishAction import com.zklh.dronecontroller.core.mission.MissionFinishAction
import com.zklh.dronecontroller.core.mission.MissionPayloadInfo
import com.zklh.dronecontroller.core.mission.MissionPlan import com.zklh.dronecontroller.core.mission.MissionPlan
import com.zklh.dronecontroller.core.mission.MissionWaypoint import com.zklh.dronecontroller.core.mission.MissionWaypoint
import com.zklh.dronecontroller.core.mission.WaypointMissionService import com.zklh.dronecontroller.core.mission.WaypointMissionService
import com.zklh.dronecontroller.core.mission.WpmlKmzBuilder import com.zklh.dronecontroller.core.mission.WpmlKmzBuilder
import com.zklh.dronecontroller.core.msdk.DjiCommandResult import com.zklh.dronecontroller.core.msdk.DjiCommandResult
import com.zklh.dronecontroller.core.telemetry.TelemetryCameraIdentity
import com.zklh.dronecontroller.core.telemetry.TelemetrySnapshot import com.zklh.dronecontroller.core.telemetry.TelemetrySnapshot
import com.zklh.dronecontroller.core.telemetry.hasReliableRtkPosition import com.zklh.dronecontroller.core.telemetry.hasReliableRtkPosition
import com.zklh.dronecontroller.core.telemetry.isRtkBlockingWaypointMission import com.zklh.dronecontroller.core.telemetry.isRtkBlockingWaypointMission
@@ -25,11 +30,9 @@ import java.io.File
import java.net.URL import java.net.URL
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.asin
import kotlin.math.atan2 import kotlin.math.atan2
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.math.sin import kotlin.math.sin
import kotlin.math.sqrt import kotlin.math.sqrt
@@ -44,15 +47,11 @@ import org.json.JSONObject
private const val CloudCommandLogTag = "ZklhCloudCommand" private const val CloudCommandLogTag = "ZklhCloudCommand"
private const val DefaultFlyToHeightMeters = 20.0 private const val DefaultFlyToHeightMeters = 20.0
private const val MinimumTakeoffTargetHeightMeters = 2.0 private const val MinimumTakeoffTargetHeightMeters = 2.0
private const val MinimumWaylineTakeoffHeightMeters = 20.0
private const val WaylineInitialTakeoffReadyTimeoutMs = 45_000L
private const val WaylineInitialTakeoffSettleDelayMs = 3_000L
private const val WaylineStartRetryCount = 6 private const val WaylineStartRetryCount = 6
private const val WaylineDirectGroundStartRetryCount = 2
private const val WaylineStartRetryDelayMs = 2_000L private const val WaylineStartRetryDelayMs = 2_000L
private const val WaypointRtkReadyWaitMs = 25_000L
private const val WaypointRtkReadyPollMs = 1_000L
private const val TemporaryWaylineExitSettleDelayMs = 1_500L private const val TemporaryWaylineExitSettleDelayMs = 1_500L
private const val TemporaryWaylineDepartureOffsetMeters = 5.0
private const val TemporaryWaylineSamePointThresholdMeters = 2.0
private const val PointFlightProgressIntervalMs = 5_000L private const val PointFlightProgressIntervalMs = 5_000L
private const val PointFlightProgressMonitorTimeoutMs = 30 * 60 * 1_000L private const val PointFlightProgressMonitorTimeoutMs = 30 * 60 * 1_000L
private const val PointFlightArriveThresholdMeters = 3.0 private const val PointFlightArriveThresholdMeters = 3.0
@@ -61,6 +60,34 @@ private const val CoordPi = 3.1415926535897932384626
private const val CoordSemiMajorAxis = 6378245.0 private const val CoordSemiMajorAxis = 6378245.0
private const val CoordEccentricity = 0.00669342162296594323 private const val CoordEccentricity = 0.00669342162296594323
private const val CoordEarthRadiusMeters = 6_371_000.0 private const val CoordEarthRadiusMeters = 6_371_000.0
private const val WpmlUnknownEnumValue = 65_535
private const val WpmlM350DroneEnumValue = 89
private const val WpmlH20TPayloadEnumValue = 43
private const val TemporaryWaylineMinimumTakeoffHeightMeters = 2.0
private const val TemporaryWaylineDefaultGroundTakeoffHeightMeters = 20.0
private const val TemporaryWaylineLeadInDistanceMeters = 6.0
private const val TemporaryWaylineGroundLeadInDistanceMeters = 6.0
private const val TemporaryWaylineGroundVerticalDistanceThresholdMeters = 5.0
private const val TemporaryWaylineGroundVerticalSecondPointOffsetMeters = 2.0
private const val TemporaryWaylineGroundStartMaxSpeedMetersPerSecond = 5.0
private const val TemporaryWaylineMinimumDistinctWaypointDistanceMeters = 3.0
private const val M350NativeTakeoffBeforeWaylineReadyTimeoutMs = 30_000L
private const val M350NativeTakeoffBeforeWaylinePollMs = 500L
private const val M350NativeTakeoffBeforeWaylineSettleMs = 4_000L
private const val M350TakeoffToPointManualClimbHeightMeters = 20.0
private const val M350TakeoffToPointManualClimbToleranceMeters = 0.6
private const val M350TakeoffToPointManualClimbTimeoutMs = 60_000L
private const val M350TakeoffToPointManualClimbPollMs = 100L
private const val M350TakeoffToPointManualClimbSettleMs = 2_000L
private const val M350TakeoffToPointManualClimbStickRatio = 0.42
private const val PointFlightTakeoffStallCheckAfterMs = 20_000L
private const val PointFlightTakeoffStallAltitudeMeters = 1.5
private const val PointFlightTakeoffStallVerticalSpeedMeters = 0.25
private const val NeutralStickDeadBand = 3
private const val DjiHmsUnableTakeoffCode = "0x1F100031"
private const val DjiHmsUnableTakeoffCodeAlt = "0x1F100032"
private const val DjiHmsHeightLimitZoneCode = "0x1F100056"
private const val DjiHmsPoorRtkSignalCode = "0x16100066"
interface CloudMissionProgressPublisher { interface CloudMissionProgressPublisher {
fun publishFlightTaskProgress( fun publishFlightTaskProgress(
@@ -96,6 +123,7 @@ class CloudCommandExecutor(
private val gimbalControl: GimbalControlService = GimbalControlService(), private val gimbalControl: GimbalControlService = GimbalControlService(),
private val mediaUpload: CloudMediaUploadService? = null, private val mediaUpload: CloudMediaUploadService? = null,
private val telemetryProvider: () -> TelemetrySnapshot = { TelemetrySnapshot() }, private val telemetryProvider: () -> TelemetrySnapshot = { TelemetrySnapshot() },
private val warningProvider: () -> DroneWarningState = { DroneWarningState() },
private val progressPublisher: CloudMissionProgressPublisher? = null private val progressPublisher: CloudMissionProgressPublisher? = null
) { ) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -215,6 +243,22 @@ class CloudCommandExecutor(
private suspend fun executeStickControl( private suspend fun executeStickControl(
data: JSONObject data: JSONObject
): DjiCommandResult { ): DjiCommandResult {
val position = if (data.hasAny("roll", "pitch", "throttle", "yaw")) {
data.toDrcStickPosition()
} else {
data.toProtocolStickPosition()
}
val now = System.currentTimeMillis()
if (position.isNeutral(deadBand = NeutralStickDeadBand) && isPointFlightStartingOrActive()) {
if (now - lastStickLogAt > 1_000L) {
Log.d(
CloudCommandLogTag,
"ignore neutral stick_control during point-flight wayline data=$data"
)
lastStickLogAt = now
}
return DjiCommandResult.ok("自动指点飞行执行中,已忽略中位杆量心跳")
}
stopFlyToForManualControl() stopFlyToForManualControl()
val enabled = ensureVirtualStickEnabled() val enabled = ensureVirtualStickEnabled()
if (!enabled.success) return enabled if (!enabled.success) return enabled
@@ -224,12 +268,6 @@ class CloudCommandExecutor(
} else { } else {
virtualStick.setSpeedLevel(15.0) virtualStick.setSpeedLevel(15.0)
} }
val position = if (data.hasAny("roll", "pitch", "throttle", "yaw")) {
data.toDrcStickPosition()
} else {
data.toProtocolStickPosition()
}
val now = System.currentTimeMillis()
if (now - lastStickLogAt > 500L) { if (now - lastStickLogAt > 500L) {
Log.d( Log.d(
CloudCommandLogTag, CloudCommandLogTag,
@@ -434,8 +472,11 @@ class CloudCommandExecutor(
flyToFailureMessage: String, flyToFailureMessage: String,
progressKind: PointFlightProgressKind = PointFlightProgressKind.FlyToPoint progressKind: PointFlightProgressKind = PointFlightProgressKind.FlyToPoint
): DjiCommandResult { ): DjiCommandResult {
val telemetry = telemetryProvider() var telemetry = telemetryProvider()
if (telemetry.isRtkBlockingWaypointMission()) { if (telemetry.isRtkBlockingWaypointMission() && !telemetry.canAttemptAirborneTemporaryWaylineWithoutRtk()) {
telemetry = waitForTemporaryWaylineRtkReady(command)
}
if (telemetry.isRtkBlockingWaypointMission() && !telemetry.canAttemptAirborneTemporaryWaylineWithoutRtk()) {
publishPointFlightFailure(command, progressKind, "RTK 已开启但尚未就绪") publishPointFlightFailure(command, progressKind, "RTK 已开启但尚未就绪")
return DjiCommandResult.failed( return DjiCommandResult.failed(
"原生指点飞行不可用RTK 已开启但尚未就绪healthy=${telemetry.rtkHealthy}" + "原生指点飞行不可用RTK 已开启但尚未就绪healthy=${telemetry.rtkHealthy}" +
@@ -443,36 +484,116 @@ class CloudCommandExecutor(
"M350 航线任务会被 DJI 拒绝,请等待 RTK 固定解,或在 DJI Pilot 中关闭 RTK 后重试。" "M350 航线任务会被 DJI 拒绝,请等待 RTK 固定解,或在 DJI Pilot 中关闭 RTK 后重试。"
) )
} }
if (telemetry.isRtkBlockingWaypointMission()) {
Log.w(
CloudCommandLogTag,
"RTK is not ready, but aircraft is already airborne with valid GPS; " +
"try temporary wayline by FC/GPS position commandId=${command.commandId} " +
"gps=${telemetry.gpsSatelliteCount} solution=${telemetry.rtkPositioningSolution}"
)
}
val requiresM350PointFlightFallback = telemetry.requiresTemporaryWaylinePointFlight()
val m350GroundStart = requiresM350PointFlightFallback &&
!telemetry.isM350NativeTakeoffReadyForAutonomousCommand()
val m350TakeoffToPointGroundStart =
m350GroundStart && progressKind == PointFlightProgressKind.TakeoffToPoint
warningProvider().preStartPointFlightBlocker(telemetry)?.let { blocker ->
if (m350GroundStart) {
Log.w(
CloudCommandLogTag,
"M350 ground point-flight ignores pre-start HMS blocker and lets native takeoff decide " +
"commandId=${command.commandId} blocker=${blocker.toPointFlightBlockerText()}"
)
} else {
val message = "飞控当前禁止起飞:${blocker.toPointFlightBlockerText()}"
publishPointFlightFailure(command, progressKind, message)
return DjiCommandResult.failed("原生指点飞行不可用;$message")
}
}
val outputFile = File.createTempFile("point-flight-", ".kmz")
val missionId = waypointMission.missionIdFromPath(outputFile.absolutePath)
beginPointFlightStart(command, missionId)?.let { return it }
if (m350GroundStart) {
val takeoffResult = startM350NativeTakeoffBeforeTemporaryWayline(command, progressKind, missionId)
if (!takeoffResult.success) {
clearStartingPointFlight(missionId)
return takeoffResult
}
telemetry = telemetryProvider()
if (m350TakeoffToPointGroundStart) {
val climbResult = climbM350TakeoffToPointManually(command, progressKind, missionId)
if (!climbResult.success) {
clearStartingPointFlight(missionId)
return climbResult
}
telemetry = telemetryProvider()
}
}
if (
requiresM350PointFlightFallback &&
!m350TakeoffToPointGroundStart &&
telemetry.isM350NativeTakeoffReadyForAutonomousCommand()
) {
val nativeFlyTo = tryM350NativeFlyToBeforeTemporaryWayline(command, progressKind, missionId)
if (nativeFlyTo?.success == true) {
return nativeFlyTo
}
}
val startLocation = telemetry.pointFlightStartLocation() val startLocation = telemetry.pointFlightStartLocation()
?: run { ?: run {
clearStartingPointFlight(missionId)
publishPointFlightFailure(command, progressKind, "当前位置无效,无法生成航线兜底") publishPointFlightFailure(command, progressKind, "当前位置无效,无法生成航线兜底")
return DjiCommandResult.failed( return DjiCommandResult.failed(
"原生指点飞行不可用,且当前位置无效,无法生成航线兜底:$flyToFailureMessage" "原生指点飞行不可用,且当前位置无效,无法生成航线兜底:$flyToFailureMessage"
) )
} }
val isGroundStart = if (requiresM350PointFlightFallback) {
!telemetry.isM350NativeTakeoffReadyForAutonomousCommand()
} else {
!telemetry.isReadyToStartTemporaryWayline()
}
val targetHeight = (command.commanderFlightHeight ?: command.height).coerceAtLeast(1.0) val targetHeight = (command.commanderFlightHeight ?: command.height).coerceAtLeast(1.0)
val safeTakeoffHeight = command.securityTakeoffHeight val requestedSpeed = command.maxSpeed.coerceIn(1, 15).toDouble()
.coerceAtLeast(1) val speed = if (isGroundStart) {
.coerceAtMost(targetHeight.roundToInt().coerceAtLeast(1)) requestedSpeed.coerceAtMost(TemporaryWaylineGroundStartMaxSpeedMetersPerSecond)
.toDouble() } else {
val speed = command.maxSpeed.coerceIn(1, 15).toDouble() requestedSpeed
}
val safeTakeoffHeight = telemetry.temporaryWaylineTakeoffSecurityHeight(
targetHeight = targetHeight,
requestedTakeoffHeight = command.securityTakeoffHeight
)
val distanceToTarget = startLocation.distanceMetersTo(
PointFlightStartLocation(command.latitude, command.longitude)
)
val routeShape = if (
isGroundStart &&
distanceToTarget <= TemporaryWaylineGroundVerticalDistanceThresholdMeters
) {
"vertical-to-target"
} else {
"lead-in-to-target"
}
val temporaryWaypoints = temporaryPointFlightWaypoints( val temporaryWaypoints = temporaryPointFlightWaypoints(
startLocation = startLocation, startLocation = startLocation,
command = command, command = command,
targetHeight = targetHeight, targetHeight = targetHeight,
speed = speed speed = speed,
groundStart = isGroundStart
) )
val outputFile = File.createTempFile("point-flight-", ".kmz") val droneInfo = telemetry.wpmlDroneInfoForTemporaryWayline()
val missionId = waypointMission.missionIdFromPath(outputFile.absolutePath) val payloadInfo = telemetry.wpmlPayloadInfoForTemporaryWayline()
beginPointFlightStart(command, missionId)?.let { return it }
val plan = MissionPlan( val plan = MissionPlan(
name = missionId, name = missionId,
waypoints = temporaryWaypoints, waypoints = temporaryWaypoints,
globalSpeed = speed, globalSpeed = speed,
takeoffHeight = safeTakeoffHeight, takeoffHeight = safeTakeoffHeight,
globalHeight = targetHeight, globalHeight = targetHeight,
finishAction = MissionFinishAction.NoAction finishAction = MissionFinishAction.NoAction,
includeGimbalPitch = false,
droneInfo = droneInfo,
payloadInfo = payloadInfo
) )
return runCatching { return runCatching {
@@ -480,11 +601,13 @@ class CloudCommandExecutor(
WpmlKmzBuilder().generateKmz(plan, outputFile) WpmlKmzBuilder().generateKmz(plan, outputFile)
Log.d( Log.d(
CloudCommandLogTag, CloudCommandLogTag,
"FlyTo handler missing, fallback to temporary wayline commandId=${command.commandId} missionId=$missionId " + "start temporary point-flight wayline commandId=${command.commandId} missionId=$missionId " +
"from=${startLocation.latitude},${startLocation.longitude} " + "from=${startLocation.latitude},${startLocation.longitude} " +
"to=${command.latitude},${command.longitude} height=$targetHeight " + "to=${command.latitude},${command.longitude} height=$targetHeight " +
"commanderFlightHeight=${command.commanderFlightHeight} speed=$speed " + "commanderFlightHeight=${command.commanderFlightHeight} speed=$speed requestedSpeed=$requestedSpeed " +
"groundStart=$isGroundStart distanceToTarget=${distanceToTarget.formatMeters()} routeShape=$routeShape " +
"safeTakeoffHeight=$safeTakeoffHeight waypoints=${temporaryWaypoints.toLogText()} " + "safeTakeoffHeight=$safeTakeoffHeight waypoints=${temporaryWaypoints.toLogText()} " +
"wpmlDrone=$droneInfo wpmlPayloads=$payloadInfo " +
"flyToFailure=$flyToFailureMessage" "flyToFailure=$flyToFailureMessage"
) )
val upload = waypointMission.uploadKmzFile(outputFile.absolutePath) val upload = waypointMission.uploadKmzFile(outputFile.absolutePath)
@@ -498,7 +621,7 @@ class CloudCommandExecutor(
val start = startTemporaryWaylineMission(missionId) val start = startTemporaryWaylineMission(missionId)
if (start.success) { if (start.success) {
markPointFlightActive(command, missionId) markPointFlightActive(command, missionId)
startPointFlightProgressMonitor(command, progressKind, missionId) startPointFlightProgressMonitor(command, progressKind, missionId, isGroundStart)
DjiCommandResult.ok("原生指点飞行不可用,已切换为临时航线指点飞行:$missionId") DjiCommandResult.ok("原生指点飞行不可用,已切换为临时航线指点飞行:$missionId")
} else { } else {
clearStartingPointFlight(missionId) clearStartingPointFlight(missionId)
@@ -514,6 +637,184 @@ class CloudCommandExecutor(
} }
} }
private suspend fun tryM350NativeFlyToBeforeTemporaryWayline(
command: ParsedFlyToCommand,
progressKind: PointFlightProgressKind,
missionId: String
): DjiCommandResult? {
Log.d(
CloudCommandLogTag,
"try M350 native FlyTo before temporary wayline commandId=${command.commandId} missionId=$missionId " +
"lat=${command.latitude} lon=${command.longitude} height=${command.height} " +
"maxSpeed=${command.maxSpeed} securityTakeoffHeight=${command.securityTakeoffHeight}"
)
val nativeResult = flyToService.startFlyTo(
latitude = command.latitude,
longitude = command.longitude,
height = command.height,
maxSpeed = command.maxSpeed,
securityTakeoffHeight = command.securityTakeoffHeight,
flyToMode = command.flyToMode
)
Log.d(
CloudCommandLogTag,
"M350 native FlyTo before temporary wayline result commandId=${command.commandId} " +
"success=${nativeResult.success} message=${nativeResult.message}"
)
if (!nativeResult.success) {
return null
}
clearStartingPointFlight(missionId)
clearActivePointFlight()
startPointFlightProgressMonitor(command, progressKind)
return nativeResult
}
private suspend fun startM350NativeTakeoffBeforeTemporaryWayline(
command: ParsedFlyToCommand,
progressKind: PointFlightProgressKind,
missionId: String
): DjiCommandResult {
Log.d(
CloudCommandLogTag,
"M350 ground temporary wayline uses native takeoff first commandId=${command.commandId} missionId=$missionId"
)
publishPointFlightProgress(command, progressKind, "TAKEOFF_STARTED")
val takeoff = flightControl.startTakeoff()
if (!takeoff.success) {
val message = "M350 地面指点飞行先执行原生起飞失败:${takeoff.message}"
publishPointFlightFailure(command, progressKind, message)
return DjiCommandResult.failed(message)
}
val deadline = System.currentTimeMillis() + M350NativeTakeoffBeforeWaylineReadyTimeoutMs
var latest = telemetryProvider()
while (System.currentTimeMillis() < deadline) {
latest = telemetryProvider()
if (latest.isM350NativeTakeoffReadyForAutonomousCommand()) {
Log.d(
CloudCommandLogTag,
"M350 native takeoff is ready for autonomous command commandId=${command.commandId} " +
"missionId=$missionId isFlying=${latest.isFlying} motorsOn=${latest.motorsOn} " +
"altitude=${latest.altitude.formatMeters()} mode=${latest.flightMode}"
)
delay(M350NativeTakeoffBeforeWaylineSettleMs)
return DjiCommandResult.ok("M350 原生起飞完成,准备启动自主飞行指令")
}
delay(M350NativeTakeoffBeforeWaylinePollMs)
}
val message = "M350 原生起飞后未进入可启动航线状态altitude=${latest.altitude.formatMeters()}mode=${latest.flightMode}"
publishPointFlightFailure(command, progressKind, message)
return DjiCommandResult.failed(message)
}
private suspend fun climbM350TakeoffToPointManually(
command: ParsedFlyToCommand,
progressKind: PointFlightProgressKind,
missionId: String
): DjiCommandResult {
val requestedTargetHeight = (command.commanderFlightHeight ?: command.height).coerceAtLeast(1.0)
val climbTargetHeight = requestedTargetHeight.coerceAtMost(M350TakeoffToPointManualClimbHeightMeters)
var latest = telemetryProvider()
if (latest.altitude >= climbTargetHeight - M350TakeoffToPointManualClimbToleranceMeters) {
return DjiCommandResult.ok("M350 已达到航线接管高度")
}
Log.d(
CloudCommandLogTag,
"M350 takeoff_to_point starts manual climb before wayline " +
"commandId=${command.commandId} missionId=$missionId " +
"from=${latest.altitude.formatMeters()} target=${climbTargetHeight.formatMeters()}"
)
publishPointFlightProgress(command, progressKind, "MANUAL_CLIMB_STARTED")
val enable = ensureVirtualStickEnabled()
if (!enable.success) {
val message = "M350 一键起飞获取手动飞行控制权失败:${enable.message}"
publishPointFlightFailure(command, progressKind, message)
return DjiCommandResult.failed(message)
}
val climbStick = (Stick.MAX_STICK_POSITION_ABS * M350TakeoffToPointManualClimbStickRatio)
.roundToInt()
.coerceIn(1, Stick.MAX_STICK_POSITION_ABS)
val deadline = System.currentTimeMillis() + M350TakeoffToPointManualClimbTimeoutMs
var reached = false
try {
while (System.currentTimeMillis() < deadline) {
latest = telemetryProvider()
if (!latest.motorsOn) break
if (latest.altitude >= climbTargetHeight - M350TakeoffToPointManualClimbToleranceMeters) {
reached = true
break
}
virtualStick.sendStickPosition(StickPosition(leftVertical = climbStick))
delay(M350TakeoffToPointManualClimbPollMs)
}
} finally {
repeat(3) {
sendNeutralStick()
delay(M350TakeoffToPointManualClimbPollMs)
}
}
val release = releaseVirtualStickForAutonomousFlight()
if (!release.success) {
val message = "M350 一键起飞释放手动飞行控制权失败:${release.message}"
publishPointFlightFailure(command, progressKind, message)
return DjiCommandResult.failed(message)
}
if (!reached) {
latest = telemetryProvider()
val message = "M350 一键起飞手动爬升未达到航线接管高度:" +
"altitude=${latest.altitude.formatMeters()}mtarget=${climbTargetHeight.formatMeters()}m"
publishPointFlightFailure(command, progressKind, message)
return DjiCommandResult.failed(message)
}
delay(M350TakeoffToPointManualClimbSettleMs)
latest = telemetryProvider()
Log.d(
CloudCommandLogTag,
"M350 takeoff_to_point manual climb completed and released control " +
"commandId=${command.commandId} missionId=$missionId " +
"altitude=${latest.altitude.formatMeters()} mode=${latest.flightMode}"
)
publishPointFlightProgress(command, progressKind, "MANUAL_CLIMB_COMPLETED")
return DjiCommandResult.ok("M350 已手动爬升到航线接管高度,准备启动临时航线")
}
private suspend fun waitForTemporaryWaylineRtkReady(command: ParsedFlyToCommand): TelemetrySnapshot {
val deadline = System.currentTimeMillis() + WaypointRtkReadyWaitMs
var latest = telemetryProvider()
Log.w(
CloudCommandLogTag,
"temporary wayline waits for RTK ready commandId=${command.commandId} " +
"healthy=${latest.rtkHealthy} solution=${latest.rtkPositioningSolution} " +
"gps=${latest.gpsSatelliteCount}"
)
while (System.currentTimeMillis() < deadline) {
delay(WaypointRtkReadyPollMs)
latest = telemetryProvider()
if (!latest.isRtkBlockingWaypointMission() || latest.canAttemptAirborneTemporaryWaylineWithoutRtk()) {
Log.d(
CloudCommandLogTag,
"temporary wayline RTK wait passed commandId=${command.commandId} " +
"healthy=${latest.rtkHealthy} solution=${latest.rtkPositioningSolution} " +
"gps=${latest.gpsSatelliteCount}"
)
return latest
}
}
Log.w(
CloudCommandLogTag,
"temporary wayline RTK wait timed out commandId=${command.commandId} " +
"healthy=${latest.rtkHealthy} solution=${latest.rtkPositioningSolution} " +
"gps=${latest.gpsSatelliteCount}"
)
return latest
}
private fun existingPointFlightResult(command: ParsedFlyToCommand): DjiCommandResult? { private fun existingPointFlightResult(command: ParsedFlyToCommand): DjiCommandResult? {
val commandId = command.commandId.takeIf { it.isNotBlank() } ?: return null val commandId = command.commandId.takeIf { it.isNotBlank() } ?: return null
return synchronized(pointFlightStateLock) { return synchronized(pointFlightStateLock) {
@@ -611,6 +912,11 @@ class CloudCommandExecutor(
} }
} }
private fun isPointFlightStartingOrActive(): Boolean =
synchronized(pointFlightStateLock) {
activePointFlightWaylineMissionId != null || startingPointFlightMissionId != null
}
private fun clearStartingPointFlight(missionId: String) { private fun clearStartingPointFlight(missionId: String) {
synchronized(pointFlightStateLock) { synchronized(pointFlightStateLock) {
if (startingPointFlightMissionId == missionId) { if (startingPointFlightMissionId == missionId) {
@@ -623,7 +929,8 @@ class CloudCommandExecutor(
private fun startPointFlightProgressMonitor( private fun startPointFlightProgressMonitor(
command: ParsedFlyToCommand, command: ParsedFlyToCommand,
progressKind: PointFlightProgressKind, progressKind: PointFlightProgressKind,
temporaryMissionId: String? = null temporaryMissionId: String? = null,
groundStart: Boolean = false
) { ) {
val commandId = command.monitorKey(progressKind) val commandId = command.monitorKey(progressKind)
pointFlightProgressJobs.remove(commandId)?.cancel() pointFlightProgressJobs.remove(commandId)?.cancel()
@@ -631,6 +938,7 @@ class CloudCommandExecutor(
pointFlightProgressJobs[commandId] = scope.launch { pointFlightProgressJobs[commandId] = scope.launch {
val targetLocation = PointFlightStartLocation(command.latitude, command.longitude) val targetLocation = PointFlightStartLocation(command.latitude, command.longitude)
val deadline = System.currentTimeMillis() + PointFlightProgressMonitorTimeoutMs val deadline = System.currentTimeMillis() + PointFlightProgressMonitorTimeoutMs
val monitorStartAt = System.currentTimeMillis()
var lastProgressAt = 0L var lastProgressAt = 0L
var lastRemainingDistance = Double.MAX_VALUE var lastRemainingDistance = Double.MAX_VALUE
while (System.currentTimeMillis() < deadline) { while (System.currentTimeMillis() < deadline) {
@@ -641,6 +949,34 @@ class CloudCommandExecutor(
val altitudeError = abs(command.height - telemetry.altitude) val altitudeError = abs(command.height - telemetry.altitude)
val remainingDistance = sqrt(horizontalDistance * horizontalDistance + altitudeError * altitudeError) val remainingDistance = sqrt(horizontalDistance * horizontalDistance + altitudeError * altitudeError)
val remainingSeconds = (remainingDistance / command.maxSpeed.coerceIn(1, 15)).toFloat() val remainingSeconds = (remainingDistance / command.maxSpeed.coerceIn(1, 15)).toFloat()
val stalledTakeoff = groundStart &&
!temporaryMissionId.isNullOrBlank() &&
System.currentTimeMillis() - monitorStartAt >= PointFlightTakeoffStallCheckAfterMs &&
telemetry.altitude <= PointFlightTakeoffStallAltitudeMeters &&
abs(telemetry.speedZ) <= PointFlightTakeoffStallVerticalSpeedMeters
if (stalledTakeoff) {
val warning = warningProvider().pointFlightStallBlocker()
val message = buildString {
append("M350 临时航线起飞后卡在 ")
append(telemetry.altitude.formatMeters())
append("m未继续爬升")
append("flightMode=${telemetry.flightMode}")
if (warning != null) {
append(";飞控告警:")
append(warning.toPointFlightBlockerText())
}
}
Log.w(
CloudCommandLogTag,
"point flight takeoff stalled commandId=$commandId missionId=$temporaryMissionId " +
"altitude=${telemetry.altitude.formatMeters()} speedZ=${telemetry.speedZ.formatMeters()} " +
"mode=${telemetry.flightMode} warning=${warning?.toPointFlightBlockerText().orEmpty()}"
)
stopTemporaryPointFlightWaylineAfterArrival(commandId, temporaryMissionId)
publishPointFlightFailure(command, progressKind, message)
pointFlightProgressJobs.remove(commandId)
return@launch
}
val arrived = horizontalDistance <= PointFlightArriveThresholdMeters && val arrived = horizontalDistance <= PointFlightArriveThresholdMeters &&
altitudeError <= PointFlightArriveAltitudeThresholdMeters && altitudeError <= PointFlightArriveAltitudeThresholdMeters &&
(telemetry.isFlying || telemetry.altitude >= 0.8) (telemetry.isFlying || telemetry.altitude >= 0.8)
@@ -783,57 +1119,22 @@ class CloudCommandExecutor(
} }
/** /**
* M350 cannot reliably enter a dynamically generated waypoint mission * Temporary point flight is executed as a normal WPML mission.
* directly from the ground. It first needs to finish the normal MSDK * DJI wayline missions support both ground start (take off, climb, then fly)
* takeoff phase, then the already-uploaded temporary KMZ can be started. * and airborne start, so do not mix in native takeoff here.
*/ */
private suspend fun startTemporaryWaylineMission(missionId: String): DjiCommandResult { private suspend fun startTemporaryWaylineMission(missionId: String): DjiCommandResult {
if (telemetryProvider().isReadyToStartTemporaryWayline()) {
return startTemporaryWaylineMissionWithRetry(missionId)
}
Log.d(CloudCommandLogTag, "temporary wayline is ground-started; try direct startMission first missionId=$missionId")
val directStart = startTemporaryWaylineMissionWithRetry(
missionId = missionId,
maxAttempts = WaylineDirectGroundStartRetryCount
)
if (directStart.success) {
return directStart
}
Log.w(
CloudCommandLogTag,
"direct ground startMission failed, fallback to native takeoff then startMission missionId=$missionId " +
"message=${directStart.message}"
)
Log.d(CloudCommandLogTag, "issue native takeoff before retrying temporary wayline missionId=$missionId")
val takeoff = flightControl.startTakeoff()
if (!takeoff.success) {
return DjiCommandResult.failed("临时航线已上传,但地面直接启动失败:${directStart.message};原生起飞也失败:${takeoff.message}")
}
val readyDeadline = System.currentTimeMillis() + WaylineInitialTakeoffReadyTimeoutMs
while (System.currentTimeMillis() < readyDeadline) {
val telemetry = telemetryProvider() val telemetry = telemetryProvider()
if (telemetry.isReadyToStartTemporaryWayline()) {
delay(WaylineInitialTakeoffSettleDelayMs)
val settledTelemetry = telemetryProvider()
Log.d( Log.d(
CloudCommandLogTag, CloudCommandLogTag,
"native takeoff ready for temporary wayline missionId=$missionId " + "start temporary point-flight mission missionId=$missionId " +
"isFlying=${settledTelemetry.isFlying} motorsOn=${settledTelemetry.motorsOn} " + "isFlying=${telemetry.isFlying} motorsOn=${telemetry.motorsOn} " +
"altitude=${settledTelemetry.altitude} mode=${settledTelemetry.flightMode}" "altitude=${telemetry.altitude.formatMeters()} mode=${telemetry.flightMode} " +
"rtkEnabled=${telemetry.rtkEnabled} rtkHealthy=${telemetry.rtkHealthy} " +
"rtkSolution=${telemetry.rtkPositioningSolution}"
) )
return startTemporaryWaylineMissionWithRetry(missionId) return startTemporaryWaylineMissionWithRetry(missionId)
} }
delay(200L)
}
val telemetry = telemetryProvider()
return DjiCommandResult.failed(
"临时航线已上传但原生起飞未进入稳定悬停isFlying=${telemetry.isFlying}" +
"altitude=${telemetry.altitude.formatMeters()}"
)
}
private suspend fun startTemporaryWaylineMissionWithRetry( private suspend fun startTemporaryWaylineMissionWithRetry(
missionId: String, missionId: String,
@@ -1192,36 +1493,241 @@ private fun TelemetrySnapshot.requiresTemporaryWaylinePointFlight(): Boolean {
product.contains("M350") product.contains("M350")
} }
private fun TelemetrySnapshot.wpmlDroneInfoForTemporaryWayline(): MissionDroneInfo? {
val product = productType.uppercase()
return when {
product.contains("MATRICE_350") || product.contains("M350") ->
MissionDroneInfo(enumValue = WpmlM350DroneEnumValue)
else -> null
}
}
private fun TelemetrySnapshot.wpmlPayloadInfoForTemporaryWayline(): List<MissionPayloadInfo> {
val identities = cameraIdentities.ifEmpty {
listOf(
TelemetryCameraIdentity(
component = cameraComponent,
cameraType = cameraType,
cameraTypeValue = cameraTypeValue,
payloadCameraType = payloadCameraType,
payloadCameraTypeValue = payloadCameraTypeValue
)
)
}
val detected = identities
.mapNotNull { it.toMissionPayloadInfo() }
.distinctBy { it.positionIndex }
if (detected.isNotEmpty()) return detected
val fallbackEnum = wpmlPayloadEnumFor(
cameraTypeValue = cameraTypeValue,
cameraText = (
listOf(cameraType, payloadCameraType) +
cameraIdentities.flatMap { listOf(it.cameraType, it.payloadCameraType) }
).joinToString(" "),
productText = productType
) ?: return emptyList()
return listOf(
MissionPayloadInfo(
enumValue = fallbackEnum,
positionIndex = cameraComponent.toWpmlPayloadPositionIndex()
)
)
}
private fun TelemetryCameraIdentity.toMissionPayloadInfo(): MissionPayloadInfo? {
val enumValue = payloadCameraTypeValue.takeIf { it.isKnownWpmlEnum() }
?: cameraTypeValue.takeIf { it.isKnownWpmlEnum() }
?: wpmlPayloadEnumFor(
cameraTypeValue = cameraTypeValue,
cameraText = listOf(cameraType, payloadCameraType).joinToString(" "),
productText = ""
)
?: return null
return MissionPayloadInfo(
enumValue = enumValue,
positionIndex = component.toWpmlPayloadPositionIndex()
)
}
private fun Int.isKnownWpmlEnum(): Boolean = this > 0 && this != WpmlUnknownEnumValue
private fun wpmlPayloadEnumFor(cameraTypeValue: Int, cameraText: String, productText: String): Int? {
val camera = cameraText.uppercase()
val product = productText.uppercase()
return when {
camera.contains("H30T") || camera.contains("ZENMUSE_H30T") -> 83
camera.contains("H30") || camera.contains("ZENMUSE_H30") -> 82
camera.contains("H20N") || camera.contains("ZENMUSE_H20N") -> 61
camera.contains("H20T") || camera.contains("ZENMUSE_H20T") -> WpmlH20TPayloadEnumValue
camera.contains("H20") || camera.contains("ZENMUSE_H20") -> 42
cameraTypeValue.isKnownWpmlEnum() -> cameraTypeValue
product.contains("MATRICE_350") || product.contains("M350") -> WpmlH20TPayloadEnumValue
else -> null
}
}
private fun String.toWpmlPayloadPositionIndex(): Int =
when (uppercase()) {
"LEFT_OR_MAIN" -> 0
"RIGHT" -> 1
"UP" -> 2
"PORT_1" -> 3
"PORT_2" -> 4
"PORT_3" -> 5
"PORT_4" -> 6
else -> 0
}
private fun TelemetrySnapshot.isReadyToStartTemporaryWayline(): Boolean = private fun TelemetrySnapshot.isReadyToStartTemporaryWayline(): Boolean =
isFlying || altitude >= 0.8 isFlying || altitude >= 0.8
private fun TelemetrySnapshot.isM350NativeTakeoffReadyForAutonomousCommand(): Boolean {
val mode = flightMode.uppercase()
val stillTakingOff = mode.contains("AUTO_TAKE") ||
mode.contains("TAKE_OFF") ||
mode.contains("TAKEOFF") ||
mode.contains("MOTOR_START")
return motorsOn &&
altitude >= 0.8 &&
!stillTakingOff
}
private fun TelemetrySnapshot.temporaryWaylineTakeoffSecurityHeight(
targetHeight: Double,
requestedTakeoffHeight: Int
): Double {
val minimumHeight = TemporaryWaylineMinimumTakeoffHeightMeters.coerceAtMost(targetHeight).coerceAtLeast(1.0)
if (!isReadyToStartTemporaryWayline()) {
val requestedOrDefault = requestedTakeoffHeight
.takeIf { it > 0 }
?.toDouble()
?: TemporaryWaylineDefaultGroundTakeoffHeightMeters
return requestedOrDefault
.coerceAtLeast(minimumHeight)
.coerceAtMost(targetHeight.coerceAtLeast(minimumHeight))
}
return requestedTakeoffHeight
.coerceAtLeast(minimumHeight.roundToInt().coerceAtLeast(1))
.coerceAtMost(targetHeight.roundToInt().coerceAtLeast(minimumHeight.roundToInt().coerceAtLeast(1)))
.toDouble()
}
private fun TelemetrySnapshot.canAttemptAirborneTemporaryWaylineWithoutRtk(): Boolean =
isReadyToStartTemporaryWayline() &&
gpsValid &&
locationValid &&
gpsSatelliteCount >= 10 &&
isValidCoordinate(latitude, longitude)
private fun DroneWarningState.preStartPointFlightBlocker(telemetry: TelemetrySnapshot): DroneWarningItem? {
if (!active || telemetry.isReadyToStartTemporaryWayline()) return null
return items.firstOrNull { item ->
item.isUnableTakeoffWarning() || (item.isPoorRtkWarning() && telemetry.isRtkBlockingWaypointMission())
}
}
private fun DroneWarningState.pointFlightStallBlocker(): DroneWarningItem? {
if (!active) return null
return items.firstOrNull { it.isUnableTakeoffWarning() || it.isPoorRtkWarning() }
?: items.firstOrNull()
}
private fun DroneWarningItem.isUnableTakeoffWarning(): Boolean =
code.equals(DjiHmsUnableTakeoffCode, ignoreCase = true) ||
code.equals(DjiHmsUnableTakeoffCodeAlt, ignoreCase = true) ||
warningText().contains("无法起飞")
private fun DroneWarningItem.isHeightLimitZoneWarning(): Boolean =
code.equals(DjiHmsHeightLimitZoneCode, ignoreCase = true) ||
warningText().contains("限高区")
private fun DroneWarningItem.isPoorRtkWarning(): Boolean =
code.equals(DjiHmsPoorRtkSignalCode, ignoreCase = true) ||
warningText().contains("RTK 信号差", ignoreCase = true)
private fun DroneWarningItem.toPointFlightBlockerText(): String {
val text = listOf(title, description, message)
.map { it.trim() }
.firstOrNull { it.isNotBlank() }
.orEmpty()
return listOf(code.trim(), text)
.filter { it.isNotBlank() }
.distinct()
.joinToString(": ")
.ifBlank { "未知飞控告警" }
}
private fun DroneWarningItem.warningText(): String =
listOf(code, title, description, message).joinToString(" ")
private fun temporaryPointFlightWaypoints( private fun temporaryPointFlightWaypoints(
startLocation: PointFlightStartLocation, startLocation: PointFlightStartLocation,
command: ParsedFlyToCommand, command: ParsedFlyToCommand,
targetHeight: Double, targetHeight: Double,
speed: Double speed: Double,
groundStart: Boolean
): List<MissionWaypoint> { ): List<MissionWaypoint> {
val targetLocation = PointFlightStartLocation(command.latitude, command.longitude) val targetLocation = PointFlightStartLocation(command.latitude, command.longitude)
val targetDistance = startLocation.distanceMetersTo(targetLocation) val distanceToTarget = startLocation.distanceMetersTo(targetLocation)
val bearing = if (targetDistance > TemporaryWaylineSamePointThresholdMeters) { if (groundStart && distanceToTarget <= TemporaryWaylineGroundVerticalDistanceThresholdMeters) {
startLocation.bearingRadiansTo(targetLocation)
} else {
0.0
}
val departureOffset = if (targetDistance > TemporaryWaylineDepartureOffsetMeters * 2) {
TemporaryWaylineDepartureOffsetMeters
} else {
min(TemporaryWaylineDepartureOffsetMeters, TemporaryWaylineSamePointThresholdMeters + 1.0)
}
val departureLocation = startLocation.offsetBy(
distanceMeters = departureOffset,
bearingRadians = bearing
)
return listOf( return listOf(
MissionWaypoint( MissionWaypoint(
latitude = departureLocation.latitude, latitude = targetLocation.latitude,
longitude = departureLocation.longitude, longitude = targetLocation.longitude,
altitude = targetHeight,
speed = speed
),
MissionWaypoint(
latitude = targetLocation.offsetByMeters(
northMeters = TemporaryWaylineGroundVerticalSecondPointOffsetMeters,
eastMeters = 0.0
).latitude,
longitude = targetLocation.offsetByMeters(
northMeters = TemporaryWaylineGroundVerticalSecondPointOffsetMeters,
eastMeters = 0.0
).longitude,
altitude = targetHeight,
speed = speed
)
)
}
val preferredLeadInDistance = if (groundStart) {
TemporaryWaylineGroundLeadInDistanceMeters
} else {
TemporaryWaylineLeadInDistanceMeters
}
val minimumDistinctDistance = if (groundStart) {
TemporaryWaylineGroundLeadInDistanceMeters
} else {
TemporaryWaylineMinimumDistinctWaypointDistanceMeters
}
val leadInLocation = when {
distanceToTarget >= minimumDistinctDistance * 2 -> {
val distanceBeforeTarget = preferredLeadInDistance.coerceAtMost(distanceToTarget / 2.0)
startLocation.pointAlongTo(
targetLocation,
distanceFromStartMeters = distanceToTarget - distanceBeforeTarget
)
}
distanceToTarget >= minimumDistinctDistance -> {
startLocation.pointAlongTo(
targetLocation,
distanceFromStartMeters = distanceToTarget / 2.0
)
}
else -> targetLocation.offsetByMeters(
northMeters = minimumDistinctDistance,
eastMeters = 0.0
)
}
return listOf(
MissionWaypoint(
latitude = leadInLocation.latitude,
longitude = leadInLocation.longitude,
altitude = targetHeight, altitude = targetHeight,
speed = speed speed = speed
), ),
@@ -1234,6 +1740,36 @@ private fun temporaryPointFlightWaypoints(
) )
} }
private fun PointFlightStartLocation.pointAlongTo(
other: PointFlightStartLocation,
distanceFromStartMeters: Double
): PointFlightStartLocation {
val totalDistance = distanceMetersTo(other)
if (totalDistance <= 0.01) return offsetByMeters(
northMeters = TemporaryWaylineMinimumDistinctWaypointDistanceMeters,
eastMeters = 0.0
)
val fraction = (distanceFromStartMeters / totalDistance).coerceIn(0.0, 1.0)
return PointFlightStartLocation(
latitude = latitude + (other.latitude - latitude) * fraction,
longitude = longitude + (other.longitude - longitude) * fraction
)
}
private fun PointFlightStartLocation.offsetByMeters(
northMeters: Double,
eastMeters: Double
): PointFlightStartLocation {
val latitudeRadians = latitude.toRadians()
val deltaLatitude = northMeters / CoordEarthRadiusMeters * 180.0 / CoordPi
val longitudeRadius = CoordEarthRadiusMeters * cos(latitudeRadians).coerceAtLeast(0.01)
val deltaLongitude = eastMeters / longitudeRadius * 180.0 / CoordPi
return PointFlightStartLocation(
latitude = latitude + deltaLatitude,
longitude = longitude + deltaLongitude
)
}
private fun PointFlightStartLocation.distanceMetersTo(other: PointFlightStartLocation): Double { private fun PointFlightStartLocation.distanceMetersTo(other: PointFlightStartLocation): Double {
val lat1 = latitude.toRadians() val lat1 = latitude.toRadians()
val lat2 = other.latitude.toRadians() val lat2 = other.latitude.toRadians()
@@ -1245,33 +1781,6 @@ private fun PointFlightStartLocation.distanceMetersTo(other: PointFlightStartLoc
return 2 * CoordEarthRadiusMeters * atan2(sqrt(normalized), sqrt(1 - normalized)) return 2 * CoordEarthRadiusMeters * atan2(sqrt(normalized), sqrt(1 - normalized))
} }
private fun PointFlightStartLocation.bearingRadiansTo(other: PointFlightStartLocation): Double {
val lat1 = latitude.toRadians()
val lat2 = other.latitude.toRadians()
val dLon = (other.longitude - longitude).toRadians()
val y = sin(dLon) * cos(lat2)
val x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)
return atan2(y, x)
}
private fun PointFlightStartLocation.offsetBy(
distanceMeters: Double,
bearingRadians: Double
): PointFlightStartLocation {
val angularDistance = distanceMeters / CoordEarthRadiusMeters
val lat1 = latitude.toRadians()
val lon1 = longitude.toRadians()
val lat2 = asin(
sin(lat1) * cos(angularDistance) +
cos(lat1) * sin(angularDistance) * cos(bearingRadians)
)
val lon2 = lon1 + atan2(
sin(bearingRadians) * sin(angularDistance) * cos(lat1),
cos(angularDistance) - sin(lat1) * sin(lat2)
)
return PointFlightStartLocation(lat2.toDegrees(), lon2.toDegrees())
}
private fun List<MissionWaypoint>.toLogText(): String = private fun List<MissionWaypoint>.toLogText(): String =
joinToString(prefix = "[", postfix = "]") { joinToString(prefix = "[", postfix = "]") {
"${it.latitude},${it.longitude},${it.altitude}" "${it.latitude},${it.longitude},${it.altitude}"
@@ -1279,8 +1788,6 @@ private fun List<MissionWaypoint>.toLogText(): String =
private fun Double.toRadians(): Double = this / 180.0 * CoordPi private fun Double.toRadians(): Double = this / 180.0 * CoordPi
private fun Double.toDegrees(): Double = this / CoordPi * 180.0
private fun parseFlyToCommand(data: JSONObject): ParsedFlyToCommand? { private fun parseFlyToCommand(data: JSONObject): ParsedFlyToCommand? {
val target = data.optJSONObject("target_location") val target = data.optJSONObject("target_location")
?: data.optJSONObject("targetLocation") ?: data.optJSONObject("targetLocation")
@@ -1530,6 +2037,12 @@ private fun String.toLookAtMode(): LookAtMode =
else -> LookAtMode.LOOK_AT_GIMBAL_FREE else -> LookAtMode.LOOK_AT_GIMBAL_FREE
} }
private fun StickPosition.isNeutral(deadBand: Int = 0): Boolean =
abs(leftHorizontal) <= deadBand &&
abs(leftVertical) <= deadBand &&
abs(rightHorizontal) <= deadBand &&
abs(rightVertical) <= deadBand
private fun JSONObject.hasAny(vararg names: String): Boolean = private fun JSONObject.hasAny(vararg names: String): Boolean =
names.any { has(it) && !isNull(it) } names.any { has(it) && !isNull(it) }

View File

@@ -15,6 +15,7 @@ import dji.v5.manager.datacenter.livestream.LiveVideoBitrateMode
import dji.v5.manager.datacenter.livestream.StreamQuality import dji.v5.manager.datacenter.livestream.StreamQuality
import dji.v5.manager.datacenter.livestream.settings.RtmpSettings import dji.v5.manager.datacenter.livestream.settings.RtmpSettings
import dji.v5.manager.interfaces.ICameraStreamManager import dji.v5.manager.interfaces.ICameraStreamManager
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -146,17 +147,30 @@ class LiveStreamingService {
} }
_state.update { it.copy(busy = true, message = "正在关闭直播", lastError = null) } _state.update { it.copy(busy = true, message = "正在关闭直播", lastError = null) }
return suspendCancellableCoroutine { continuation -> return suspendCancellableCoroutine { continuation ->
val resumed = AtomicBoolean(false)
liveStreamManager().stopStream(object : CommonCallbacks.CompletionCallback { liveStreamManager().stopStream(object : CommonCallbacks.CompletionCallback {
override fun onSuccess() { override fun onSuccess() {
if (!resumed.compareAndSet(false, true)) {
Log.w(LiveStreamLogTag, "ignore duplicate stop stream success callback")
return
}
_state.update { it.copy(streaming = false, busy = false, message = "直播已关闭", lastError = null) } _state.update { it.copy(streaming = false, busy = false, message = "直播已关闭", lastError = null) }
if (continuation.isActive) {
continuation.resume(DjiCommandResult.ok("直播已关闭")) continuation.resume(DjiCommandResult.ok("直播已关闭"))
} }
}
override fun onFailure(error: IDJIError) { override fun onFailure(error: IDJIError) {
if (!resumed.compareAndSet(false, true)) {
Log.w(LiveStreamLogTag, "ignore duplicate stop stream failure callback: $error")
return
}
val message = error.toString() val message = error.toString()
_state.update { it.copy(busy = false, lastError = message, message = "关闭直播失败:$message") } _state.update { it.copy(busy = false, lastError = message, message = "关闭直播失败:$message") }
if (continuation.isActive) {
continuation.resume(DjiCommandResult.failed("关闭直播失败:$message")) continuation.resume(DjiCommandResult.failed("关闭直播失败:$message"))
} }
}
}) })
} }
} }
@@ -219,17 +233,30 @@ class LiveStreamingService {
) )
} }
return suspendCancellableCoroutine { continuation -> return suspendCancellableCoroutine { continuation ->
val resumed = AtomicBoolean(false)
manager.startStream(object : CommonCallbacks.CompletionCallback { manager.startStream(object : CommonCallbacks.CompletionCallback {
override fun onSuccess() { override fun onSuccess() {
if (!resumed.compareAndSet(false, true)) {
Log.w(LiveStreamLogTag, "ignore duplicate start stream success callback")
return
}
_state.update { it.copy(streaming = true, busy = false, message = "直播已开启", lastError = null) } _state.update { it.copy(streaming = true, busy = false, message = "直播已开启", lastError = null) }
if (continuation.isActive) {
continuation.resume(DjiCommandResult.ok("直播已开启")) continuation.resume(DjiCommandResult.ok("直播已开启"))
} }
}
override fun onFailure(error: IDJIError) { override fun onFailure(error: IDJIError) {
if (!resumed.compareAndSet(false, true)) {
Log.w(LiveStreamLogTag, "ignore duplicate start stream failure callback: $error")
return
}
val message = error.toString() val message = error.toString()
_state.update { it.copy(streaming = false, busy = false, lastError = message, message = "开启直播失败:$message") } _state.update { it.copy(streaming = false, busy = false, lastError = message, message = "开启直播失败:$message") }
if (continuation.isActive) {
continuation.resume(DjiCommandResult.failed("开启直播失败:$message")) continuation.resume(DjiCommandResult.failed("开启直播失败:$message"))
} }
}
}) })
} }
} }

View File

@@ -7,7 +7,21 @@ data class MissionPlan(
val takeoffHeight: Double = 20.0, val takeoffHeight: Double = 20.0,
val globalHeight: Double = 100.0, val globalHeight: Double = 100.0,
val finishAction: MissionFinishAction = MissionFinishAction.GoHome, val finishAction: MissionFinishAction = MissionFinishAction.GoHome,
val lostAction: MissionLostAction = MissionLostAction.GoBack val lostAction: MissionLostAction = MissionLostAction.GoBack,
val includeGimbalPitch: Boolean = true,
val droneInfo: MissionDroneInfo? = null,
val payloadInfo: List<MissionPayloadInfo> = emptyList()
)
data class MissionDroneInfo(
val enumValue: Int,
val subEnumValue: Int = 0
)
data class MissionPayloadInfo(
val enumValue: Int,
val subEnumValue: Int = 0,
val positionIndex: Int = 0
) )
data class MissionWaypoint( data class MissionWaypoint(

View File

@@ -1,15 +1,22 @@
package com.zklh.dronecontroller.core.mission package com.zklh.dronecontroller.core.mission
import android.util.Log
import com.zklh.dronecontroller.core.msdk.DjiCommandResult import com.zklh.dronecontroller.core.msdk.DjiCommandResult
import com.zklh.dronecontroller.core.safety.SafetyInterlock import com.zklh.dronecontroller.core.safety.SafetyInterlock
import dji.v5.common.callback.CommonCallbacks import dji.v5.common.callback.CommonCallbacks
import dji.v5.common.error.IDJIError import dji.v5.common.error.IDJIError
import dji.v5.manager.aircraft.waypoint3.WaylineExecutingInfoListener
import dji.v5.manager.aircraft.waypoint3.WaypointMissionManager import dji.v5.manager.aircraft.waypoint3.WaypointMissionManager
import dji.v5.manager.aircraft.waypoint3.model.WaylineExecutingInfo
import java.io.File import java.io.File
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
class WaypointMissionService { class WaypointMissionService {
init {
installWaypointDebugListenersOnce()
}
fun uploadKmzFile( fun uploadKmzFile(
kmzPath: String, kmzPath: String,
onUpdate: (MissionUploadUpdate) -> Unit onUpdate: (MissionUploadUpdate) -> Unit
@@ -118,6 +125,41 @@ class WaypointMissionService {
fun missionIdFromPath(path: String): String = File(path).nameWithoutExtension fun missionIdFromPath(path: String): String = File(path).nameWithoutExtension
private fun installWaypointDebugListenersOnce() {
synchronized(WaypointListenerLock) {
if (waypointDebugListenersInstalled) return
waypointDebugListenersInstalled = true
}
runCatching {
WaypointMissionManager.getInstance().addWaypointMissionExecuteStateListener { state ->
Log.d(WaypointMissionLogTag, "execute state=${state?.name ?: state}")
}
WaypointMissionManager.getInstance().addWaylineExecutingInfoListener(
object : WaylineExecutingInfoListener {
override fun onWaylineExecutingInfoUpdate(info: WaylineExecutingInfo) {
Log.d(
WaypointMissionLogTag,
"executing info mission=${info.missionFileName} waylineId=${info.waylineID} " +
"waypointIndex=${info.currentWaypointIndex}"
)
}
override fun onWaylineExecutingInterruptReasonUpdate(error: IDJIError?) {
if (error != null) {
Log.e(
WaypointMissionLogTag,
"executing interrupt code=${error.errorCode()} description=${error.description()} raw=$error"
)
}
}
}
)
Log.d(WaypointMissionLogTag, "waypoint debug listeners installed")
}.onFailure { error ->
Log.w(WaypointMissionLogTag, "install waypoint debug listeners failed: ${error.message}", error)
}
}
private fun callback( private fun callback(
continuation: kotlinx.coroutines.CancellableContinuation<DjiCommandResult>, continuation: kotlinx.coroutines.CancellableContinuation<DjiCommandResult>,
successMessage: String successMessage: String
@@ -132,4 +174,11 @@ class WaypointMissionService {
} }
} }
} }
private companion object {
private const val WaypointMissionLogTag = "ZklhWaypointMission"
private val WaypointListenerLock = Any()
@Volatile
private var waypointDebugListenersInstalled = false
}
} }

View File

@@ -21,6 +21,7 @@ import dji.sdk.wpmz.value.mission.WaylineAltitudeMode
import dji.sdk.wpmz.value.mission.WaylineCoordinateMode import dji.sdk.wpmz.value.mission.WaylineCoordinateMode
import dji.sdk.wpmz.value.mission.WaylineCoordinateParam import dji.sdk.wpmz.value.mission.WaylineCoordinateParam
import dji.sdk.wpmz.value.mission.WaylineDroneInfo import dji.sdk.wpmz.value.mission.WaylineDroneInfo
import dji.sdk.wpmz.value.mission.WaylineDroneType
import dji.sdk.wpmz.value.mission.WaylineExitOnRCLostAction import dji.sdk.wpmz.value.mission.WaylineExitOnRCLostAction
import dji.sdk.wpmz.value.mission.WaylineExitOnRCLostBehavior import dji.sdk.wpmz.value.mission.WaylineExitOnRCLostBehavior
import dji.sdk.wpmz.value.mission.WaylineFinishedAction import dji.sdk.wpmz.value.mission.WaylineFinishedAction
@@ -32,6 +33,7 @@ import dji.sdk.wpmz.value.mission.WaylineMission
import dji.sdk.wpmz.value.mission.WaylineMissionConfig import dji.sdk.wpmz.value.mission.WaylineMissionConfig
import dji.sdk.wpmz.value.mission.WaylinePayloadInfo import dji.sdk.wpmz.value.mission.WaylinePayloadInfo
import dji.sdk.wpmz.value.mission.WaylinePayloadParam import dji.sdk.wpmz.value.mission.WaylinePayloadParam
import dji.sdk.wpmz.value.mission.WaylinePayloadType
import dji.sdk.wpmz.value.mission.WaylinePositioningType import dji.sdk.wpmz.value.mission.WaylinePositioningType
import dji.sdk.wpmz.value.mission.WaylineTemplateWaypointInfo import dji.sdk.wpmz.value.mission.WaylineTemplateWaypointInfo
import dji.sdk.wpmz.value.mission.WaylineWaypoint import dji.sdk.wpmz.value.mission.WaylineWaypoint
@@ -65,13 +67,13 @@ class WpmlKmzBuilder {
return WaylineMissionConfig().apply { return WaylineMissionConfig().apply {
flyToWaylineMode = WaylineFlyToWaylineMode.SAFELY flyToWaylineMode = WaylineFlyToWaylineMode.SAFELY
finishAction = plan.finishAction.toDji() finishAction = plan.finishAction.toDji()
droneInfo = WaylineDroneInfo() droneInfo = plan.droneInfo.toDji()
securityTakeOffHeight = plan.takeoffHeight securityTakeOffHeight = plan.takeoffHeight
isSecurityTakeOffHeightSet = true isSecurityTakeOffHeightSet = true
exitOnRCLostBehavior = WaylineExitOnRCLostBehavior.EXCUTE_RC_LOST_ACTION exitOnRCLostBehavior = WaylineExitOnRCLostBehavior.EXCUTE_RC_LOST_ACTION
exitOnRCLostType = plan.lostAction.toDji() exitOnRCLostType = plan.lostAction.toDji()
globalTransitionalSpeed = plan.globalSpeed globalTransitionalSpeed = plan.globalSpeed
payloadInfo = ArrayList<WaylinePayloadInfo>() payloadInfo = ArrayList(plan.payloadInfo.map { it.toDji() })
} }
} }
@@ -117,7 +119,11 @@ class WpmlKmzBuilder {
) )
} }
isTemplateGlobalYawParamSet = true isTemplateGlobalYawParamSet = true
pitchMode = WaylineWaypointPitchMode.USE_POINT_SETTING pitchMode = if (plan.includeGimbalPitch) {
WaylineWaypointPitchMode.USE_POINT_SETTING
} else {
WaylineWaypointPitchMode.MANUALLY
}
} }
} }
@@ -133,7 +139,9 @@ class WpmlKmzBuilder {
ellipsoidHeight = point.altitude ellipsoidHeight = point.altitude
speed = point.speed ?: plan.globalSpeed speed = point.speed ?: plan.globalSpeed
useGlobalTurnParam = true useGlobalTurnParam = true
if (plan.includeGimbalPitch) {
gimbalPitchAngle = point.gimbalPitch gimbalPitchAngle = point.gimbalPitch
}
yawParam = WaylineWaypointYawParam().apply { yawParam = WaylineWaypointYawParam().apply {
enableYawAngle = point.heading != null enableYawAngle = point.heading != null
yawAngle = point.heading ?: 0.0 yawAngle = point.heading ?: 0.0
@@ -264,4 +272,22 @@ class WpmlKmzBuilder {
MissionLostAction.Hover -> WaylineExitOnRCLostAction.HOVER MissionLostAction.Hover -> WaylineExitOnRCLostAction.HOVER
} }
} }
private fun MissionDroneInfo?.toDji(): WaylineDroneInfo {
return WaylineDroneInfo().apply {
this@toDji?.let { info ->
droneType = WaylineDroneType.find(info.enumValue)
droneSubType = info.subEnumValue
}
}
}
private fun MissionPayloadInfo.toDji(): WaylinePayloadInfo {
return WaylinePayloadInfo().apply {
payloadType = WaylinePayloadType.find(enumValue)
payloadSubType = subEnumValue
payloadPositionIndex = positionIndex
isPayloadPositionIndexSet = true
}
}
} }

View File

@@ -995,7 +995,14 @@ class TelemetryRepository(
return return
} }
val source = state.rtkReferenceStationSource ?: return val source = state.rtkReferenceStationSource ?: return
if (!source.isNetworkRtkSource() || rtkServiceStarted) return if (!source.isNetworkRtkSource()) return
if (rtkServiceStarted) {
rtkServiceStarted = false
Log.d(
TelemetryLogTag,
"RTK network service became unhealthy; mark service stale and restart current source=$source"
)
}
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (rtkStartInProgress || now - lastRtkStartAtMs < RTK_START_RETRY_INTERVAL_MS) return if (rtkStartInProgress || now - lastRtkStartAtMs < RTK_START_RETRY_INTERVAL_MS) return
Log.d(TelemetryLogTag, "restore existing RTK network service source=$source") Log.d(TelemetryLogTag, "restore existing RTK network service source=$source")

View File

@@ -322,6 +322,8 @@ fun DroneControllerScreen() {
val liveStreaming = remember { LiveStreamingService() } val liveStreaming = remember { LiveStreamingService() }
val simulator = remember { SimulatorService() } val simulator = remember { SimulatorService() }
val warningRepository = remember { DroneWarningRepository() } val warningRepository = remember { DroneWarningRepository() }
val warningState by warningRepository.state.collectAsState()
val latestWarningState by rememberUpdatedState(warningState)
val cloudLoginClient = remember { CloudLoginClient() } val cloudLoginClient = remember { CloudLoginClient() }
val cloudMqttService = remember { CloudMqttService(cloudLoginClient) } val cloudMqttService = remember { CloudMqttService(cloudLoginClient) }
val cloudMediaUpload = remember(appContext, cloudMqttService) { val cloudMediaUpload = remember(appContext, cloudMqttService) {
@@ -337,12 +339,12 @@ fun DroneControllerScreen() {
waypointMission = waypointMission, waypointMission = waypointMission,
mediaUpload = cloudMediaUpload, mediaUpload = cloudMediaUpload,
telemetryProvider = { latestTelemetry }, telemetryProvider = { latestTelemetry },
warningProvider = { latestWarningState },
progressPublisher = cloudMqttService progressPublisher = cloudMqttService
) )
} }
val stickStatus by virtualStick.status.collectAsState() val stickStatus by virtualStick.status.collectAsState()
val simulatorStatus by simulator.status.collectAsState() val simulatorStatus by simulator.status.collectAsState()
val warningState by warningRepository.state.collectAsState()
val cloudState by cloudMqttService.state.collectAsState() val cloudState by cloudMqttService.state.collectAsState()
val liveState by liveStreaming.state.collectAsState() val liveState by liveStreaming.state.collectAsState()