This commit is contained in:
zyp
2026-07-09 18:19:02 +08:00
parent f632730452
commit 52b5861bed
14 changed files with 1841 additions and 324 deletions

View File

@@ -9,9 +9,11 @@ import com.zklh.dronecontroller.core.media.CameraControlService
import com.zklh.dronecontroller.core.media.CameraMediaService
import com.zklh.dronecontroller.core.mission.WaypointMissionService
import com.zklh.dronecontroller.core.msdk.DjiCommandResult
import com.zklh.dronecontroller.core.telemetry.TelemetrySnapshot
import com.zklh.dronecontroller.core.virtualstick.StickPosition
import com.zklh.dronecontroller.core.virtualstick.VirtualStickService
import dji.sdk.keyvalue.value.flightcontroller.FlyToMode
import dji.sdk.keyvalue.value.flightcontroller.LookAtMode
import dji.v5.manager.aircraft.virtualstick.Stick
import java.io.File
import java.net.URL
@@ -28,6 +30,10 @@ import org.json.JSONObject
private const val CloudCommandLogTag = "ZklhCloudCommand"
private const val DefaultFlyToHeightMeters = 20.0
private const val MinimumTakeoffTargetHeightMeters = 2.0
private const val CoordPi = 3.1415926535897932384626
private const val CoordSemiMajorAxis = 6378245.0
private const val CoordEccentricity = 0.00669342162296594323
class CloudCommandExecutor(
private val flightControl: FlightControlService,
@@ -37,12 +43,15 @@ class CloudCommandExecutor(
private val virtualStick: VirtualStickService,
private val waypointMission: WaypointMissionService,
private val cameraControl: CameraControlService = CameraControlService(),
private val gimbalControl: GimbalControlService = GimbalControlService()
private val gimbalControl: GimbalControlService = GimbalControlService(),
private val mediaUpload: CloudMediaUploadService? = null,
private val telemetryProvider: () -> TelemetrySnapshot = { TelemetrySnapshot() }
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val virtualStickEnabling = AtomicBoolean(false)
private val preparedMissionIds = ConcurrentHashMap<String, String>()
private var lastStickAt = 0L
private var lastStickLogAt = 0L
private var stickTimeoutJob: Job? = null
suspend fun execute(request: CloudCommandRequest): DjiCommandResult {
@@ -73,8 +82,9 @@ class CloudCommandExecutor(
command == "drc_mode_enter" -> enterDrcMode(data)
command == "drc_mode_exit" -> exitDrcMode()
command in StickCommands -> executeStickControl(data)
command in TakeoffToPointCommands -> startFlyTo(data)
command in TakeoffCommands && data.hasFlyToTarget() -> startFlyTo(data)
command in TakeoffCommands -> flightControl.startTakeoff()
command in TakeoffCommands -> startTakeoff(data)
command in StopTakeoffCommands -> flightControl.stopTakeoff()
command in GoHomeCommands -> flightControl.startGoHome()
command in StopGoHomeCommands -> flightControl.stopGoHome()
@@ -82,23 +92,33 @@ class CloudCommandExecutor(
command in ForceLandingCommands -> forceLanding()
command in StopLandingCommands -> flightControl.stopAutoLanding()
command in ConfirmLandingCommands -> flightControl.confirmLanding()
command in PhotoCommands -> cameraMedia.takePhoto()
command in PhotoCommands -> takePhotoAndUpload()
command in RecordStartCommands -> cameraMedia.startRecord()
command in RecordStopCommands -> cameraMedia.stopRecord()
command in RecordToggleCommands -> cameraMedia.toggleRecord()
command in RecordStopCommands -> stopRecordAndUpload()
command in RecordToggleCommands -> toggleRecordAndMaybeUpload()
command in CameraModeCommands -> setCameraMode(data)
command in PanoramaCommands -> cameraMedia.shootPanorama()
command in LaserFillLightCommands -> setLaserFillLight(data)
command in LaserMeasureCommands -> setLaserMeasure(data)
command in FlyToUpdateCommands -> updateFlyTo(data)
command in FlyToCommands -> startFlyTo(data)
command in StopFlyToCommands -> flyToService.stopFlyTo()
command in StopFlyToCommands -> stopFlyTo()
command in WaylinePrepareCommands -> prepareWayline(data)
command in WaylineExecuteCommands -> executeWayline(data)
command in WaylinePauseCommands -> waypointMission.pauseMission()
command in WaylineRecoveryCommands -> waypointMission.resumeMission()
command in WaylineUndoCommands -> stopWayline(data)
command in CameraZoomCommands -> setCameraZoom(data)
command in LinkageZoomCommands -> setLinkageZoom(data)
command in LensSwitchCommands -> switchLens(data)
command in CameraLookAtCommands -> lookAt(data)
command in CameraAimCommands -> cameraAim(data)
command in CameraFrameZoomCommands -> frameZoom(data)
command in CameraExposureCommands -> setCameraExposure(data)
command in CameraExposureModeCommands -> setCameraExposureMode(data)
command in CameraFocusModeCommands -> setCameraFocusMode(data)
command in CameraScreenSplitCommands -> setCameraScreenSplit(data)
command in CameraNightModeCommands -> setCameraNightMode(data)
command in GimbalDragCommands -> rotateGimbal(data)
command in GimbalResetCommands -> gimbalControl.reset()
command in LiveStartCommands -> liveStreaming.startFromCloud(data)
@@ -109,6 +129,7 @@ class CloudCommandExecutor(
}
private suspend fun enterDrcMode(data: JSONObject): DjiCommandResult {
stopFlyToForManualControl()
val result = ensureVirtualStickEnabled()
if (result.success) {
virtualStick.setSpeedLevel(data.optDoubleAny("speed_level", "speedLevel").takeIf { it > 0.0 } ?: 15.0)
@@ -136,6 +157,7 @@ class CloudCommandExecutor(
private suspend fun executeStickControl(
data: JSONObject
): DjiCommandResult {
stopFlyToForManualControl()
val enabled = ensureVirtualStickEnabled()
if (!enabled.success) return enabled
val speedLevel = data.optDoubleAny("speed_level", "speedLevel", "speed")
@@ -149,12 +171,48 @@ class CloudCommandExecutor(
} else {
data.toProtocolStickPosition()
}
val now = System.currentTimeMillis()
if (now - lastStickLogAt > 500L) {
Log.d(
CloudCommandLogTag,
"stick_control data=$data position=$position virtualStickEnabled=${virtualStick.status.value.enabled}"
)
lastStickLogAt = now
}
virtualStick.sendStickPosition(position)
lastStickAt = System.currentTimeMillis()
lastStickAt = now
scheduleStickTimeout()
return DjiCommandResult.ok("杆量控制已下发")
}
private suspend fun stopFlyToForManualControl() {
if (!flyToService.isMissionActive()) return
val stopResult = flyToService.stopFlyTo()
if (!stopResult.success) {
Log.w(CloudCommandLogTag, "stop flyTo before manual control failed: ${stopResult.message}")
}
}
private suspend fun startTakeoff(data: JSONObject): DjiCommandResult {
val targetHeight = data.optDoubleAnyOrNull(
"targetHeight",
"target_height",
"height",
"altitude",
"alt",
"commander_flight_height",
"commanderFlightHeight"
)?.takeIf { it > MinimumTakeoffTargetHeightMeters }
if (targetHeight == null) {
return flightControl.startTakeoff()
}
return DjiCommandResult.failed(
"一键起飞带目标高度时必须同时下发经纬度,请使用 takeoff_to_point/fly_to_pointMSDK 端不再用虚拟杆兜底爬升"
)
}
private fun scheduleStickTimeout() {
if (stickTimeoutJob?.isActive == true) return
stickTimeoutJob = scope.launch {
@@ -185,6 +243,36 @@ class CloudCommandExecutor(
}
}
private suspend fun takePhotoAndUpload(): DjiCommandResult {
val capture = cameraMedia.takePhoto()
if (!capture.success) return capture
return appendMediaUploadResult(capture)
}
private suspend fun stopRecordAndUpload(): DjiCommandResult {
val stop = cameraMedia.stopRecord()
if (!stop.success) return stop
return appendMediaUploadResult(stop)
}
private suspend fun toggleRecordAndMaybeUpload(): DjiCommandResult {
val shouldStop = cameraMedia.isRecording()
val result = if (shouldStop) cameraMedia.stopRecord() else cameraMedia.startRecord()
if (!result.success || !shouldStop) return result
return appendMediaUploadResult(result)
}
private suspend fun appendMediaUploadResult(captureResult: DjiCommandResult): DjiCommandResult {
val uploader = mediaUpload
?: return DjiCommandResult.ok("${captureResult.message};未配置平台上传")
val upload = uploader.uploadLatestMedia(cameraMedia.currentCameraIndex())
return if (upload.success) {
DjiCommandResult.ok("${captureResult.message}${upload.message}")
} else {
DjiCommandResult.failed("${captureResult.message}${upload.message}")
}
}
private suspend fun setLaserFillLight(data: JSONObject): DjiCommandResult {
if (!data.hasAny("enable", "enabled", "ir_fill_light_enable", "irFillLightEnable")) {
return cameraMedia.toggleLaserFillLight()
@@ -200,52 +288,55 @@ class CloudCommandExecutor(
}
private suspend fun startFlyTo(data: JSONObject): DjiCommandResult {
val target = data.optJSONObject("target_location")
?: data.optJSONObject("targetLocation")
?: data.optJSONObject("target_point")
?: data.optJSONObject("targetPoint")
?: data.optJSONObject("target")
?: data.optJSONObject("location")
?: data.optJSONObject("position")
?: data.firstPoint()
?: data
val latitude = target.optDoubleAnyOrNull("latitude", "lat", "target_latitude", "targetLatitude")
?: data.optDoubleAnyOrNull("targetLatitude", "target_latitude", "latitude", "lat")
?: 0.0
val longitude = target.optDoubleAnyOrNull("longitude", "lng", "lon", "target_longitude", "targetLongitude")
?: data.optDoubleAnyOrNull("targetLongitude", "target_longitude", "longitude", "lng", "lon")
?: 0.0
val requestedHeight = target.optDoubleAnyOrNull("height", "altitude", "alt", "target_height", "targetHeight", "target_altitude")
?: data.optDoubleAnyOrNull("targetHeight", "target_height", "height", "altitude", "alt", "commander_flight_height")
val fallbackHeight = data.optDoubleAnyOrNull("security_takeoff_height", "securityTakeoffHeight", "safe_height", "safeHeight")
?.takeIf { it > 0.0 }
?: DefaultFlyToHeightMeters
val height = requestedHeight?.takeIf { it > 0.0 } ?: fallbackHeight
val maxSpeed = target.optIntAny(0, "max_speed", "maxSpeed")
.takeIf { it > 0 }
?: data.optIntAny(15, "max_speed", "maxSpeed")
val securityTakeoffHeight = data.optIntAny(20, "security_takeoff_height", "securityTakeoffHeight")
val flyToMode = data.optStringAny("fly_to_mode", "flyToMode")
.toFlyToMode(defaultMode = FlyToMode.SET_HEIGHT)
if (latitude == 0.0 || longitude == 0.0) {
val command = parseFlyToCommand(data)
if (command == null) {
return DjiCommandResult.failed("指点飞行参数无效:需要 latitude、longitude")
}
Log.d(
CloudCommandLogTag,
"startFlyTo lat=$latitude lon=$longitude height=$height requestedHeight=$requestedHeight maxSpeed=$maxSpeed securityTakeoffHeight=$securityTakeoffHeight flyToMode=$flyToMode data=$data"
"startFlyTo rawLat=${command.rawLatitude} rawLon=${command.rawLongitude} lat=${command.latitude} lon=${command.longitude} " +
"coordType=${command.coordinateType} height=${command.height} requestedHeight=${command.requestedHeight} " +
"maxSpeed=${command.maxSpeed} securityTakeoffHeight=${command.securityTakeoffHeight} flyToMode=${command.flyToMode} data=$data"
)
val releaseResult = releaseVirtualStickForAutonomousFlight()
if (!releaseResult.success) {
Log.w(CloudCommandLogTag, "release virtual stick before flyTo failed: ${releaseResult.message}")
}
return flyToService.startFlyTo(
latitude = latitude,
longitude = longitude,
height = height,
maxSpeed = maxSpeed,
securityTakeoffHeight = securityTakeoffHeight,
flyToMode = flyToMode
val result = flyToService.startFlyTo(
latitude = command.latitude,
longitude = command.longitude,
height = command.height,
maxSpeed = command.maxSpeed,
securityTakeoffHeight = command.securityTakeoffHeight,
flyToMode = command.flyToMode
)
return result
}
private suspend fun updateFlyTo(data: JSONObject): DjiCommandResult {
val command = parseFlyToCommand(data)
?: return DjiCommandResult.failed("更新指点飞行参数无效:需要 latitude、longitude")
if (!flyToService.isMissionActive()) {
Log.d(CloudCommandLogTag, "flyTo update received without active mission, start instead")
return startFlyTo(data)
}
val target = flyToService.updateFlyToTarget(
latitude = command.latitude,
longitude = command.longitude,
height = command.height,
maxSpeed = command.maxSpeed,
securityTakeoffHeight = command.securityTakeoffHeight
)
if (!target.success) return target
val param = flyToService.updateFlyToParam(
height = command.height.roundToInt().coerceAtLeast(1),
mode = command.flyToMode
)
return if (param.success) {
DjiCommandResult.ok("${target.message}${param.message}")
} else {
DjiCommandResult.failed("${target.message}${param.message}")
}
}
private suspend fun releaseVirtualStickForAutonomousFlight(): DjiCommandResult {
@@ -257,6 +348,11 @@ class CloudCommandExecutor(
}
}
private suspend fun stopFlyTo(): DjiCommandResult {
sendNeutralStick()
return flyToService.stopFlyTo()
}
private suspend fun setCameraMode(data: JSONObject): DjiCommandResult {
if (data.hasAny("camera_mode", "cameraMode")) {
return cameraMedia.setCloudCameraMode(data.optIntAny(-1, "camera_mode", "cameraMode"))
@@ -330,6 +426,15 @@ class CloudCommandExecutor(
}
}
private suspend fun setLinkageZoom(data: JSONObject): DjiCommandResult {
val enabled = when {
data.hasAny("state") -> data.optIntAny(0, "state") != 0
data.hasAny("enable", "enabled") -> data.optBooleanAny("enable", "enabled")
else -> return DjiCommandResult.failed("联动变焦参数无效:缺少 state/enable")
}
return cameraControl.setLinkZoomEnabled(enabled)
}
private suspend fun switchLens(data: JSONObject): DjiCommandResult {
val lens = data.optStringAny("lens", "camera_type", "cameraType", "video_type", "videoType", "type")
.ifBlank {
@@ -341,6 +446,84 @@ class CloudCommandExecutor(
return cameraControl.setLens(lens)
}
private suspend fun lookAt(data: JSONObject): DjiCommandResult {
val command = parseFlyToCommand(data)
?: return DjiCommandResult.failed("看向目标点参数无效:需要 latitude、longitude、height")
val mode = data.optStringAny("look_at_mode", "lookAtMode", "mode")
.toLookAtMode()
return flightControl.lookAt(command.latitude, command.longitude, command.height, mode)
}
private suspend fun cameraAim(data: JSONObject): DjiCommandResult {
val cameraType = data.optStringAny("camera_type", "cameraType", "lens", "video_type", "videoType")
return cameraControl.aimAt(
cameraType = cameraType,
locked = data.optBooleanAny("locked", "lock_gimbal", "lockGimbal"),
x = data.optDoubleAny("x"),
y = data.optDoubleAny("y")
)
}
private suspend fun frameZoom(data: JSONObject): DjiCommandResult {
val cameraType = data.optStringAny("camera_type", "cameraType", "lens", "video_type", "videoType")
val x = data.optDoubleAny("x")
val y = data.optDoubleAny("y")
val width = data.optDoubleAny("width")
.takeIf { it > 0.0 }
?: data.optDoubleAny("length").takeIf { it > 0.0 }
?: return DjiCommandResult.failed("框选变焦参数无效:缺少 width")
val height = data.optDoubleAny("height")
.takeIf { it > 0.0 }
?: width
return cameraControl.frameZoom(
cameraType = cameraType,
locked = data.optBooleanAny("locked", "lock_gimbal", "lockGimbal"),
x = x,
y = y,
width = width,
height = height
)
}
private suspend fun setCameraExposure(data: JSONObject): DjiCommandResult {
val value = data.optIntAny(Int.MIN_VALUE, "exposure_value", "exposureValue")
if (value == Int.MIN_VALUE) return DjiCommandResult.failed("曝光补偿参数无效:缺少 exposure_value")
return cameraControl.setExposureCompensation(
cameraType = data.optStringAny("camera_type", "cameraType", "lens"),
value = value
)
}
private suspend fun setCameraExposureMode(data: JSONObject): DjiCommandResult {
val mode = data.optIntAny(Int.MIN_VALUE, "exposure_mode", "exposureMode", "mode")
if (mode == Int.MIN_VALUE) return DjiCommandResult.failed("曝光模式参数无效:缺少 exposure_mode")
return cameraControl.setExposureMode(
cameraType = data.optStringAny("camera_type", "cameraType", "lens"),
mode = mode
)
}
private suspend fun setCameraFocusMode(data: JSONObject): DjiCommandResult {
val mode = data.optIntAny(Int.MIN_VALUE, "focus_mode", "focusMode", "mode")
if (mode == Int.MIN_VALUE) return DjiCommandResult.failed("对焦模式参数无效:缺少 focus_mode")
return cameraControl.setFocusMode(
cameraType = data.optStringAny("camera_type", "cameraType", "lens"),
mode = mode
)
}
private suspend fun setCameraScreenSplit(data: JSONObject): DjiCommandResult =
cameraControl.setThermalDisplaySplit(data.optBooleanAny("enable", "enabled", "screen_split_enable", "screenSplitEnable"))
private suspend fun setCameraNightMode(data: JSONObject): DjiCommandResult {
val mode = when {
data.hasAny("mode") -> data.optIntAny(0, "mode")
data.hasAny("enable", "enabled") -> if (data.optBooleanAny("enable", "enabled")) 1 else 0
else -> return DjiCommandResult.failed("夜景模式参数无效:缺少 mode/enable")
}
return cameraControl.setNightSceneMode(mode)
}
private suspend fun rotateGimbal(data: JSONObject): DjiCommandResult {
val pitchSpeed = data.optDoubleAny("pitch_speed", "pitchSpeed", "gimbal_pitch_speed", "gimbalPitchSpeed")
val yawSpeed = data.optDoubleAny("yaw_speed", "yawSpeed", "gimbal_yaw_speed", "gimbalYawSpeed")
@@ -348,6 +531,79 @@ class CloudCommandExecutor(
}
}
private data class ParsedFlyToCommand(
val rawLatitude: Double,
val rawLongitude: Double,
val latitude: Double,
val longitude: Double,
val coordinateType: String,
val requestedHeight: Double?,
val height: Double,
val maxSpeed: Int,
val securityTakeoffHeight: Int,
val flyToMode: FlyToMode
)
private fun parseFlyToCommand(data: JSONObject): ParsedFlyToCommand? {
val target = data.optJSONObject("target_location")
?: data.optJSONObject("targetLocation")
?: data.optJSONObject("target_point")
?: data.optJSONObject("targetPoint")
?: data.optJSONObject("target")
?: data.optJSONObject("location")
?: data.optJSONObject("position")
?: data.firstPoint()
?: data
val rawLatitude = target.optDoubleAnyOrNull("latitude", "lat", "target_latitude", "targetLatitude")
?: data.optDoubleAnyOrNull("targetLatitude", "target_latitude", "latitude", "lat")
?: 0.0
val rawLongitude = target.optDoubleAnyOrNull("longitude", "lng", "lon", "target_longitude", "targetLongitude")
?: data.optDoubleAnyOrNull("targetLongitude", "target_longitude", "longitude", "lng", "lon")
?: 0.0
val coordinateType = data.optStringAny(
"coordinate_type",
"coordinateType",
"coord_type",
"coordType",
"coordinate_system",
"coordinateSystem"
)
val (latitude, longitude) = convertCloudTargetCoordinate(rawLatitude, rawLongitude, coordinateType)
if (!isValidCoordinate(latitude, longitude)) return null
val requestedHeight = target.optDoubleAnyOrNull("height", "altitude", "alt", "target_height", "targetHeight", "target_altitude")
?: data.optDoubleAnyOrNull(
"targetHeight",
"target_height",
"height",
"altitude",
"alt",
"commander_flight_height",
"commanderFlightHeight"
)
val fallbackHeight = data.optDoubleAnyOrNull("security_takeoff_height", "securityTakeoffHeight", "safe_height", "safeHeight")
?.takeIf { it > 0.0 }
?: DefaultFlyToHeightMeters
val height = requestedHeight?.takeIf { it > 0.0 } ?: fallbackHeight
val maxSpeed = target.optIntAny(0, "max_speed", "maxSpeed")
.takeIf { it > 0 }
?: data.optIntAny(15, "max_speed", "maxSpeed")
.coerceIn(1, 15)
val securityTakeoffHeight = data.optIntAny(20, "security_takeoff_height", "securityTakeoffHeight", "safe_height", "safeHeight")
return ParsedFlyToCommand(
rawLatitude = rawLatitude,
rawLongitude = rawLongitude,
latitude = latitude,
longitude = longitude,
coordinateType = coordinateType,
requestedHeight = requestedHeight,
height = height,
maxSpeed = maxSpeed,
securityTakeoffHeight = securityTakeoffHeight,
flyToMode = data.toFlyToMode(defaultMode = FlyToMode.SET_HEIGHT)
)
}
private val PassiveAckCommands = setOf(
"flight_authority_grab",
"payload_authority_grab",
@@ -357,6 +613,7 @@ private val PassiveAckCommands = setOf(
"drc_initial_state_subscribe"
)
private val StickCommands = setOf("stick_control", "drone_control", "drc_drone_stick_control")
private val TakeoffToPointCommands = setOf("takeoff_to_point")
private val TakeoffCommands = setOf("start_takeoff", "takeoff", "take_off", "auto_takeoff")
private val StopTakeoffCommands = setOf("stop_takeoff", "cancel_takeoff")
private val GoHomeCommands = setOf("start_go_home", "go_home", "return_home", "start_rth", "return_auto")
@@ -378,15 +635,25 @@ private val LaserFillLightCommands = setOf(
"drc_infrared_fill_light_enable"
)
private val LaserMeasureCommands = setOf("laser_measure", "toggle_laser_measure")
private val FlyToCommands = setOf("fly_to_point", "fly_to_point_update", "takeoff_to_point", "fly_to", "go_to_point", "gotargetpoint")
private val FlyToCommands = setOf("fly_to_point", "fly_to", "go_to_point", "gotargetpoint")
private val FlyToUpdateCommands = setOf("fly_to_point_update", "flytopointtaskupdate")
private val StopFlyToCommands = setOf("stop_fly_to", "cancel_fly_to", "fly_to_point_stop")
private val WaylinePrepareCommands = setOf("flighttask_prepare", "wayline_prepare")
private val WaylineExecuteCommands = setOf("flighttask_execute", "wayline_execute", "start_wayline")
private val WaylinePauseCommands = setOf("flighttask_pause", "wayline_pause")
private val WaylineRecoveryCommands = setOf("flighttask_recovery", "wayline_resume", "wayline_recovery")
private val WaylineUndoCommands = setOf("flighttask_undo", "wayline_stop", "stop_wayline")
private val CameraZoomCommands = setOf("camera_focal_length_set", "drc_camera_focal_length_set", "drc_linkage_zoom_set", "linkage_zoom_set")
private val LensSwitchCommands = setOf("camera_lens_change", "lens_change", "live_lens_change", "camera_video_stream_source_set", "camera_screen_split", "drc_camera_screen_split")
private val CameraZoomCommands = setOf("camera_focal_length_set", "drc_camera_focal_length_set")
private val LinkageZoomCommands = setOf("drc_linkage_zoom_set", "linkage_zoom_set")
private val LensSwitchCommands = setOf("camera_lens_change", "lens_change", "live_lens_change", "camera_video_stream_source_set")
private val CameraLookAtCommands = setOf("camera_look_at", "drc_camera_look_at")
private val CameraAimCommands = setOf("camera_aim", "drc_camera_aim")
private val CameraFrameZoomCommands = setOf("camera_frame_zoom", "drc_camera_frame_zoom")
private val CameraExposureCommands = setOf("camera_exposure_set", "drc_camera_exposure_set")
private val CameraExposureModeCommands = setOf("camera_exposure_mode_set", "drc_camera_exposure_mode_set")
private val CameraFocusModeCommands = setOf("camera_focus_mode_set", "drc_camera_focus_mode_set")
private val CameraScreenSplitCommands = setOf("camera_screen_split", "drc_camera_screen_split")
private val CameraNightModeCommands = setOf("drc_camera_night_mode_set", "drc_camera_night_vision_enable", "camera_night_mode_set")
private val GimbalDragCommands = setOf("camera_screen_drag", "drc_camera_screen_drag", "gimbal_rotate_by_speed")
private val GimbalResetCommands = setOf("gimbal_reset", "drc_gimbal_reset")
private val LiveStartCommands = setOf("live_start_push", "live_start", "start_live", "start_livestream")
@@ -456,6 +723,18 @@ private fun JSONObject.optBooleanAny(vararg names: String): Boolean {
return false
}
private fun JSONObject.toFlyToMode(defaultMode: FlyToMode): FlyToMode {
val modeText = optStringAny("fly_to_mode", "flyToMode", "mode")
if (modeText.isNotBlank()) {
return modeText.toFlyToMode(defaultMode)
}
return when (optIntAny(-1, "commander_flight_mode", "commanderFlightMode")) {
0 -> FlyToMode.SMART_HEIGHT
1 -> FlyToMode.SET_HEIGHT
else -> defaultMode
}
}
private fun String.toFlyToMode(defaultMode: FlyToMode): FlyToMode =
when (normalizeCloudCommand()) {
"smart_height", "smartheight", "smart" -> FlyToMode.SMART_HEIGHT
@@ -463,9 +742,78 @@ private fun String.toFlyToMode(defaultMode: FlyToMode): FlyToMode =
else -> defaultMode
}
private fun String.toLookAtMode(): LookAtMode =
when (normalizeCloudCommand()) {
"following", "follow", "gimbal_following", "look_at_gimbal_following", "1" -> LookAtMode.LOOK_AT_GIMBAL_FOLLOWING
"zoom_circle", "circle", "look_at_zoom_circle", "2" -> LookAtMode.LOOK_AT_ZOOM_CIRCLE
else -> LookAtMode.LOOK_AT_GIMBAL_FREE
}
private fun JSONObject.hasAny(vararg names: String): Boolean =
names.any { has(it) && !isNull(it) }
private fun isValidCoordinate(latitude: Double, longitude: Double): Boolean =
!latitude.isNaN() &&
!longitude.isNaN() &&
!(latitude == 0.0 && longitude == 0.0) &&
latitude in -90.0..90.0 &&
longitude in -180.0..180.0
private fun convertCloudTargetCoordinate(
latitude: Double,
longitude: Double,
coordinateType: String
): Pair<Double, Double> {
val normalized = coordinateType.normalizeCloudCommand()
return if (normalized in setOf("gcj02", "gcj_02", "gcj-02", "mars")) {
gcj02ToWgs84(latitude, longitude)
} else {
latitude to longitude
}
}
private fun gcj02ToWgs84(latitude: Double, longitude: Double): Pair<Double, Double> {
if (isOutOfChina(latitude, longitude)) return latitude to longitude
val dLat = transformLatitude(longitude - 105.0, latitude - 35.0)
val dLon = transformLongitude(longitude - 105.0, latitude - 35.0)
val radLat = latitude / 180.0 * CoordPi
var magic = kotlin.math.sin(radLat)
magic = 1 - CoordEccentricity * magic * magic
val sqrtMagic = kotlin.math.sqrt(magic)
val adjustedLat = (dLat * 180.0) / ((CoordSemiMajorAxis * (1 - CoordEccentricity)) / (magic * sqrtMagic) * CoordPi)
val adjustedLon = (dLon * 180.0) / (CoordSemiMajorAxis / sqrtMagic * kotlin.math.cos(radLat) * CoordPi)
val mgLat = latitude + adjustedLat
val mgLon = longitude + adjustedLon
return (latitude * 2 - mgLat) to (longitude * 2 - mgLon)
}
private fun isOutOfChina(latitude: Double, longitude: Double): Boolean =
longitude < 72.004 ||
longitude > 137.8347 ||
latitude < 0.8293 ||
latitude > 55.8271
private fun transformLatitude(longitude: Double, latitude: Double): Double {
var value = -100.0 + 2.0 * longitude + 3.0 * latitude + 0.2 * latitude * latitude +
0.1 * longitude * latitude + 0.2 * kotlin.math.sqrt(kotlin.math.abs(longitude))
value += (20.0 * kotlin.math.sin(6.0 * longitude * CoordPi) + 20.0 * kotlin.math.sin(2.0 * longitude * CoordPi)) * 2.0 / 3.0
value += (20.0 * kotlin.math.sin(latitude * CoordPi) + 40.0 * kotlin.math.sin(latitude / 3.0 * CoordPi)) * 2.0 / 3.0
value += (160.0 * kotlin.math.sin(latitude / 12.0 * CoordPi) + 320.0 * kotlin.math.sin(latitude * CoordPi / 30.0)) * 2.0 / 3.0
return value
}
private fun transformLongitude(longitude: Double, latitude: Double): Double {
var value = 300.0 + longitude + 2.0 * latitude + 0.1 * longitude * longitude +
0.1 * longitude * latitude + 0.1 * kotlin.math.sqrt(kotlin.math.abs(longitude))
value += (20.0 * kotlin.math.sin(6.0 * longitude * CoordPi) + 20.0 * kotlin.math.sin(2.0 * longitude * CoordPi)) * 2.0 / 3.0
value += (20.0 * kotlin.math.sin(longitude * CoordPi) + 40.0 * kotlin.math.sin(longitude / 3.0 * CoordPi)) * 2.0 / 3.0
value += (150.0 * kotlin.math.sin(longitude / 12.0 * CoordPi) + 300.0 * kotlin.math.sin(longitude / 30.0 * CoordPi)) * 2.0 / 3.0
return value
}
private fun Double.formatMeters(): String =
"%.1f".format(this)
private fun JSONObject.firstPoint(): JSONObject? {
val points = optJSONArray("points") ?: return null
if (points.length() <= 0) return null

View File

@@ -40,10 +40,13 @@ data class CloudCommandRequest(
companion object {
fun parse(topic: String, payload: String): CloudCommandRequest {
val request = JSONObject(payload)
val data = request.optJSONObject("data") ?: JSONObject()
val rootData = request.optJSONObject("data") ?: JSONObject()
val data = rootData.unwrapNestedCommandData()
val method = request.optString("method")
.ifBlank { data.optString("method") }
.ifBlank { rootData.optString("method") }
.ifBlank { request.optString("cmd") }
.ifBlank { rootData.optString("cmd") }
.ifBlank { data.optString("method") }
.ifBlank { data.optString("cmd") }
return CloudCommandRequest(
topic = topic,
@@ -53,7 +56,7 @@ data class CloudCommandRequest(
data = data,
tid = request.optString("tid"),
bid = request.optString("bid"),
seq = request.optLongOrNull("seq") ?: data.optLongOrNull("seq")
seq = request.optLongOrNull("seq") ?: rootData.optLongOrNull("seq") ?: data.optLongOrNull("seq")
)
}
}
@@ -87,3 +90,15 @@ fun String.normalizeCloudCommand(): String =
private fun JSONObject.optLongOrNull(name: String): Long? =
if (has(name) && !isNull(name)) optLong(name) else null
private fun JSONObject.unwrapNestedCommandData(): JSONObject {
val nested = optJSONObject("data") ?: return this
if (!has("cmd") && !has("method")) return this
val merged = JSONObject(nested.toString())
for (field in SnFields + listOf("seq", "payload_index", "payloadIndex", "cmd", "method")) {
if (has(field) && !isNull(field) && !merged.has(field)) {
merged.put(field, opt(field))
}
}
return merged
}

View File

@@ -0,0 +1,305 @@
package com.zklh.dronecontroller.core.cloud
import android.content.Context
import android.util.Log
import com.zklh.dronecontroller.core.msdk.DjiCommandResult
import dji.sdk.keyvalue.value.common.ComponentIndexType
import dji.v5.common.callback.CommonCallbacks
import dji.v5.common.error.IDJIError
import dji.v5.manager.datacenter.MediaDataCenter
import dji.v5.manager.datacenter.media.MediaFile
import dji.v5.manager.datacenter.media.MediaFileDownloadListener
import dji.v5.manager.datacenter.media.MediaFileListDataSource
import dji.v5.manager.datacenter.media.PullMediaFileListParam
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.DataOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URI
import java.util.UUID
import kotlin.coroutines.resume
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONObject
private const val CloudMediaUploadLogTag = "ZklhCloudMediaUpload"
private const val MediaFetchTimeoutMs = 25_000L
private const val MediaDownloadMinTimeoutMs = 120_000L
private const val MediaDownloadMaxTimeoutMs = 600_000L
private const val UploadReadTimeoutMs = 600_000
private const val PlatformHost = "uav.zklhjs.com"
private const val PlatformFallbackIp = "221.226.33.58"
class CloudMediaUploadService(
context: Context,
private val sessionProvider: () -> CloudSession?
) {
private val appContext = context.applicationContext
suspend fun uploadLatestMedia(
cameraIndex: ComponentIndexType = ComponentIndexType.LEFT_OR_MAIN
): DjiCommandResult = withContext(Dispatchers.IO) {
val session = sessionProvider()
?: return@withContext DjiCommandResult.failed("第三方云未登录,媒体未上传平台")
if (session.accessToken.isBlank()) {
return@withContext DjiCommandResult.failed("第三方云登录态缺少 token媒体未上传平台")
}
val enableResult = enableMediaManager()
if (!enableResult.success) {
return@withContext DjiCommandResult.failed("媒体管理模块开启失败,媒体未上传平台:${enableResult.message}")
}
val localFileResult = try {
val mediaFile = withTimeoutOrNull(MediaFetchTimeoutMs) {
fetchLatestMediaFile(cameraIndex)
} ?: return@withContext DjiCommandResult.failed("相机文件列表拉取超时,媒体未上传平台")
Log.i(
CloudMediaUploadLogTag,
"latest media selected camera=$cameraIndex name=${mediaFile.fileName} index=${mediaFile.fileIndex} size=${mediaFile.fileSize}"
)
downloadOriginalMedia(mediaFile)
} finally {
disableMediaManager()
}
val localFile = localFileResult.getOrElse { error ->
return@withContext DjiCommandResult.failed("媒体文件已在飞机存储,但下载到遥控器失败:${error.message ?: error.javaClass.simpleName}")
}
uploadMultipart(session, localFile).fold(
onSuccess = { platformId ->
DjiCommandResult.ok("媒体已上传平台:${localFile.name}平台文件ID$platformId")
},
onFailure = { error ->
DjiCommandResult.failed("媒体已下载到遥控器,但上传平台失败:${error.message ?: error.javaClass.simpleName}")
}
)
}
private suspend fun fetchLatestMediaFile(cameraIndex: ComponentIndexType): MediaFile? {
val manager = MediaDataCenter.getInstance().mediaManager
val source = MediaFileListDataSource.Builder().setIndexType(cameraIndex).build()
manager.setMediaFileDataSource(source)
val pullResult = pullMediaFileList()
return if (!pullResult.success) {
Log.w(CloudMediaUploadLogTag, "pull media list failed: ${pullResult.message}")
null
} else {
waitLatestMediaFile()
}
}
private suspend fun enableMediaManager(): DjiCommandResult =
suspendCancellableCoroutine { continuation ->
MediaDataCenter.getInstance().mediaManager.enable(object : CommonCallbacks.CompletionCallback {
override fun onSuccess() {
continuation.resume(DjiCommandResult.ok("媒体管理已开启"))
}
override fun onFailure(error: IDJIError) {
continuation.resume(DjiCommandResult.failed(error))
}
})
}
private suspend fun disableMediaManager(): DjiCommandResult =
suspendCancellableCoroutine { continuation ->
MediaDataCenter.getInstance().mediaManager.disable(object : CommonCallbacks.CompletionCallback {
override fun onSuccess() {
continuation.resume(DjiCommandResult.ok("媒体管理已关闭"))
}
override fun onFailure(error: IDJIError) {
Log.w(CloudMediaUploadLogTag, "disable media manager failed: ${error.description()}")
continuation.resume(DjiCommandResult.failed(error))
}
})
}
private suspend fun pullMediaFileList(): DjiCommandResult =
suspendCancellableCoroutine { continuation ->
MediaDataCenter.getInstance().mediaManager.pullMediaFileListFromCamera(
PullMediaFileListParam.Builder().mediaFileIndex(0).count(30).build(),
object : CommonCallbacks.CompletionCallback {
override fun onSuccess() {
continuation.resume(DjiCommandResult.ok("相机文件列表已拉取"))
}
override fun onFailure(error: IDJIError) {
continuation.resume(DjiCommandResult.failed(error))
}
}
)
}
private suspend fun waitLatestMediaFile(): MediaFile? {
val startAt = System.currentTimeMillis()
while (System.currentTimeMillis() - startAt < 8_000L) {
val data = MediaDataCenter.getInstance().mediaManager.mediaFileListData.data
val latest = data.maxByOrNull { it.fileIndex }
if (latest != null) {
Log.d(
CloudMediaUploadLogTag,
"media list ready count=${data.size} latest=${latest.fileName} index=${latest.fileIndex}"
)
return latest
}
delay(300)
}
Log.w(CloudMediaUploadLogTag, "media list empty after pull")
return null
}
private suspend fun downloadOriginalMedia(mediaFile: MediaFile): Result<File> {
val timeoutMs = ((mediaFile.fileSize / 1024L / 1024L) * 4_000L)
.coerceIn(MediaDownloadMinTimeoutMs, MediaDownloadMaxTimeoutMs)
return withTimeoutOrNull(timeoutMs) {
downloadOriginalMediaOnce(mediaFile)
} ?: Result.failure(IllegalStateException("下载超时:${mediaFile.fileName}"))
}
private suspend fun downloadOriginalMediaOnce(mediaFile: MediaFile): Result<File> =
suspendCancellableCoroutine { continuation ->
val dir = File(appContext.cacheDir, "msdk-media")
if (!dir.exists()) dir.mkdirs()
val fileName = mediaFile.fileName?.takeIf { it.isNotBlank() } ?: "DJI_${mediaFile.fileIndex}.dat"
val outputFile = File(dir, fileName)
var completed = false
val outputStream = FileOutputStream(outputFile, false)
val bufferedOutput = BufferedOutputStream(outputStream)
fun closeStreams() {
runCatching { bufferedOutput.flush() }
runCatching { bufferedOutput.close() }
runCatching { outputStream.close() }
}
fun resumeOnce(result: Result<File>) {
if (completed) return
completed = true
closeStreams()
if (continuation.isActive) continuation.resume(result)
}
continuation.invokeOnCancellation {
closeStreams()
runCatching { outputFile.delete() }
}
mediaFile.pullOriginalMediaFileFromCamera(0L, object : MediaFileDownloadListener {
override fun onStart() {
Log.i(CloudMediaUploadLogTag, "download start name=${mediaFile.fileName} size=${mediaFile.fileSize}")
}
override fun onProgress(total: Long, current: Long) {
if (current == total || current % (1024 * 1024) == 0L) {
Log.d(CloudMediaUploadLogTag, "download progress name=${mediaFile.fileName} current=$current total=$total")
}
}
override fun onRealtimeDataUpdate(data: ByteArray, position: Long) {
runCatching {
bufferedOutput.write(data)
}.onFailure { error ->
resumeOnce(Result.failure(error))
}
}
override fun onFinish() {
Log.i(CloudMediaUploadLogTag, "download finish path=${outputFile.absolutePath} size=${outputFile.length()}")
resumeOnce(Result.success(outputFile))
}
override fun onFailure(error: IDJIError?) {
val message = error?.description() ?: error?.toString() ?: "未知下载错误"
Log.w(CloudMediaUploadLogTag, "download failed name=${mediaFile.fileName}: $message")
resumeOnce(Result.failure(IllegalStateException(message)))
}
})
}
private fun uploadMultipart(session: CloudSession, file: File): Result<String> =
runCatching {
val boundary = "----ZKLH-MSDK-${UUID.randomUUID()}"
val url = "${session.baseUrl.trimEnd('/')}/system/base/uploadFile"
val connection = URI(url).toURL().openConnection() as HttpURLConnection
connection.requestMethod = "POST"
connection.connectTimeout = 20_000
connection.readTimeout = UploadReadTimeoutMs
connection.doInput = true
connection.doOutput = true
connection.useCaches = false
connection.setChunkedStreamingMode(1024 * 1024)
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
connection.setRequestProperty("Accept", "application/json")
platformHostHeaderForUploadUrl(url)?.let { connection.setRequestProperty("Host", it) }
connection.setRequestProperty("Tenant-Id", session.tenantId)
connection.setRequestProperty("tenant-id", session.tenantId)
connection.setRequestProperty("x-auth-token", session.accessToken)
connection.setRequestProperty("Authorization", "Bearer ${session.accessToken}")
DataOutputStream(BufferedOutputStream(connection.outputStream)).use { output ->
output.writeBytes("--$boundary\r\n")
output.writeBytes("Content-Disposition: form-data; name=\"file\"; filename=\"${file.name}\"\r\n")
output.writeBytes("Content-Type: ${file.contentType()}\r\n\r\n")
BufferedInputStream(FileInputStream(file)).use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read <= 0) break
output.write(buffer, 0, read)
}
}
output.writeBytes("\r\n--$boundary--\r\n")
output.flush()
}
val responseCode = connection.responseCode
val responseText = (if (responseCode in 200..299) connection.inputStream else connection.errorStream ?: connection.inputStream)
.bufferedReader(Charsets.UTF_8)
.use { it.readText() }
Log.i(CloudMediaUploadLogTag, "upload response code=$responseCode body=$responseText")
if (responseCode !in 200..299) {
error("HTTP $responseCode: $responseText")
}
parseUploadResponse(responseText)
}
private fun parseUploadResponse(responseText: String): String {
val text = responseText.trim()
if (!text.startsWith("{")) return text.ifBlank { "unknown" }
val root = JSONObject(text)
val code = root.optInt("code", 0)
if (code != 0 && code != 200) {
error(root.optString("message", root.optString("msg", "平台上传失败")))
}
return root.opt("data")?.toString()?.takeIf { it.isNotBlank() && it != "null" } ?: "unknown"
}
}
private fun File.contentType(): String =
when (extension.lowercase()) {
"jpg", "jpeg" -> "image/jpeg"
"png" -> "image/png"
"dng" -> "image/x-adobe-dng"
"mp4" -> "video/mp4"
"mov" -> "video/quicktime"
else -> "application/octet-stream"
}
private fun platformHostHeaderForUploadUrl(url: String): String? {
val uri = runCatching { URI(url) }.getOrNull() ?: return null
if (!uri.host.equals(PlatformFallbackIp, ignoreCase = true)) return null
val defaultPort = (uri.scheme.equals("http", ignoreCase = true) && uri.port == 80) ||
(uri.scheme.equals("https", ignoreCase = true) && uri.port == 443)
val port = if (uri.port > 0 && !defaultPort) ":${uri.port}" else ""
return "$PlatformHost$port"
}

View File

@@ -1,8 +1,11 @@
package com.zklh.dronecontroller.core.cloud
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.TelemetrySnapshot
import dji.v5.manager.diagnostic.WarningLevel
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.atan2
@@ -20,6 +23,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken
import org.eclipse.paho.client.mqttv3.MqttCallback
import org.eclipse.paho.client.mqttv3.MqttCallbackExtended
import org.eclipse.paho.client.mqttv3.MqttClient
import org.eclipse.paho.client.mqttv3.MqttConnectOptions
import org.eclipse.paho.client.mqttv3.MqttException
@@ -35,11 +39,15 @@ 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 ThingVersion = "1.2.0"
private const val CloudAccessType = "msdk"
private const val OSD_MIN_INTERVAL_MS = 1_000L
private const val MQTT_MAX_INFLIGHT = 200
private const val MQTT_REASON_MAX_INFLIGHT = 32202
private const val MQTT_PUBLISH_CONGESTION_COOLDOWN_MS = 5_000L
private const val JsonClassKey = "@class"
private const val OsdRemoteControlClass = "com.dji.sdk.cloudapi.device.OsdRemoteControl"
private const val WirelessLinkClass = "com.dji.sdk.cloudapi.device.WirelessLink"
@@ -51,7 +59,6 @@ private const val RcDistanceLimitStatusClass = "com.dji.sdk.cloudapi.device.RcDi
private const val StorageClass = "com.dji.sdk.cloudapi.device.Storage"
private const val OsdCameraClass = "com.dji.sdk.cloudapi.device.OsdCamera"
private const val RcDronePayloadClass = "com.dji.sdk.cloudapi.device.RcDronePayload"
private const val JavaArrayListClass = "java.util.ArrayList"
private const val EarthRadiusMeters = 6_371_000.0
class CloudMqttService(
@@ -67,10 +74,19 @@ class CloudMqttService(
private var session: CloudSession? = null
private var deviceIdentity = CloudDeviceIdentity()
private var telemetry = TelemetrySnapshot()
private var warningState = DroneWarningState()
private var subscribedControlKey = ""
private var onlineKey = ""
private var liveCapacityKey = ""
private var warningHmsKey = ""
private var warningDebugRaw = ""
private var hmsDebugKey = ""
private var lastOsdAt = 0L
@Volatile
private var lastPublishCongestedAt = 0L
private val publishLock = Any()
private val osdThrottleLock = Any()
private val clientInstanceId = UUID.randomUUID().toString().replace("-", "")
fun connect(
session: CloudSession,
@@ -88,10 +104,10 @@ class CloudMqttService(
)
}
scope.launch {
var attemptClient: MqttClient? = null
runCatching {
client?.takeIf { it.isConnected }?.disconnect()
client?.close()
val clientId = "zklh-rc-${deviceIdentity.remoteControllerSn.ifBlank { UUID.randomUUID().toString() }}"
closeCurrentClient()
val clientId = buildClientId()
val mqttAddresses = CloudLoginClient.mqttAddressCandidates(session.mqttAddress)
.ifEmpty { listOf(session.mqttAddress) }
var connectedClient: MqttClient? = null
@@ -99,14 +115,19 @@ class CloudMqttService(
var lastConnectError: Throwable? = null
for (mqttAddress in mqttAddresses) {
val result = runCatching {
Log.i(CloudMqttLogTag, "connect attempt address=$mqttAddress username=${session.mqttUsername}")
Log.i(
CloudMqttLogTag,
"connect attempt address=$mqttAddress clientId=$clientId username=${session.mqttUsername}"
)
val mqttClient = MqttClient(mqttAddress, clientId, MemoryPersistence())
attemptClient = mqttClient
mqttClient.setCallback(callback())
val options = MqttConnectOptions().apply {
isAutomaticReconnect = true
isCleanSession = true
connectionTimeout = 10
keepAliveInterval = 20
maxInflight = MQTT_MAX_INFLIGHT
userName = session.mqttUsername
password = session.mqttPassword.toCharArray()
}
@@ -120,6 +141,8 @@ class CloudMqttService(
} else {
val error = result.exceptionOrNull()
Log.w(CloudMqttLogTag, "connect attempt failed address=$mqttAddress: ${error?.message}")
closeMqttClient(attemptClient)
attemptClient = null
lastConnectError = error
}
}
@@ -138,8 +161,10 @@ class CloudMqttService(
subscribeControlTopicsIfReady()
publishOnlineIfReady(force = true)
publishOsdIfReady(force = true)
publishHmsIfReady(force = true)
}.onFailure { error ->
Log.e(CloudMqttLogTag, "connect failed", error)
closeMqttClient(attemptClient)
_state.update {
it.copy(
connected = false,
@@ -156,12 +181,7 @@ class CloudMqttService(
fun disconnect() {
scope.launch {
runCatching { publishStatusOnline(online = false) }
runCatching { client?.disconnect() }
runCatching { client?.close() }
client = null
subscribedControlKey = ""
onlineKey = ""
liveCapacityKey = ""
closeCurrentClient()
_state.update {
it.copy(
connected = false,
@@ -175,12 +195,7 @@ class CloudMqttService(
fun clear() {
runCatching { publishStatusOnline(online = false) }
runCatching { client?.disconnectForcibly(500, 500, false) }
runCatching { client?.close() }
client = null
subscribedControlKey = ""
onlineKey = ""
liveCapacityKey = ""
closeCurrentClient()
scope.cancel()
}
@@ -194,6 +209,8 @@ class CloudMqttService(
scope.launch {
subscribeControlTopicsIfReady()
publishOnlineIfReady(force = false)
publishLiveCapacityIfReady(deviceIdentity, force = false)
publishHmsIfReady(force = false)
}
}
@@ -204,10 +221,51 @@ class CloudMqttService(
}
}
fun updateWarnings(state: DroneWarningState) {
warningState = state
if (state.raw != warningDebugRaw) {
warningDebugRaw = state.raw
Log.d(
CloudMqttLogTag,
"warning state updated active=${state.active} count=${state.items.size} raw=${state.raw.ifBlank { "empty" }}"
)
}
scope.launch {
publishHmsIfReady(force = false)
}
}
private fun callback(): MqttCallback =
object : MqttCallback {
object : MqttCallbackExtended {
override fun connectComplete(reconnect: Boolean, serverURI: String?) {
Log.i(CloudMqttLogTag, "connect complete reconnect=$reconnect serverURI=$serverURI")
_state.update {
it.copy(
connected = true,
connecting = false,
message = if (reconnect) "MQTT 已重连" else "MQTT 已连接",
lastError = null
)
}
if (reconnect) {
subscribedControlKey = ""
onlineKey = ""
liveCapacityKey = ""
scope.launch {
subscribeControlTopicsIfReady()
publishOnlineIfReady(force = true)
publishOsdIfReady(force = true)
publishHmsIfReady(force = true)
}
}
}
override fun connectionLost(cause: Throwable?) {
Log.e(CloudMqttLogTag, "connection lost", cause)
subscribedControlKey = ""
onlineKey = ""
liveCapacityKey = ""
warningHmsKey = ""
_state.update {
it.copy(
connected = false,
@@ -240,6 +298,7 @@ class CloudMqttService(
if (aircraftSn.isNotBlank()) {
add("thing/product/$aircraftSn/services")
add("thing/product/$aircraftSn/property/set")
add("thing/product/$aircraftSn/drc/down")
}
}.toTypedArray()
runCatching {
@@ -289,7 +348,7 @@ class CloudMqttService(
onlineKey = key
val now = System.currentTimeMillis()
_state.update { it.copy(lastOnlineAt = now, message = "设备上线信息已上报") }
publishLiveCapacityIfReady(identity)
publishLiveCapacityIfReady(identity, force = true)
bindDevices(identity)
}
@@ -335,13 +394,14 @@ class CloudMqttService(
publish("sys/product/$rcSn/status", payload, qos = 1)
}
private fun publishLiveCapacityIfReady(identity: CloudDeviceIdentity) {
private fun publishLiveCapacityIfReady(identity: CloudDeviceIdentity, force: Boolean) {
val cloudSession = session ?: return
if (!isConnected()) return
val rcSn = identity.remoteControllerSn
val aircraftSn = identity.aircraftSn
if (rcSn.isBlank() || aircraftSn.isBlank()) return
val key = "$rcSn/$aircraftSn/${cloudSession.workspaceId}"
if (liveCapacityKey == key) return
if (!force && liveCapacityKey == key) return
val video = JSONObject()
.put("video_index", Matrice4LiveVideoIndex)
@@ -371,9 +431,59 @@ class CloudMqttService(
.put("device_list", JSONArray().put(device))
)
)
publish("thing/product/$rcSn/state", payload, qos = 1)
if (publish("thing/product/$rcSn/state", payload, qos = 1)) {
liveCapacityKey = key
Log.d(CloudMqttLogTag, "live capacity published rc=$rcSn aircraft=$aircraftSn")
Log.d(CloudMqttLogTag, "live capacity published rc=$rcSn aircraft=$aircraftSn force=$force")
}
}
private fun publishHmsIfReady(force: Boolean) {
if (!isConnected()) {
logHmsDebug("skip:not_connected")
return
}
val cloudSession = session ?: run {
logHmsDebug("skip:no_session")
return
}
val identity = deviceIdentity
if (!identity.readyForOnline) {
logHmsDebug("skip:identity_not_ready:${identity.remoteControllerSn}/${identity.aircraftSn}")
return
}
val hmsItems = JSONArray().apply {
val inTheSky = telemetry.isFlying || telemetry.motorsOn
warningState.items
.map { it.toHmsJson(inTheSky) }
.forEach { put(it) }
}
val key = "${identity.remoteControllerSn}/${identity.aircraftSn}/${hmsItems.hmsSignature()}"
if (!force && key == warningHmsKey) {
logHmsDebug("skip:unchanged:$key")
return
}
if (!warningState.active && hmsItems.length() == 0 && warningHmsKey.isBlank() && !force) {
logHmsDebug("skip:no_warning")
return
}
logHmsDebug("ready:force=$force active=${warningState.active} count=${hmsItems.length()} key=$key")
val payload = topicRequest(cloudSession)
.put("method", "hms")
.put("gateway", identity.remoteControllerSn)
.put("from", identity.aircraftSn)
.put("need_reply", false)
.put("data", JSONObject().put("list", hmsItems))
if (publish("thing/product/${identity.aircraftSn}/events", payload, qos = 1)) {
warningHmsKey = key
Log.d(CloudMqttLogTag, "hms published aircraft=${identity.aircraftSn} count=${hmsItems.length()} force=$force")
}
}
private fun logHmsDebug(message: String) {
if (message == hmsDebugKey) return
hmsDebugKey = message
Log.d(CloudMqttLogTag, "hms $message")
}
private fun publishOsdIfReady(force: Boolean) {
@@ -381,8 +491,17 @@ class CloudMqttService(
val identity = deviceIdentity
if (!identity.readyForOnline) return
val now = System.currentTimeMillis()
if (!force && now - lastOsdAt < OSD_MIN_INTERVAL_MS) return
val shouldPublish = synchronized(osdThrottleLock) {
when {
!force && now - lastOsdAt < OSD_MIN_INTERVAL_MS -> false
!force && isPublishCongested(now) -> false
else -> {
lastOsdAt = now
true
}
}
}
if (!shouldPublish) return
publishRcOsd(identity)
publishDroneOsd(identity)
_state.update { it.copy(lastOsdAt = now) }
@@ -432,7 +551,9 @@ class CloudMqttService(
CloudMqttLogTag,
"DroneOsdPosition source=${telemetry.droneOsdPositionSource()} lat=$latitude lon=$longitude " +
"gpsValid=${telemetry.gpsValid} gps=${telemetry.gpsSatelliteCount} rtkHealthy=${telemetry.rtkHealthy} " +
"rtkUsable=${telemetry.rtkFusionDataUsable} solution=${telemetry.rtkPositioningSolution}"
"rtkUsable=${telemetry.rtkFusionDataUsable} solution=${telemetry.rtkPositioningSolution} " +
"simulator=${telemetry.simulatorStarted} fc=${telemetry.latitude},${telemetry.longitude} " +
"rtk=${telemetry.rtkLatitude},${telemetry.rtkLongitude} rc=${telemetry.rcLatitude},${telemetry.rcLongitude}/${telemetry.rcLocationValid}"
)
val relativeAltitude = telemetry.altitude.toFloat()
val payload = topicRequest(cloudSession)
@@ -516,7 +637,7 @@ class CloudMqttService(
if (telemetry.batteryHighVoltageStorageKnown) {
batteryItem.put("high_voltage_storage_days", telemetry.batteryHighVoltageStorageDays())
}
return if (batteryItem.length() > 2) cloudArrayList(batteryItem) else cloudArrayList()
return if (batteryItem.length() > 2) JSONArray().put(batteryItem) else JSONArray()
}
private fun dronePositionStateJson(): JSONObject =
@@ -619,9 +740,15 @@ class CloudMqttService(
publish(replyTopic, payload, qos = 1)
}
private fun publish(topic: String, payload: JSONObject, qos: Int) {
val mqttClient = client ?: return
if (!mqttClient.isConnected) return
private fun publish(topic: String, payload: JSONObject, qos: Int): Boolean {
val mqttClient = client ?: return false
if (!mqttClient.isConnected) return false
if (qos == 0 && isPublishCongested(System.currentTimeMillis())) {
Log.d(CloudMqttLogTag, "skip non-critical publish while congested topic=$topic")
return false
}
return synchronized(publishLock) {
if (!mqttClient.isConnected) return@synchronized false
runCatching {
val message = MqttMessage(payload.toString().toByteArray(Charsets.UTF_8)).apply {
this.qos = qos
@@ -629,13 +756,130 @@ class CloudMqttService(
}
mqttClient.publish(topic, message)
Log.d(CloudMqttLogTag, "published topic=$topic payload=$payload")
}.onFailure { error ->
if (error is MqttException) Log.e(CloudMqttLogTag, "publish failed reason=${error.reasonCode}", error)
_state.update { it.copy(lastError = error.message ?: error.toString()) }
}.fold(
onSuccess = { true },
onFailure = { error ->
handlePublishFailure(error, topic)
false
}
)
}
}
private fun isConnected(): Boolean = client?.isConnected == true && session != null
private fun handlePublishFailure(error: Throwable, topic: String) {
val message = error.message ?: error.toString()
val maxInflight = error is MqttException &&
(error.reasonCode == MQTT_REASON_MAX_INFLIGHT || message.contains("too many publishes", ignoreCase = true))
if (maxInflight) {
lastPublishCongestedAt = System.currentTimeMillis()
Log.w(CloudMqttLogTag, "publish congested topic=$topic reason=${(error as MqttException).reasonCode}: $message")
_state.update { it.copy(message = "MQTT 发布队列拥塞,已限流实时数据", lastError = null) }
return
}
if (error is MqttException) {
Log.e(CloudMqttLogTag, "publish failed topic=$topic reason=${error.reasonCode}", error)
} else {
Log.e(CloudMqttLogTag, "publish failed topic=$topic", error)
}
_state.update { it.copy(lastError = message) }
}
private fun isPublishCongested(now: Long): Boolean =
now - lastPublishCongestedAt < MQTT_PUBLISH_CONGESTION_COOLDOWN_MS
private fun buildClientId(): String {
val sn = deviceIdentity.remoteControllerSn.ifBlank { "pending-${clientInstanceId.take(8)}" }
return "zklh-msdk-$sn".take(64)
}
private fun closeCurrentClient() {
val oldClient = client
client = null
resetPublishState()
closeMqttClient(oldClient)
}
private fun closeMqttClient(mqttClient: MqttClient?) {
mqttClient ?: return
runCatching { mqttClient.setCallback(null) }
runCatching {
if (mqttClient.isConnected) {
mqttClient.disconnectForcibly(500, 500, false)
}
}.onFailure { error ->
Log.w(CloudMqttLogTag, "force disconnect mqtt failed: ${error.message}")
}
runCatching { mqttClient.close() }
.onFailure { error -> Log.w(CloudMqttLogTag, "close mqtt failed: ${error.message}") }
}
private fun resetPublishState() {
subscribedControlKey = ""
onlineKey = ""
liveCapacityKey = ""
warningHmsKey = ""
hmsDebugKey = ""
}
}
private fun DroneWarningItem.toHmsJson(inTheSky: Boolean): JSONObject {
val hmsCode = code.toHmsCodeOrFallback(message)
return JSONObject()
.put("code", hmsCode)
.put("device_type", Matrice4DeviceType)
.put("imminent", level.isHmsAlarm())
.put("in_the_sky", inTheSky)
.put("level", level.toHmsLevel())
.put("module", 3)
.put("args", hmsArgsJson())
.put("title", title)
.put("description", description)
.put("message_zh", message)
.put("message_en", message)
}
private fun DroneWarningItem.hmsArgsJson(): JSONObject =
JSONObject().apply {
componentId.toIntOrNull()?.let { put("component_index", it) }
sensorIndex.toIntOrNull()?.let { put("sensor_index", it) }
}
private fun String.toHmsCodeOrFallback(fallbackText: String): String {
val value = trim()
if (value.isBlank()) return fallbackText.toStableHmsCode()
if (value.startsWith("0x", ignoreCase = true)) return value
val decimal = value.toLongOrNull() ?: return value
return "0x" + decimal.toString(16).uppercase().padStart(8, '0')
}
private fun String.toStableHmsCode(): String {
val basis = ifBlank { "msdk_unknown_warning" }
val unsignedHash = basis.hashCode().toLong() and 0xffffffffL
return "0x" + unsignedHash.toString(16).uppercase().padStart(8, '0')
}
private fun WarningLevel.toHmsLevel(): Int =
when (this) {
WarningLevel.SERIOUS_WARNING,
WarningLevel.WARNING -> 2
WarningLevel.CAUTION,
WarningLevel.NOTICE -> 1
WarningLevel.UNKNOWN,
WarningLevel.NORMAL -> 0
}
private fun WarningLevel.isHmsAlarm(): Boolean = toHmsLevel() >= 2
private fun JSONArray.hmsSignature(): String {
if (length() == 0) return "empty"
val parts = mutableListOf<String>()
for (index in 0 until length()) {
val item = optJSONObject(index) ?: continue
parts += "${item.optString("code")}:${item.optInt("level")}:${item.optString("message_zh")}"
}
return parts.sorted().joinToString("|")
}
private fun TelemetrySnapshot.toModeCode(productConnected: Boolean): Int {
@@ -716,7 +960,7 @@ private fun TelemetrySnapshot.droneOsdPositionSource(): String =
if (hasUsableRtkPosition()) "RTK" else "FC"
private fun TelemetrySnapshot.hasFixedPosition(): Boolean =
gpsValid || hasUsableRtkPosition()
(gpsValid && locationValid) || hasUsableRtkPosition()
private fun TelemetrySnapshot.hasUsableRtkPosition(): Boolean =
rtkLocationValid && (rtkHealthy || rtkFusionDataUsable || rtkPositioningSolution.isSolvedRtkSolution())
@@ -753,15 +997,6 @@ private fun TelemetrySnapshot.batteryHighVoltageStorageDays(): Int =
.coerceIn(0L, Int.MAX_VALUE.toLong())
.toInt()
private fun cloudArrayList(vararg items: Any): JSONArray =
JSONArray()
.put(JavaArrayListClass)
.put(
JSONArray().apply {
items.forEach { put(it) }
}
)
private fun TelemetrySnapshot.cameraModeCode(): Int {
val mode = cameraWorkMode.uppercase()
return when {

View File

@@ -1,37 +1,60 @@
package com.zklh.dronecontroller.core.diagnostics
import android.util.Log
import dji.v5.manager.diagnostic.DeviceHealthManager
import dji.v5.manager.diagnostic.DeviceStatusManager
import dji.v5.manager.diagnostic.DJIDeviceHealthInfo
import dji.v5.manager.diagnostic.DJIDeviceHealthInfoChangeListener
import dji.v5.manager.diagnostic.DJIDeviceStatus
import dji.v5.manager.diagnostic.DJIDeviceStatusChangeListener
import dji.v5.manager.diagnostic.WarningLevel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
private const val DroneWarningLogTag = "ZklhDroneWarning"
data class DroneWarningState(
val active: Boolean = false,
val level: WarningLevel = WarningLevel.NORMAL,
val message: String = "无实时告警",
val count: Int = 0,
val messages: List<String> = emptyList(),
val items: List<DroneWarningItem> = emptyList(),
val raw: String = ""
)
data class DroneWarningItem(
val code: String,
val level: WarningLevel,
val title: String,
val description: String,
val message: String,
val componentId: String = "",
val sensorIndex: String = ""
)
class DroneWarningRepository {
private val _state = MutableStateFlow(DroneWarningState())
val state: StateFlow<DroneWarningState> = _state.asStateFlow()
private val healthInfoChangeListener = DJIDeviceHealthInfoChangeListener {
private val healthInfoChangeListener = DJIDeviceHealthInfoChangeListener { infos ->
updateWarnings(healthInfos = infos)
}
private val deviceStatusChangeListener = DJIDeviceStatusChangeListener { _, _ ->
updateWarnings()
}
@Volatile
private var started = false
private var lastLoggedRaw = ""
fun start() {
if (started) return
started = true
DeviceHealthManager.getInstance().addDJIDeviceHealthInfoChangeListener(healthInfoChangeListener)
DeviceStatusManager.getInstance().addDJIDeviceStatusChangeListener(deviceStatusChangeListener)
updateWarnings()
}
@@ -39,31 +62,43 @@ class DroneWarningRepository {
if (!started) return
started = false
DeviceHealthManager.getInstance().removeDJIDeviceHealthInfoChangeListener(healthInfoChangeListener)
DeviceStatusManager.getInstance().removeDJIDeviceStatusChangeListener(deviceStatusChangeListener)
}
fun clear() {
_state.value = DroneWarningState()
}
private fun updateWarnings() {
val messages = DeviceHealthManager.getInstance().currentDJIDeviceHealthInfos
private fun updateWarnings(
healthInfos: List<DJIDeviceHealthInfo> = DeviceHealthManager.getInstance().currentDJIDeviceHealthInfos
) {
val messages = healthInfos
.map { it.toMessage() }
.plus(DeviceStatusManager.getInstance().currentDJIDeviceStatus.toMessage())
.filterNotNull()
.filter { it.text.isNotBlank() }
.filterNot { it.isNormalNotice() }
.sortedWith(compareByDescending<WarningMessage> { warningSeverity(it.level) }.thenBy { it.text })
if (messages.isEmpty()) {
logIfChanged("")
_state.value = DroneWarningState()
return
}
val top = messages.first()
val raw = messages.joinToString(separator = "\n") {
"${it.source}:${it.level}:${it.code}:${it.text}"
}
logIfChanged(raw)
_state.value = DroneWarningState(
active = true,
level = top.level,
message = top.text,
count = messages.size,
messages = messages.map { it.text },
raw = messages.joinToString(separator = "\n") { "${it.level}:${it.code}:${it.text}" }
items = messages.map { it.toItem() },
raw = raw
)
}
@@ -76,15 +111,69 @@ class DroneWarningRepository {
title.isNotBlank() -> title
else -> code
}
return WarningMessage(text = text, level = warningLevel(), code = code)
return WarningMessage(
text = text,
level = warningLevel(),
code = code,
title = title,
description = description,
componentId = componentId().toString(),
sensorIndex = sensorIndex().toString(),
source = "health"
)
}
private fun DJIDeviceStatus.toMessage(): WarningMessage? {
if (this == DJIDeviceStatus.NORMAL || warningLevel() == WarningLevel.NORMAL) return null
val description = description()
val code = statusCode()
val text = description.ifBlank { toString() }
return WarningMessage(
text = text,
level = warningLevel(),
code = code,
title = text,
description = description,
source = "status"
)
}
private data class WarningMessage(
val text: String,
val level: WarningLevel,
val code: String
val code: String,
val title: String = "",
val description: String = "",
val componentId: String = "",
val sensorIndex: String = "",
val source: String
) {
fun toItem(): DroneWarningItem =
DroneWarningItem(
code = code.trim(),
level = level,
title = title,
description = description,
message = text,
componentId = componentId,
sensorIndex = sensorIndex
)
fun isNormalNotice(): Boolean =
level == WarningLevel.NOTICE &&
listOf(text, title, description).any { it.contains("正常") }
}
private fun logIfChanged(raw: String) {
if (raw == lastLoggedRaw) return
lastLoggedRaw = raw
if (raw.isBlank()) {
Log.d(DroneWarningLogTag, "warnings cleared")
} else {
Log.d(DroneWarningLogTag, "warnings changed:\n$raw")
}
}
private fun warningSeverity(level: WarningLevel): Int =
when (level) {
WarningLevel.SERIOUS_WARNING -> 5

View File

@@ -3,6 +3,9 @@ package com.zklh.dronecontroller.core.flight
import com.zklh.dronecontroller.core.msdk.DjiCommandResult
import com.zklh.dronecontroller.core.safety.SafetyInterlock
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.error.IDJIError
import dji.v5.et.action
import dji.v5.et.create
@@ -100,4 +103,26 @@ class FlightControlService {
})
}
}
suspend fun lookAt(
latitude: Double,
longitude: Double,
altitude: Double,
mode: LookAtMode = LookAtMode.LOOK_AT_GIMBAL_FREE
): DjiCommandResult =
if (!SafetyInterlock.FLIGHT_COMMANDS_ENABLED) {
DjiCommandResult.failed(SafetyInterlock.LOCKED_MESSAGE)
} else {
suspendCancellableCoroutine { continuation ->
val info = LookAtInfo().apply {
this.mode = mode
location = LocationCoordinate3D(latitude, longitude, altitude)
}
FlightControllerKey.KeyLookAt.create().action(info, {
continuation.resume(DjiCommandResult.ok("云台/机头看向目标点指令已下发"))
}, { error: IDJIError ->
continuation.resume(DjiCommandResult.failed(error))
})
}
}
}

View File

@@ -5,17 +5,96 @@ import com.zklh.dronecontroller.core.msdk.DjiCommandResult
import com.zklh.dronecontroller.core.safety.SafetyInterlock
import dji.sdk.keyvalue.value.common.LocationCoordinate3D
import dji.sdk.keyvalue.value.flightcontroller.FlyToMode
import dji.sdk.keyvalue.value.flightcontroller.FlyToMissionState
import dji.v5.common.callback.CommonCallbacks
import dji.v5.common.error.IDJIError
import dji.v5.manager.intelligent.IMissionCapabilityListener
import dji.v5.manager.intelligent.IMissionInfoListener
import dji.v5.manager.intelligent.IntelligentFlightManager
import dji.v5.manager.intelligent.flyto.FlyToCapability
import dji.v5.manager.intelligent.flyto.FlyToInfo
import dji.v5.manager.intelligent.flyto.FlyToParam
import dji.v5.manager.intelligent.flyto.FlyToTarget
import kotlin.coroutines.resume
import kotlin.math.roundToInt
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.suspendCancellableCoroutine
private const val FlyToLogTag = "ZklhFlyTo"
data class FlyToStatus(
val state: FlyToMissionState = FlyToMissionState.UNKNOWN,
val mode: FlyToMode = FlyToMode.UNKNOWN,
val height: Int = 0,
val targetLatitude: Double = 0.0,
val targetLongitude: Double = 0.0,
val targetAltitude: Double = 0.0,
val heightRangeText: String = ""
) {
val active: Boolean
get() = state !in setOf(
FlyToMissionState.IDLE,
FlyToMissionState.FINISHED,
FlyToMissionState.UNKNOWN
)
}
class FlyToService {
private val manager = IntelligentFlightManager.getInstance().flyToMissionManager
private val _status = MutableStateFlow(FlyToStatus())
val status: StateFlow<FlyToStatus> = _status.asStateFlow()
private val missionInfoListener = object : IMissionInfoListener<FlyToInfo, FlyToTarget> {
override fun onMissionInfoUpdate(info: FlyToInfo) {
_status.update {
it.copy(
state = info.state ?: FlyToMissionState.UNKNOWN,
mode = info.flyToMode ?: FlyToMode.UNKNOWN,
height = info.flyToHeight
)
}
Log.d(FlyToLogTag, "info state=${info.state} mode=${info.flyToMode} height=${info.flyToHeight} exit=${info.exitReason}")
}
override fun onMissionTargetUpdate(target: FlyToTarget) {
val location = target.targetLocation
_status.update {
it.copy(
targetLatitude = location?.latitude ?: 0.0,
targetLongitude = location?.longitude ?: 0.0,
targetAltitude = location?.altitude ?: 0.0
)
}
Log.d(FlyToLogTag, "target=$location maxSpeed=${target.maxSpeed} securityTakeoffHeight=${target.securityTakeoffHeight}")
}
}
private val capabilityListener = object : IMissionCapabilityListener<FlyToCapability> {
override fun onMissionCapabilityUpdate(capability: FlyToCapability) {
_status.update {
it.copy(
heightRangeText = capability.heightRange?.toString().orEmpty()
)
}
Log.d(
FlyToLogTag,
"capability heightRange=${capability.heightRange}"
)
}
}
init {
runCatching { manager.addMissionInfoListener(missionInfoListener) }
.onFailure { Log.w(FlyToLogTag, "addMissionInfoListener failed: ${it.message}") }
runCatching { manager.addMissionCapabilityListener(capabilityListener) }
.onFailure { Log.w(FlyToLogTag, "addMissionCapabilityListener failed: ${it.message}") }
}
fun isMissionActive(): Boolean = status.value.active
suspend fun startFlyTo(
latitude: Double,
longitude: Double,
@@ -28,35 +107,126 @@ class FlyToService {
DjiCommandResult.failed(SafetyInterlock.LOCKED_MESSAGE)
} else {
suspendCancellableCoroutine { continuation ->
val manager = IntelligentFlightManager.getInstance().flyToMissionManager
val targetHeight = height.roundToInt().coerceAtLeast(1)
val safeTakeoffHeight = securityTakeoffHeight
.coerceAtLeast(1)
.coerceAtMost(targetHeight)
val target = FlyToTarget().apply {
targetLocation = LocationCoordinate3D(latitude, longitude, height)
this.maxSpeed = maxSpeed
this.securityTakeoffHeight = securityTakeoffHeight
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=$securityTakeoffHeight"
"startFlyTo lat=$latitude lon=$longitude height=$height mode=$flyToMode maxSpeed=$maxSpeed securityTakeoffHeight=$safeTakeoffHeight"
)
manager.startMission(
target,
null,
callback(continuation, "指点飞行已开始")
param,
callback(
continuation,
"指点飞行已开始",
onSuccess = {
_status.update {
it.copy(
state = FlyToMissionState.PREPARE,
mode = flyToMode,
height = targetHeight,
targetLatitude = latitude,
targetLongitude = longitude,
targetAltitude = height
)
}
},
onFailure = {
_status.update { it.copy(state = FlyToMissionState.IDLE) }
}
)
)
}
}
suspend fun stopFlyTo(): DjiCommandResult =
suspendCancellableCoroutine { continuation ->
IntelligentFlightManager.getInstance().flyToMissionManager.stopMission(
callback(continuation, "指点飞行已停止")
manager.stopMission(
callback(
continuation,
"指点飞行已停止",
onSuccess = { _status.update { it.copy(state = FlyToMissionState.IDLE) } }
)
)
}
suspend fun updateFlyToTarget(
latitude: Double,
longitude: Double,
height: Double,
maxSpeed: Int = 14,
securityTakeoffHeight: Int = 20
): DjiCommandResult =
if (!SafetyInterlock.FLIGHT_COMMANDS_ENABLED) {
DjiCommandResult.failed(SafetyInterlock.LOCKED_MESSAGE)
} else {
suspendCancellableCoroutine { continuation ->
val targetHeight = height.roundToInt().coerceAtLeast(1)
val safeTakeoffHeight = securityTakeoffHeight
.coerceAtLeast(1)
.coerceAtMost(targetHeight)
val target = FlyToTarget().apply {
targetLocation = LocationCoordinate3D(latitude, longitude, height)
this.maxSpeed = maxSpeed
this.securityTakeoffHeight = safeTakeoffHeight
}
Log.d(
FlyToLogTag,
"updateFlyToTarget lat=$latitude lon=$longitude height=$height maxSpeed=$maxSpeed securityTakeoffHeight=$safeTakeoffHeight"
)
manager.updateMissionTarget(
target,
callback(
continuation,
"指点飞行目标点已更新",
onSuccess = {
_status.update {
it.copy(
targetLatitude = latitude,
targetLongitude = longitude,
targetAltitude = height
)
}
}
)
)
}
}
suspend fun updateFlyToParam(
height: Int? = null,
mode: FlyToMode? = null
): DjiCommandResult =
if (!SafetyInterlock.FLIGHT_COMMANDS_ENABLED) {
DjiCommandResult.failed(SafetyInterlock.LOCKED_MESSAGE)
} else {
suspendCancellableCoroutine { continuation ->
val param = FlyToParam().apply {
if (mode != null) flyToMode = mode
if (height != null) this.height = height.coerceAtLeast(1)
}
manager.updateMissionParam(
param,
callback(continuation, "指点飞行参数已更新")
)
}
}
suspend fun setFlyToMode(mode: FlyToMode): DjiCommandResult =
suspendCancellableCoroutine { continuation ->
val param = FlyToParam().apply { flyToMode = mode }
IntelligentFlightManager.getInstance().flyToMissionManager.updateMissionParam(
manager.updateMissionParam(
param,
callback(continuation, "指点飞行模式已更新:${mode.name}")
)
@@ -65,7 +235,7 @@ class FlyToService {
suspend fun setFlyToHeight(height: Int): DjiCommandResult =
suspendCancellableCoroutine { continuation ->
val param = FlyToParam().apply { this.height = height }
IntelligentFlightManager.getInstance().flyToMissionManager.updateMissionParam(
manager.updateMissionParam(
param,
callback(continuation, "指点飞行高度已更新:${height}m")
)
@@ -73,14 +243,18 @@ class FlyToService {
private fun callback(
continuation: kotlinx.coroutines.CancellableContinuation<DjiCommandResult>,
successMessage: String
successMessage: String,
onSuccess: () -> Unit = {},
onFailure: () -> Unit = {}
): CommonCallbacks.CompletionCallback =
object : CommonCallbacks.CompletionCallback {
override fun onSuccess() {
onSuccess()
continuation.resume(DjiCommandResult.ok(successMessage))
}
override fun onFailure(error: IDJIError) {
onFailure()
continuation.resume(DjiCommandResult.failed(error))
}
}

View File

@@ -5,8 +5,8 @@ import dji.sdk.keyvalue.key.DJIKey
import dji.sdk.keyvalue.key.GimbalKey
import dji.sdk.keyvalue.key.KeyTools
import dji.sdk.keyvalue.value.common.ComponentIndexType
import dji.sdk.keyvalue.value.common.EmptyMsg
import dji.sdk.keyvalue.value.gimbal.CtrlInfo
import dji.sdk.keyvalue.value.gimbal.GimbalResetType
import dji.sdk.keyvalue.value.gimbal.GimbalSpeedRotation
import dji.v5.common.error.IDJIError
import dji.v5.et.action
@@ -33,8 +33,8 @@ class GimbalControlService(
suspend fun reset(): DjiCommandResult =
runAction(
KeyTools.createKey(GimbalKey.KeyRestoreFactorySettings, gimbalIndex),
EmptyMsg(),
KeyTools.createKey(GimbalKey.KeyGimbalReset, gimbalIndex),
GimbalResetType.RECENTER,
"云台复位已下发"
)
@@ -55,4 +55,3 @@ class GimbalControlService(
)
}
}

View File

@@ -165,8 +165,8 @@ class LiveStreamingService {
val cameraIndex = config.videoId.toCameraIndex()
runCatching {
cameraStreamManager().enableStream(cameraIndex, true)
manager.cameraIndex = cameraIndex
cameraStreamManager().enableStream(cameraIndex, true)
manager.liveStreamSettings = LiveStreamSettings.Builder()
.setLiveStreamType(LiveStreamType.RTMP)
.setRtmpSettings(RtmpSettings.Builder().setUrl(config.url).build())
@@ -328,10 +328,19 @@ private fun JSONObject.optValueAsString(vararg names: String): String {
private fun JSONArray.arrayValueAsString(): String {
if (length() == 0) return ""
for (i in length() - 1 downTo 0) {
val value = opt(i)
if (value is String && value.isNotBlank()) return value
when (val value = opt(i)) {
is String -> if (value.isNotBlank() && !value.startsWith("com.", ignoreCase = true)) return value
is JSONObject -> {
val nested = value.optStringAny("url", "rtmp_url", "rtmpUrl", "push_url", "pushUrl", "value", "id")
if (nested.isNotBlank()) return nested
}
return optString(length() - 1, "")
is JSONArray -> {
val nested = value.arrayValueAsString()
if (nested.isNotBlank()) return nested
}
}
}
return ""
}
private fun JSONObject.optIntAny(defaultValue: Int, vararg names: String): Int {

View File

@@ -2,12 +2,25 @@ package com.zklh.dronecontroller.core.media
import com.zklh.dronecontroller.core.msdk.DjiCommandResult
import dji.sdk.keyvalue.key.CameraKey
import dji.sdk.keyvalue.key.DJIKey
import dji.sdk.keyvalue.key.DJICameraKey
import dji.sdk.keyvalue.key.KeyTools
import dji.sdk.keyvalue.value.camera.CameraExposureCompensation
import dji.sdk.keyvalue.value.camera.CameraExposureMode
import dji.sdk.keyvalue.value.camera.CameraFocusMode
import dji.sdk.keyvalue.value.camera.CameraNightSceneMode
import dji.sdk.keyvalue.value.camera.CameraVideoStreamSourceType
import dji.sdk.keyvalue.value.camera.FrameZoomMsg
import dji.sdk.keyvalue.value.camera.LiveViewSourceCameraType
import dji.sdk.keyvalue.value.camera.TapZoomMode
import dji.sdk.keyvalue.value.camera.ThermalDisplayMode
import dji.sdk.keyvalue.value.camera.ZoomTargetPointInfo
import dji.sdk.keyvalue.value.common.CameraLensType
import dji.sdk.keyvalue.value.common.ComponentIndexType
import dji.sdk.keyvalue.key.DJIKey
import dji.sdk.keyvalue.value.common.EmptyMsg
import dji.v5.common.error.IDJIError
import dji.v5.et.action
import dji.v5.et.create
import dji.v5.et.set
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -43,6 +56,131 @@ class CameraControlService(
)
}
suspend fun setLinkZoomEnabled(enabled: Boolean): DjiCommandResult =
setValueKey(
KeyTools.createKey(CameraKey.KeyLinkZoomEnabled, cameraIndex),
enabled,
if (enabled) "联动变焦已开启" else "联动变焦已关闭"
)
suspend fun frameZoom(
cameraType: String,
locked: Boolean,
x: Double,
y: Double,
width: Double,
height: Double
): DjiCommandResult {
val targetCamera = cameraType.toLiveViewSourceCameraType()
val message = FrameZoomMsg().apply {
lockGimbal = locked
switchLiveview = true
this.targetCamera = targetCamera
this.x = x.coerceIn(0.0, 1.0)
this.y = y.coerceIn(0.0, 1.0)
this.width = width.coerceIn(0.0, 1.0)
length = height.coerceIn(0.0, 1.0)
}
return runAction(
CameraKey.KeyFrameZoom.create(cameraIndex),
message,
"框选变焦已下发"
)
}
suspend fun aimAt(
cameraType: String,
locked: Boolean,
x: Double,
y: Double
): DjiCommandResult {
if (cameraType.isNotBlank()) {
val lens = setLens(cameraType)
if (!lens.success) return lens
}
val enable = setValueKey(
KeyTools.createKey(CameraKey.KeyTapZoomEnable, cameraIndex),
true,
"点选变焦已开启"
)
if (!enable.success) return enable
val target = ZoomTargetPointInfo().apply {
this.x = x.coerceIn(0.0, 1.0)
this.y = y.coerceIn(0.0, 1.0)
tapZoomModeEnable = true
mode = if (locked) TapZoomMode.GIMBAL_LOCK else TapZoomMode.GIMBAL_FOLLOW
}
return runAction(
CameraKey.KeyTapZoomAtTarget.create(cameraIndex),
target,
"相机点选瞄准已下发"
)
}
suspend fun setExposureCompensation(
cameraType: String,
value: Int
): DjiCommandResult {
val compensation = CameraExposureCompensation.find(value)
if (compensation == CameraExposureCompensation.UNKNOWN) {
return DjiCommandResult.failed("未知曝光补偿值:$value")
}
return setValueKey(
KeyTools.createCameraKey(CameraKey.KeyExposureCompensation, cameraIndex, cameraType.toLensType()),
compensation,
"曝光补偿已设置:${compensation.name}"
)
}
suspend fun setExposureMode(
cameraType: String,
mode: Int
): DjiCommandResult {
val exposureMode = CameraExposureMode.find(mode)
if (exposureMode == CameraExposureMode.UNKNOWN) {
return DjiCommandResult.failed("未知曝光模式:$mode")
}
return setValueKey(
KeyTools.createCameraKey(CameraKey.KeyExposureMode, cameraIndex, cameraType.toLensType()),
exposureMode,
"曝光模式已设置:${exposureMode.name}"
)
}
suspend fun setFocusMode(
cameraType: String,
mode: Int
): DjiCommandResult {
val focusMode = CameraFocusMode.find(mode)
if (focusMode == CameraFocusMode.UNKNOWN) {
return DjiCommandResult.failed("未知对焦模式:$mode")
}
return setValueKey(
KeyTools.createCameraKey(CameraKey.KeyCameraFocusMode, cameraIndex, cameraType.toLensType()),
focusMode,
"对焦模式已设置:${focusMode.name}"
)
}
suspend fun setThermalDisplaySplit(enabled: Boolean): DjiCommandResult =
setValueKey(
KeyTools.createCameraKey(CameraKey.KeyThermalDisplayMode, cameraIndex, CameraLensType.CAMERA_LENS_THERMAL),
if (enabled) ThermalDisplayMode.PIP else ThermalDisplayMode.VISUAL_ONLY,
if (enabled) "红外分屏已开启" else "红外分屏已关闭"
)
suspend fun setNightSceneMode(mode: Int): DjiCommandResult {
val nightMode = CameraNightSceneMode.find(mode)
if (nightMode == CameraNightSceneMode.UNKNOWN) {
return DjiCommandResult.failed("未知夜景模式:$mode")
}
return setValueKey(
KeyTools.createKey(DJICameraKey.KeyCameraNightSceneMode, cameraIndex),
nightMode,
"夜景模式已设置:${nightMode.name}"
)
}
private suspend fun setDoubleKey(
key: DJIKey<Double>,
value: Double,
@@ -66,6 +204,23 @@ class CameraControlService(
}
)
}
private suspend fun <Param, Result> runAction(
key: DJIKey.ActionKey<Param, Result>,
param: Param,
successMessage: String
): DjiCommandResult =
suspendCancellableCoroutine { continuation ->
key.action(
param,
{
continuation.resume(DjiCommandResult.ok(successMessage))
},
{ error: IDJIError ->
continuation.resume(DjiCommandResult.failed(error))
}
)
}
}
private fun String.normalizeLensName(): String =
@@ -73,3 +228,17 @@ private fun String.normalizeLensName(): String =
.replace("-", "_")
.lowercase()
private fun String.toLensType(): CameraLensType =
when (normalizeLensName()) {
"ir", "infrared", "thermal", "camera_lens_thermal" -> CameraLensType.CAMERA_LENS_THERMAL
"zoom", "visible_zoom", "camera_lens_zoom" -> CameraLensType.CAMERA_LENS_ZOOM
else -> CameraLensType.CAMERA_LENS_WIDE
}
private fun String.toLiveViewSourceCameraType(): LiveViewSourceCameraType =
when (normalizeLensName()) {
"wide", "normal", "visible", "visible_wide", "camera_lens_wide" -> LiveViewSourceCameraType.WIDE_CAMERA
"ir", "infrared", "thermal", "camera_lens_thermal" -> LiveViewSourceCameraType.INFRARED_CAMERA
"zoom", "visible_zoom", "camera_lens_zoom" -> LiveViewSourceCameraType.ZOOM_CAMERA
else -> LiveViewSourceCameraType.DEFAULT_CAMERA
}

View File

@@ -18,10 +18,6 @@ import dji.v5.et.action
import dji.v5.et.create
import dji.v5.et.get
import dji.v5.et.set
import dji.v5.manager.datacenter.MediaDataCenter
import dji.v5.manager.datacenter.media.MediaFile
import dji.v5.manager.datacenter.media.MediaFileListDataSource
import dji.v5.manager.datacenter.media.PullMediaFileListParam
import kotlin.coroutines.resume
import kotlinx.coroutines.delay
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -38,7 +34,6 @@ class CameraMediaService(
Log.d(CameraMediaLogTag, "takePhoto candidates=${candidateCameraIndexes()}")
for (index in candidateCameraIndexes()) {
val previousMedia = readLatestMediaFile(index)
val previousSnapshot = readCachedMediaSnapshot("before photo")
val modeResult = setPhotoCaptureMode(index)
if (!modeResult.success) {
lastFailure = modeResult
@@ -61,7 +56,6 @@ class CameraMediaService(
val saved = waitForGeneratedMediaFile(
cameraIndex = index,
previousMedia = previousMedia,
previousSnapshot = previousSnapshot,
successMessage = "拍照完成,文件已写入飞机存储",
timeoutMessage = "拍照指令已下发,但没有检测到新照片文件。请检查飞机 SD 卡/内置存储、相机存储位置和电量告警。"
)
@@ -122,7 +116,6 @@ class CameraMediaService(
val indexes = if (recordingIndexes.isNotEmpty()) recordingIndexes else candidateCameraIndexes()
for (index in indexes) {
val previousMedia = readLatestMediaFile(index)
val previousSnapshot = readCachedMediaSnapshot("before stop record")
val stopResult = runEmptyAction(
CameraKey.KeyStopRecord.create(index),
"录像停止指令已下发,正在等待文件写入"
@@ -142,7 +135,6 @@ class CameraMediaService(
val saved = waitForGeneratedMediaFile(
cameraIndex = index,
previousMedia = previousMedia,
previousSnapshot = previousSnapshot,
successMessage = "录像已停止,文件已写入飞机存储",
timeoutMessage = "录像已停止,但没有检测到新视频文件。请检查飞机 SD 卡/内置存储和相机存储状态。",
timeoutMs = 12_000L
@@ -252,6 +244,9 @@ class CameraMediaService(
fun isRecording(): Boolean =
isRecording(activeCameraIndex)
fun currentCameraIndex(): ComponentIndexType =
activeCameraIndex
private fun isRecording(index: ComponentIndexType): Boolean =
runCatching {
DJICameraKey.KeyIsRecording.create(index).get(false)
@@ -282,7 +277,6 @@ class CameraMediaService(
private suspend fun waitForGeneratedMediaFile(
cameraIndex: ComponentIndexType,
previousMedia: GeneratedMediaFileInfo?,
previousSnapshot: MediaSnapshot?,
successMessage: String,
timeoutMessage: String,
timeoutMs: Long = 8_000L
@@ -306,23 +300,13 @@ class CameraMediaService(
if (afterStoreIdentity.isNotBlank() && afterStoreIdentity != previousIdentity) {
return DjiCommandResult.ok("$successMessage${afterStoreMedia.mediaDisplayName()}")
}
val afterStoreSnapshot = fetchMediaSnapshot(cameraIndex, "after store")
return if (afterStoreSnapshot.isNewerThan(previousSnapshot)) {
DjiCommandResult.ok("$successMessage${afterStoreSnapshot?.latestDisplayName ?: "飞机存储"}")
} else {
DjiCommandResult.ok(successMessage)
}
return DjiCommandResult.ok(successMessage)
}
delay(300)
}
val finalSnapshot = fetchMediaSnapshot(cameraIndex, "after timeout")
return if (finalSnapshot.isNewerThan(previousSnapshot)) {
DjiCommandResult.ok("$successMessage${finalSnapshot?.latestDisplayName ?: "飞机存储"}")
} else {
DjiCommandResult.failed(timeoutMessage)
}
return DjiCommandResult.failed(timeoutMessage)
}
private fun readLatestMediaFile(index: ComponentIndexType): GeneratedMediaFileInfo? =
@@ -330,116 +314,6 @@ class CameraMediaService(
DJICameraKey.KeyNewlyGeneratedMediaFile.create(index).get(GeneratedMediaFileInfo())
}.getOrNull()
private fun readCachedMediaSnapshot(reason: String): MediaSnapshot? {
val files = runCatching { MediaDataCenter.getInstance().mediaManager.mediaFileListData.data }.getOrNull().orEmpty()
val latest = files.latestMediaFile()
val snapshot = MediaSnapshot(
count = files.size,
latestIdentity = latest.mediaIdentity(),
latestDisplayName = latest.mediaDisplayName()
)
Log.d(
CameraMediaLogTag,
"media cached snapshot reason=$reason count=${snapshot.count} latest=${snapshot.latestDisplayName}"
)
return snapshot.takeIf { it.latestIdentity.isNotBlank() || it.count > 0 }
}
private suspend fun fetchMediaSnapshot(index: ComponentIndexType, reason: String): MediaSnapshot? {
val manager = MediaDataCenter.getInstance().mediaManager
val dataSourceResult = runCatching {
val mediaSource = MediaFileListDataSource.Builder().setIndexType(index).build()
manager.setMediaFileDataSource(mediaSource)
}
if (dataSourceResult.isFailure) {
Log.w(CameraMediaLogTag, "set media data source failed camera=$index reason=$reason", dataSourceResult.exceptionOrNull())
return null
}
val enableResult = enableMediaManager()
if (!enableResult.success) {
Log.w(CameraMediaLogTag, "enable media manager failed camera=$index reason=$reason message=${enableResult.message}")
return null
}
return try {
val pullResult = pullMediaFileListFromCamera()
if (!pullResult.success) {
Log.w(CameraMediaLogTag, "pull media list failed camera=$index reason=$reason message=${pullResult.message}")
null
} else {
delay(350)
val files = runCatching { manager.mediaFileListData.data }.getOrNull().orEmpty()
val latest = files.latestMediaFile()
val snapshot = MediaSnapshot(
count = files.size,
latestIdentity = latest.mediaIdentity(),
latestDisplayName = latest.mediaDisplayName()
)
Log.d(
CameraMediaLogTag,
"media snapshot camera=$index reason=$reason count=${snapshot.count} latest=${snapshot.latestDisplayName}"
)
snapshot
}
} finally {
val disableResult = disableMediaManager()
if (!disableResult.success) {
Log.w(CameraMediaLogTag, "disable media manager failed camera=$index reason=$reason message=${disableResult.message}")
}
}
}
private suspend fun enableMediaManager(): DjiCommandResult =
suspendCancellableCoroutine { continuation ->
MediaDataCenter.getInstance().mediaManager.enable(
object : dji.v5.common.callback.CommonCallbacks.CompletionCallback {
override fun onSuccess() {
continuation.resume(DjiCommandResult.ok("媒体文件管理已开启"))
}
override fun onFailure(error: IDJIError) {
continuation.resume(DjiCommandResult.failed(error))
}
}
)
}
private suspend fun disableMediaManager(): DjiCommandResult =
suspendCancellableCoroutine { continuation ->
MediaDataCenter.getInstance().mediaManager.disable(
object : dji.v5.common.callback.CommonCallbacks.CompletionCallback {
override fun onSuccess() {
continuation.resume(DjiCommandResult.ok("媒体文件管理已关闭"))
}
override fun onFailure(error: IDJIError) {
continuation.resume(DjiCommandResult.failed(error))
}
}
)
}
private suspend fun pullMediaFileListFromCamera(): DjiCommandResult =
suspendCancellableCoroutine { continuation ->
val param = PullMediaFileListParam.Builder()
.mediaFileIndex(-1)
.count(-1)
.build()
MediaDataCenter.getInstance().mediaManager.pullMediaFileListFromCamera(
param,
object : dji.v5.common.callback.CommonCallbacks.CompletionCallback {
override fun onSuccess() {
continuation.resume(DjiCommandResult.ok("媒体文件列表已刷新"))
}
override fun onFailure(error: IDJIError) {
continuation.resume(DjiCommandResult.failed(error))
}
}
)
}
private fun isCameraStoringFile(index: ComponentIndexType): Boolean =
runCatching {
DJICameraKey.KeyCameraStoringFile.create(index).get(false)
@@ -607,29 +481,4 @@ class CameraMediaService(
}
}
private data class MediaSnapshot(
val count: Int,
val latestIdentity: String,
val latestDisplayName: String
)
private fun MediaSnapshot?.isNewerThan(previous: MediaSnapshot?): Boolean {
if (this == null) return false
if (previous == null) return latestIdentity.isNotBlank()
return count > previous.count ||
(latestIdentity.isNotBlank() && latestIdentity != previous.latestIdentity)
}
private fun List<MediaFile>.latestMediaFile(): MediaFile? =
maxByOrNull { it.fileIndex }
private fun MediaFile?.mediaIdentity(): String {
if (this == null) return ""
return "${fileIndex}|${fileName.orEmpty()}"
}
private fun MediaFile?.mediaDisplayName(): String {
if (this == null) return "飞机存储"
return fileName?.takeIf { it.isNotBlank() } ?: "Media#$fileIndex"
}
}

View File

@@ -1,5 +1,6 @@
package com.zklh.dronecontroller.core.simulator
import android.util.Log
import com.zklh.dronecontroller.core.msdk.DjiCommandResult
import dji.sdk.keyvalue.value.common.LocationCoordinate2D
import dji.v5.common.callback.CommonCallbacks
@@ -15,6 +16,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.suspendCancellableCoroutine
private const val SimulatorLogTag = "ZklhSimulator"
data class SimulatorStatus(
val enabled: Boolean = false,
val motorsOn: Boolean = false,
@@ -49,6 +52,7 @@ class SimulatorService {
init {
SimulatorManager.getInstance().addSimulatorStateListener(listener)
disableStaleSimulator()
_status.update {
it.copy(
enabled = SimulatorManager.getInstance().isSimulatorEnabled,
@@ -57,9 +61,27 @@ class SimulatorService {
}
}
private fun disableStaleSimulator() {
if (!SimulatorManager.getInstance().isSimulatorEnabled) return
SimulatorManager.getInstance().disableSimulator(
object : CommonCallbacks.CompletionCallback {
override fun onSuccess() {
Log.d(SimulatorLogTag, "disabled stale simulator on startup")
_status.update {
it.copy(enabled = false, flying = false, motorsOn = false, message = "模拟器未开启")
}
}
override fun onFailure(error: IDJIError) {
Log.w(SimulatorLogTag, "disable stale simulator failed: $error")
}
}
)
}
suspend fun enable(
latitude: Double = DEFAULT_LATITUDE,
longitude: Double = DEFAULT_LONGITUDE,
latitude: Double,
longitude: Double,
satelliteCount: Int = 12
): DjiCommandResult =
suspendCancellableCoroutine { continuation ->
@@ -106,8 +128,4 @@ class SimulatorService {
SimulatorManager.getInstance().removeSimulatorStateListener(listener)
}
private companion object {
const val DEFAULT_LATITUDE = 31.2304
const val DEFAULT_LONGITUDE = 121.4737
}
}

View File

@@ -52,6 +52,10 @@ import dji.v5.manager.aircraft.rtk.RTKLocationInfoListener
import dji.v5.manager.aircraft.rtk.RTKSystemState
import dji.v5.manager.aircraft.rtk.RTKSystemStateListener
import dji.v5.manager.aircraft.rtk.network.INetworkServiceInfoListener
import dji.v5.manager.aircraft.rtk.station.ConnectedRTKStationInfoListener
import dji.v5.manager.aircraft.rtk.station.RTKStationConnectStatusListener
import dji.v5.manager.aircraft.rtk.station.SearchRTKStationListener
import dji.v5.utils.common.LocationUtil
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -59,6 +63,7 @@ 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
data class TelemetrySnapshot(
val latitude: Double = 0.0,
@@ -162,6 +167,18 @@ class TelemetryRepository(
private val rtkCenter = RTKCenter.getInstance()
private val rtkLocationInfoListener = RTKLocationInfoListener { info -> updateRtkLocationInfo(info) }
private val rtkSystemStateListener = RTKSystemStateListener { state -> updateRtkSystemState(state) }
private val searchRtkStationListener = SearchRTKStationListener { stations ->
Log.d(
TelemetryLogTag,
"RTKStationSearch stations=${stations?.joinToString { "${it.stationName}:${it.stationId}:signal=${it.signalLevel}" }.orEmpty()}"
)
}
private val rtkStationConnectStatusListener = RTKStationConnectStatusListener { status ->
Log.d(TelemetryLogTag, "RTKStationConnectStatus=$status")
}
private val connectedRtkStationInfoListener = ConnectedRTKStationInfoListener { info ->
Log.d(TelemetryLogTag, "ConnectedRTKStationInfo=$info")
}
private val rtkNetworkServiceInfoListener = object : INetworkServiceInfoListener {
override fun onServiceStateUpdate(state: RTKServiceState?) {
Log.d(TelemetryLogTag, "RTKNetworkServiceState=$state")
@@ -205,6 +222,8 @@ class TelemetryRepository(
private var rtkEnableInProgress = false
private var rtkSourceSetInProgress = false
private var lastRtkStartAtMs = 0L
private var lastRtkStartSource = RTKReferenceStationSource.UNKNOWN
private var lastRtkBootstrapAtMs = 0L
private var lastRtkSystemState: RTKSystemState? = null
fun start() {
@@ -221,20 +240,18 @@ class TelemetryRepository(
}
listenSafely("KeyAircraftLocation3D", KeyTools.createKey(FlightControllerKey.KeyAircraftLocation3D)) { location: LocationCoordinate3D? ->
location ?: return@listenSafely
val altitude = location.altitude ?: _state.value.altitude
val latitude = location.latitude ?: 0.0
val longitude = location.longitude ?: 0.0
val valid = isValidCoordinate(latitude, longitude)
Log.d(
TelemetryLogTag,
"KeyAircraftLocation3D lat=$latitude lon=$longitude altitude=$altitude valid=$valid"
"KeyAircraftLocation3D lat=$latitude lon=$longitude rawAltitude=${location.altitude} valid=$valid"
)
_state.update {
it.copy(
latitude = latitude,
longitude = longitude,
locationValid = valid,
altitude = altitude,
error = null
)
}
@@ -347,6 +364,7 @@ class TelemetryRepository(
_state.update { it.copy(landingConfirmationNeeded = value, error = null) }
}
listenSafely("KeyIsSimulatorStarted", KeyTools.createKey(FlightControllerKey.KeyIsSimulatorStarted)) { started: Boolean? ->
Log.d(TelemetryLogTag, "KeyIsSimulatorStarted=$started")
_state.update { it.copy(simulatorStarted = started == true, error = null) }
}
listenSafely("KeyHeightLimit", KeyTools.createKey(FlightControllerKey.KeyHeightLimit)) { limit: Int? ->
@@ -496,6 +514,9 @@ class TelemetryRepository(
runCatching { rtkCenter.qxrtkManager.removeNetworkRTKServiceInfoListener(rtkNetworkServiceInfoListener) }
runCatching { rtkCenter.customRTKManager.removeNetworkRTKServiceInfoListener(rtkNetworkServiceInfoListener) }
runCatching { rtkCenter.cmccrtkManager.removeNetworkRTKServiceInfoListener(rtkNetworkServiceInfoListener) }
runCatching { rtkCenter.rtkStationManager.removeSearchRTKStationListener(searchRtkStationListener) }
runCatching { rtkCenter.rtkStationManager.removeRTKStationConnectStatusListener(rtkStationConnectStatusListener) }
runCatching { rtkCenter.rtkStationManager.removeConnectedRTKStationInfoListener(connectedRtkStationInfoListener) }
started = false
}
@@ -579,13 +600,15 @@ class TelemetryRepository(
val location = info?.location
val latitude = location?.latitude ?: 0.0
val longitude = location?.longitude ?: 0.0
val valid = info?.isValid == true && isValidCoordinate(latitude, longitude)
val coordinateValid = isValidCoordinate(latitude, longitude)
val accepted = coordinateValid
Log.d(
TelemetryLogTag,
"KeyRcGPSInfo valid=${info?.isValid} lat=$latitude lon=$longitude satellites=${info?.satelliteCount} accuracy=${info?.accuracy}"
"KeyRcGPSInfo valid=${info?.isValid} coordinateValid=$coordinateValid accepted=$accepted " +
"lat=$latitude lon=$longitude satellites=${info?.satelliteCount} accuracy=${info?.accuracy}"
)
_state.update {
if (valid) {
if (accepted) {
it.copy(
rcLatitude = latitude,
rcLongitude = longitude,
@@ -646,7 +669,33 @@ class TelemetryRepository(
}.onFailure { error ->
Log.w(TelemetryLogTag, "register RTK network service listeners failed: ${error.message}")
}
listenRtkStationTelemetry()
listenRtkMobileStationKeys()
bootstrapAllPositioning("telemetry_start")
}
private fun listenRtkStationTelemetry() {
runCatching {
rtkCenter.rtkStationManager.addSearchRTKStationListener(searchRtkStationListener)
rtkCenter.rtkStationManager.addRTKStationConnectStatusListener(rtkStationConnectStatusListener)
rtkCenter.rtkStationManager.addConnectedRTKStationInfoListener(connectedRtkStationInfoListener)
Log.d(TelemetryLogTag, "registered RTK station listeners")
}.onFailure { error ->
Log.w(TelemetryLogTag, "register RTK station listeners failed: ${error.message}")
}
runCatching {
rtkCenter.rtkStationManager.startSearchRTKStation(object : CommonCallbacks.CompletionCallback {
override fun onSuccess() {
Log.d(TelemetryLogTag, "start RTK station search success")
}
override fun onFailure(error: IDJIError) {
Log.w(TelemetryLogTag, "start RTK station search failed: $error")
}
})
}.onFailure { error ->
Log.w(TelemetryLogTag, "start RTK station search exception: ${error.message}")
}
}
private fun listenRtkMobileStationKeys() {
@@ -784,7 +833,10 @@ class TelemetryRepository(
}
private fun ensureRtkServiceStarted(state: RTKSystemState?) {
if (state == null) return
if (state == null) {
bootstrapAllPositioning("rtk_state_null")
return
}
if (state.rtkHealthy) {
rtkStartInProgress = false
rtkServiceStarted = true
@@ -792,21 +844,48 @@ class TelemetryRepository(
}
if (!state.isRTKEnabled) {
enableRtkModuleIfPossible()
bootstrapAllPositioning("rtk_disabled")
return
}
val source = state.rtkReferenceStationSource ?: RTKReferenceStationSource.UNKNOWN
if (!source.isNetworkRtkSource()) {
if (source == RTKReferenceStationSource.UNKNOWN) {
setPreferredNetworkRtkSource(source)
return
}
if (!source.isNetworkRtkSource()) {
Log.d(TelemetryLogTag, "RTK source is $source, station mode active")
return
}
if (rtkServiceStarted) return
val now = System.currentTimeMillis()
if (rtkStartInProgress || now - lastRtkStartAtMs < RTK_START_RETRY_INTERVAL_MS) return
startNetworkRtkService(source)
}
private fun bootstrapAllPositioning(reason: String) {
val now = System.currentTimeMillis()
if (now - lastRtkBootstrapAtMs < RTK_BOOTSTRAP_RETRY_INTERVAL_MS) return
lastRtkBootstrapAtMs = now
Log.d(TelemetryLogTag, "bootstrap positioning reason=$reason")
enableRtkModuleIfPossible()
runCatching {
rtkCenter.setRTKMaintainAccuracyEnabled(true, object : CommonCallbacks.CompletionCallback {
override fun onSuccess() {
Log.d(TelemetryLogTag, "enable RTK maintain accuracy success")
}
override fun onFailure(error: IDJIError) {
Log.w(TelemetryLogTag, "enable RTK maintain accuracy failed: $error")
}
})
}.onFailure { error ->
Log.w(TelemetryLogTag, "enable RTK maintain accuracy exception: ${error.message}")
}
setPreferredNetworkRtkSource(lastRtkSystemState?.rtkReferenceStationSource ?: RTKReferenceStationSource.UNKNOWN)
}
private fun enableRtkModuleIfPossible() {
if (rtkEnableInProgress || _state.value.motorsOn) return
if (rtkEnableInProgress) return
rtkEnableInProgress = true
Log.d(TelemetryLogTag, "enable RTK module")
rtkCenter.setAircraftRTKModuleEnabled(true, object : CommonCallbacks.CompletionCallback {
@@ -814,6 +893,7 @@ class TelemetryRepository(
rtkEnableInProgress = false
Log.d(TelemetryLogTag, "enable RTK module success")
rtkCenter.setRTKMaintainAccuracyEnabled(true, null)
setPreferredNetworkRtkSource(lastRtkSystemState?.rtkReferenceStationSource ?: RTKReferenceStationSource.UNKNOWN)
}
override fun onFailure(error: IDJIError) {
@@ -824,7 +904,11 @@ class TelemetryRepository(
}
private fun setPreferredNetworkRtkSource(currentSource: RTKReferenceStationSource) {
if (rtkSourceSetInProgress || rtkStartInProgress) return
if (rtkSourceSetInProgress) return
if (currentSource == DefaultNetworkRtkSource) {
startNetworkRtkService(DefaultNetworkRtkSource)
return
}
rtkSourceSetInProgress = true
Log.d(
TelemetryLogTag,
@@ -850,21 +934,54 @@ class TelemetryRepository(
}
private fun startNetworkRtkService(source: RTKReferenceStationSource) {
val now = System.currentTimeMillis()
if (now - lastRtkStartAtMs < 1_000L && source == lastRtkStartSource) {
Log.d(TelemetryLogTag, "skip duplicate RTK network service source=$source")
return
}
rtkStartInProgress = true
rtkServiceStarted = false
lastRtkStartAtMs = System.currentTimeMillis()
lastRtkStartAtMs = now
lastRtkStartSource = source
rtkCenter.setRTKMaintainAccuracyEnabled(true, null)
Log.d(TelemetryLogTag, "start RTK network service source=$source coordinate=$DefaultNetworkRtkCoordinateSystem")
when (source) {
RTKReferenceStationSource.QX_NETWORK_SERVICE -> {
rtkCenter.qxrtkManager.stopNetworkRTKService(object : CommonCallbacks.CompletionCallback {
override fun onSuccess() {
rtkCenter.qxrtkManager.startNetworkRTKService(DefaultNetworkRtkCoordinateSystem, rtkStartCallback("QX"))
}
override fun onFailure(error: IDJIError) {
Log.w(TelemetryLogTag, "stop QX RTK service before restart failed: $error")
rtkCenter.qxrtkManager.startNetworkRTKService(DefaultNetworkRtkCoordinateSystem, rtkStartCallback("QX"))
}
})
}
RTKReferenceStationSource.NTRIP_NETWORK_SERVICE -> {
rtkCenter.cmccrtkManager.stopNetworkRTKService(object : CommonCallbacks.CompletionCallback {
override fun onSuccess() {
rtkCenter.cmccrtkManager.startNetworkRTKService(DefaultNetworkRtkCoordinateSystem, rtkStartCallback("NTRIP"))
}
override fun onFailure(error: IDJIError) {
Log.w(TelemetryLogTag, "stop NTRIP RTK service before restart failed: $error")
rtkCenter.cmccrtkManager.startNetworkRTKService(DefaultNetworkRtkCoordinateSystem, rtkStartCallback("NTRIP"))
}
})
}
RTKReferenceStationSource.CUSTOM_NETWORK_SERVICE -> {
rtkCenter.customRTKManager.stopNetworkRTKService(object : CommonCallbacks.CompletionCallback {
override fun onSuccess() {
rtkCenter.customRTKManager.startNetworkRTKService(rtkStartCallback("CUSTOM"))
}
override fun onFailure(error: IDJIError) {
Log.w(TelemetryLogTag, "stop CUSTOM RTK service before restart failed: $error")
rtkCenter.customRTKManager.startNetworkRTKService(rtkStartCallback("CUSTOM"))
}
})
}
else -> {
rtkStartInProgress = false
}
@@ -890,6 +1007,7 @@ class TelemetryRepository(
private fun startAndroidLocationFallback() {
val manager = locationManager ?: return
val context = appContext ?: return
Log.d(TelemetryLogTag, "start RC Android location fallback")
if (
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED
@@ -898,7 +1016,20 @@ class TelemetryRepository(
return
}
(
runCatching {
LocationUtil.getLastLocation()?.let { location ->
Log.d(
TelemetryLogTag,
"LocationUtilLastLocation provider=${location.provider} lat=${location.latitude} " +
"lon=${location.longitude} accuracy=${location.accuracy}"
)
updateRcLocationFromAndroid(location)
}
}.onFailure { error ->
Log.w(TelemetryLogTag, "read DJI LocationUtil last location failed: ${error.message}")
}
val providers = (
listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER, LocationManager.PASSIVE_PROVIDER) +
manager.allProviders
)
@@ -907,7 +1038,11 @@ class TelemetryRepository(
provider == LocationManager.PASSIVE_PROVIDER ||
runCatching { manager.isProviderEnabled(provider) }.getOrDefault(false)
}
.forEach { provider ->
Log.d(TelemetryLogTag, "RC Android location providers=$providers all=${manager.allProviders}")
if (providers.isEmpty()) {
Log.w(TelemetryLogTag, "no enabled RC Android location providers")
}
providers.forEach { provider ->
runCatching {
manager.getLastKnownLocation(provider)?.let(::updateRcLocationFromAndroid)
manager.requestLocationUpdates(
@@ -970,7 +1105,7 @@ private fun isValidCoordinate(latitude: Double?, longitude: Double?): Boolean {
}
private val DefaultNetworkRtkSource = RTKReferenceStationSource.NTRIP_NETWORK_SERVICE
private val DefaultNetworkRtkCoordinateSystem = CoordinateSystem.CGCS2000
private val DefaultNetworkRtkCoordinateSystem = CoordinateSystem.WGS84
private fun RTKReferenceStationSource.isNetworkRtkSource(): Boolean =
this == RTKReferenceStationSource.QX_NETWORK_SERVICE ||

View File

@@ -1,6 +1,7 @@
package com.zklh.dronecontroller.ui
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Paint
import android.graphics.Typeface
import android.util.Log
@@ -148,6 +149,7 @@ import androidx.compose.ui.zIndex
import com.zklh.dronecontroller.core.cloud.CloudCommandExecutor
import com.zklh.dronecontroller.core.cloud.CloudLoginClient
import com.zklh.dronecontroller.core.cloud.CloudLoginRequest
import com.zklh.dronecontroller.core.cloud.CloudMediaUploadService
import com.zklh.dronecontroller.core.cloud.CloudMqttService
import com.zklh.dronecontroller.core.cloud.CloudMqttState
import com.zklh.dronecontroller.core.flight.FlightControlService
@@ -201,6 +203,19 @@ private const val DefaultVirtualStickSpeedLevel = 1.0
private const val CameraLogTag = "ZklhCamera"
private const val CloudLogTag = "ZklhCloud"
private const val FlightControlLogTag = "ZklhFlightControl"
private const val DefaultThirdCloudUrl = "http://uav.zklhjs.com/p"
private const val DefaultThirdCloudTenantName = "中科联航"
private const val DefaultThirdCloudUsername = "zklh"
private const val DefaultThirdCloudPassword = "zklh@123"
private const val SimulatorDefaultLatitude = 31.230402481870684
private const val SimulatorDefaultLongitude = 121.47369280461436
private const val CloudPrefsName = "zklh_third_cloud"
private const val CloudPrefsAutoConnect = "auto_connect"
private const val CloudPrefsUrl = "url"
private const val CloudPrefsTenantName = "tenant_name"
private const val CloudPrefsUsername = "username"
private const val CloudPrefsPassword = "password"
private const val CloudPrefsMqttAddress = "mqtt_address"
private enum class PilotPage {
Home,
@@ -222,10 +237,78 @@ private enum class CameraQuickAction {
Panorama
}
private data class SavedCloudLoginConfig(
val url: String = DefaultThirdCloudUrl,
val tenantName: String = DefaultThirdCloudTenantName,
val username: String = DefaultThirdCloudUsername,
val password: String = DefaultThirdCloudPassword,
val mqttAddress: String = "",
val autoConnect: Boolean = false
)
private fun Context.readSavedCloudLoginConfig(): SavedCloudLoginConfig {
val prefs = getSharedPreferences(CloudPrefsName, Context.MODE_PRIVATE)
return SavedCloudLoginConfig(
url = prefs.getString(CloudPrefsUrl, DefaultThirdCloudUrl).orEmpty().ifBlank { DefaultThirdCloudUrl },
tenantName = prefs.getString(CloudPrefsTenantName, DefaultThirdCloudTenantName).orEmpty()
.ifBlank { DefaultThirdCloudTenantName },
username = prefs.getString(CloudPrefsUsername, DefaultThirdCloudUsername).orEmpty().ifBlank { DefaultThirdCloudUsername },
password = prefs.getString(CloudPrefsPassword, DefaultThirdCloudPassword).orEmpty().ifBlank { DefaultThirdCloudPassword },
mqttAddress = prefs.getString(CloudPrefsMqttAddress, "").orEmpty(),
autoConnect = prefs.getBoolean(CloudPrefsAutoConnect, true)
)
}
private fun Context.saveCloudLoginConfig(request: CloudLoginRequest, mqttAddress: String) {
getSharedPreferences(CloudPrefsName, Context.MODE_PRIVATE)
.edit()
.putBoolean(CloudPrefsAutoConnect, true)
.putString(CloudPrefsUrl, request.baseUrl)
.putString(CloudPrefsTenantName, request.tenantName)
.putString(CloudPrefsUsername, request.username)
.putString(CloudPrefsPassword, request.password)
.putString(CloudPrefsMqttAddress, mqttAddress)
.apply()
}
private fun Context.disableCloudAutoConnect() {
getSharedPreferences(CloudPrefsName, Context.MODE_PRIVATE)
.edit()
.putBoolean(CloudPrefsAutoConnect, false)
.apply()
}
private data class SimulatorStartCoordinate(
val latitude: Double,
val longitude: Double,
val source: String
)
private fun simulatorStartCoordinate(telemetry: TelemetrySnapshot): SimulatorStartCoordinate =
when {
isUsableCoordinate(telemetry.latitude, telemetry.longitude) ->
SimulatorStartCoordinate(telemetry.latitude, telemetry.longitude, "飞机坐标")
isUsableCoordinate(telemetry.homeLatitude, telemetry.homeLongitude) ->
SimulatorStartCoordinate(telemetry.homeLatitude, telemetry.homeLongitude, "返航点坐标")
isUsableCoordinate(telemetry.rcLatitude, telemetry.rcLongitude) ->
SimulatorStartCoordinate(telemetry.rcLatitude, telemetry.rcLongitude, "遥控器坐标")
else ->
SimulatorStartCoordinate(SimulatorDefaultLatitude, SimulatorDefaultLongitude, "默认坐标")
}
private fun isUsableCoordinate(latitude: Double, longitude: Double): Boolean =
!latitude.isNaN() &&
!longitude.isNaN() &&
(latitude != 0.0 || longitude != 0.0) &&
latitude in -90.0..90.0 &&
longitude in -180.0..180.0
@Composable
fun DroneControllerScreen() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val appContext = context.applicationContext
val savedCloudLoginConfig = remember(appContext) { appContext.readSavedCloudLoginConfig() }
val sdkState by DroneSdkManager.state.collectAsState()
val flightControl = remember { FlightControlService() }
val virtualStick = remember { VirtualStickService() }
@@ -238,13 +321,26 @@ fun DroneControllerScreen() {
val warningRepository = remember { DroneWarningRepository() }
val cloudLoginClient = remember { CloudLoginClient() }
val cloudMqttService = remember { CloudMqttService(cloudLoginClient) }
val cloudCommandExecutor = remember(flightControl, cameraMedia, flyToService, liveStreaming, virtualStick, waypointMission) {
CloudCommandExecutor(flightControl, cameraMedia, flyToService, liveStreaming, virtualStick, waypointMission)
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,
cameraMedia = cameraMedia,
flyToService = flyToService,
liveStreaming = liveStreaming,
virtualStick = virtualStick,
waypointMission = waypointMission,
mediaUpload = cloudMediaUpload,
telemetryProvider = { latestTelemetry }
)
}
val stickStatus by virtualStick.status.collectAsState()
val simulatorStatus by simulator.status.collectAsState()
val warningState by warningRepository.state.collectAsState()
val telemetry by telemetryRepository.state.collectAsState()
val cloudState by cloudMqttService.state.collectAsState()
val liveState by liveStreaming.state.collectAsState()
@@ -260,16 +356,17 @@ fun DroneControllerScreen() {
var returnAltitude by remember { mutableStateOf(100) }
var maxAltitude by remember { mutableStateOf(500) }
var farLimitEnabled by remember { mutableStateOf(false) }
var thirdCloudUrl by remember { mutableStateOf("http://uav.zklhjs.com/p") }
var thirdCloudTenantName by remember { mutableStateOf("中科联航") }
var thirdCloudUsername by remember { mutableStateOf("zklh") }
var thirdCloudPassword by remember { mutableStateOf("zklh@123") }
var thirdCloudMqttAddress by remember { mutableStateOf("") }
var thirdCloudUrl by remember { mutableStateOf(savedCloudLoginConfig.url) }
var thirdCloudTenantName by remember { mutableStateOf(savedCloudLoginConfig.tenantName) }
var thirdCloudUsername by remember { mutableStateOf(savedCloudLoginConfig.username) }
var thirdCloudPassword by remember { mutableStateOf(savedCloudLoginConfig.password) }
var thirdCloudMqttAddress by remember { mutableStateOf(savedCloudLoginConfig.mqttAddress) }
var thirdCloudConnected by remember { mutableStateOf(false) }
var thirdCloudLoginVisible by remember { mutableStateOf(false) }
var thirdCloudStatusMessage by remember { mutableStateOf("") }
var thirdCloudStatusIsError by remember { mutableStateOf(false) }
var thirdCloudReloadTick by remember { mutableStateOf(0) }
var thirdCloudAutoLoginStarted by remember { mutableStateOf(false) }
var cameraRecording by remember { mutableStateOf(false) }
var laserFillLightEnabled by remember { mutableStateOf(false) }
var landingConfirmationAutoSent by remember { mutableStateOf(false) }
@@ -341,6 +438,15 @@ fun DroneControllerScreen() {
cloudMqttService.updateTelemetry(telemetry)
}
LaunchedEffect(
warningState,
cloudState.connected,
sdkState.remoteControllerSerialNumber,
sdkState.aircraftSerialNumber
) {
cloudMqttService.updateWarnings(warningState)
}
LaunchedEffect(cloudState.connected) {
thirdCloudConnected = cloudState.connected
}
@@ -576,7 +682,17 @@ fun DroneControllerScreen() {
Log.e(CameraLogTag, "takePhoto exception", error)
DjiCommandResult.failed("拍照异常:${error.message ?: error.javaClass.simpleName}")
}
commandMessage = result.message
val finalResult = if (result.success) {
val uploadResult = cloudMediaUpload.uploadLatestMedia(cameraMedia.currentCameraIndex())
if (uploadResult.success) {
DjiCommandResult.ok("${result.message}${uploadResult.message}")
} else {
DjiCommandResult.failed("${result.message}${uploadResult.message}")
}
} else {
result
}
commandMessage = finalResult.message
Log.d(CameraLogTag, "takePhoto success=${result.success}, message=${result.message}")
laserFillLightEnabled = cameraMedia.isLaserFillLightEnabled()
toast(commandMessage)
@@ -592,7 +708,17 @@ fun DroneControllerScreen() {
Log.e(CameraLogTag, "toggleRecord exception", error)
DjiCommandResult.failed("录像异常:${error.message ?: error.javaClass.simpleName}")
}
commandMessage = result.message
val finalResult = if (result.success && shouldStop) {
val uploadResult = cloudMediaUpload.uploadLatestMedia(cameraMedia.currentCameraIndex())
if (uploadResult.success) {
DjiCommandResult.ok("${result.message}${uploadResult.message}")
} else {
DjiCommandResult.failed("${result.message}${uploadResult.message}")
}
} else {
result
}
commandMessage = finalResult.message
Log.d(CameraLogTag, "toggleRecord shouldStop=$shouldStop, success=${result.success}, message=${result.message}")
cameraRecording = if (result.success) !shouldStop else cameraMedia.isRecording()
laserFillLightEnabled = cameraMedia.isLaserFillLightEnabled()
@@ -635,9 +761,13 @@ fun DroneControllerScreen() {
}
val enableSimulator: () -> Unit = {
scope.launch {
val latitude = telemetry.latitude.takeIf { it != 0.0 } ?: 31.2304
val longitude = telemetry.longitude.takeIf { it != 0.0 } ?: 121.4737
commandMessage = simulator.enable(latitude, longitude).message
val start = simulatorStartCoordinate(telemetry)
val result = simulator.enable(start.latitude, start.longitude)
commandMessage = if (start.source == "默认坐标") {
"${result.message}(未获取到定位,使用默认坐标)"
} else {
"${result.message}${start.source}"
}
toast(commandMessage)
}
}
@@ -654,7 +784,7 @@ fun DroneControllerScreen() {
toast(commandMessage)
}
}
val loginThirdCloud: () -> Unit = {
fun loginThirdCloud(showToast: Boolean = true) {
scope.launch {
val request = CloudLoginRequest(
baseUrl = thirdCloudUrl,
@@ -667,28 +797,43 @@ fun DroneControllerScreen() {
commandMessage = "正在登录第三方云"
thirdCloudStatusMessage = commandMessage
thirdCloudStatusIsError = false
toast(commandMessage)
if (showToast) toast(commandMessage)
cloudLoginClient.login(request)
.onSuccess { session ->
thirdCloudUrl = session.baseUrl
if (thirdCloudMqttAddress.isBlank()) {
thirdCloudMqttAddress = session.mqttAddress
}
appContext.saveCloudLoginConfig(request.copy(baseUrl = session.baseUrl), session.mqttAddress)
commandMessage = "第三方云登录成功,正在连接 MQTT"
thirdCloudStatusMessage = commandMessage
thirdCloudStatusIsError = false
toast(commandMessage)
if (showToast) toast(commandMessage)
cloudMqttService.connect(session, cloudCommandExecutor)
}
.onFailure { error ->
commandMessage = "第三方云登录失败:${error.message ?: error.javaClass.simpleName}"
thirdCloudStatusMessage = commandMessage
thirdCloudStatusIsError = true
toast(commandMessage)
if (showToast) toast(commandMessage)
}
}
}
val loginThirdCloudFromUi: () -> Unit = {
loginThirdCloud(showToast = true)
}
LaunchedEffect(savedCloudLoginConfig.autoConnect) {
if (savedCloudLoginConfig.autoConnect && !thirdCloudAutoLoginStarted) {
thirdCloudAutoLoginStarted = true
thirdCloudStatusMessage = "正在自动连接第三方云"
thirdCloudStatusIsError = false
delay(600)
loginThirdCloud(showToast = false)
}
}
when (page) {
PilotPage.Home -> HomeScreen(
sdkState = sdkState,
@@ -751,7 +896,7 @@ fun DroneControllerScreen() {
onCloseWeb = {
thirdCloudLoginVisible = false
},
onLogin = loginThirdCloud,
onLogin = loginThirdCloudFromUi,
onConnected = {
if (!thirdCloudConnected) {
toast("网页登录成功,请确认 MQTT 接入状态")
@@ -765,11 +910,13 @@ fun DroneControllerScreen() {
} else {
cloudMqttService.updateDeviceState(sdkState)
cloudMqttService.updateTelemetry(telemetry)
cloudMqttService.updateWarnings(warningState)
toast("已刷新第三方云接入状态")
}
},
onLogout = {
cloudMqttService.disconnect()
appContext.disableCloudAutoConnect()
CookieManager.getInstance().removeAllCookies(null)
CookieManager.getInstance().flush()
WebStorage.getInstance().deleteAllData()