From 52b5861bed2e2a0edeef12accc1bfbb7a6231e79 Mon Sep 17 00:00:00 2001 From: zyp <1480083907@qq.com> Date: Thu, 9 Jul 2026 18:19:02 +0800 Subject: [PATCH] update --- .../core/cloud/CloudCommandExecutor.kt | 442 ++++++++++++++++-- .../core/cloud/CloudCommandRequest.kt | 21 +- .../core/cloud/CloudMediaUploadService.kt | 305 ++++++++++++ .../core/cloud/CloudMqttService.kt | 337 +++++++++++-- .../diagnostics/DroneWarningRepository.kt | 103 +++- .../core/flight/FlightControlService.kt | 25 + .../core/flight/FlyToService.kt | 194 +++++++- .../core/gimbal/GimbalControlService.kt | 7 +- .../core/livestream/LiveStreamingService.kt | 17 +- .../core/media/CameraControlService.kt | 171 ++++++- .../core/media/CameraMediaService.kt | 161 +------ .../core/simulator/SimulatorService.kt | 30 +- .../core/telemetry/TelemetryRepository.kt | 169 ++++++- .../ui/DroneControllerScreen.kt | 183 +++++++- 14 files changed, 1841 insertions(+), 324 deletions(-) create mode 100644 sample/src/main/java/com/zklh/dronecontroller/core/cloud/CloudMediaUploadService.kt diff --git a/sample/src/main/java/com/zklh/dronecontroller/core/cloud/CloudCommandExecutor.kt b/sample/src/main/java/com/zklh/dronecontroller/core/cloud/CloudCommandExecutor.kt index 290c4c3..ea28e16 100644 --- a/sample/src/main/java/com/zklh/dronecontroller/core/cloud/CloudCommandExecutor.kt +++ b/sample/src/main/java/com/zklh/dronecontroller/core/cloud/CloudCommandExecutor.kt @@ -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() 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_point;MSDK 端不再用虚拟杆兜底爬升" + ) + } + 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 { @@ -337,10 +442,88 @@ class CloudCommandExecutor( data.optBooleanAny("enable", "enabled") -> "ir" else -> "wide" } - } + } 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 { + 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 { + 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 diff --git a/sample/src/main/java/com/zklh/dronecontroller/core/cloud/CloudCommandRequest.kt b/sample/src/main/java/com/zklh/dronecontroller/core/cloud/CloudCommandRequest.kt index 993d750..28adb60 100644 --- a/sample/src/main/java/com/zklh/dronecontroller/core/cloud/CloudCommandRequest.kt +++ b/sample/src/main/java/com/zklh/dronecontroller/core/cloud/CloudCommandRequest.kt @@ -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 +} diff --git a/sample/src/main/java/com/zklh/dronecontroller/core/cloud/CloudMediaUploadService.kt b/sample/src/main/java/com/zklh/dronecontroller/core/cloud/CloudMediaUploadService.kt new file mode 100644 index 0000000..73344cd --- /dev/null +++ b/sample/src/main/java/com/zklh/dronecontroller/core/cloud/CloudMediaUploadService.kt @@ -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 { + 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 = + 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) { + 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 = + 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" +} diff --git a/sample/src/main/java/com/zklh/dronecontroller/core/cloud/CloudMqttService.kt b/sample/src/main/java/com/zklh/dronecontroller/core/cloud/CloudMqttService.kt index 7f99d76..6215445 100644 --- a/sample/src/main/java/com/zklh/dronecontroller/core/cloud/CloudMqttService.kt +++ b/sample/src/main/java/com/zklh/dronecontroller/core/cloud/CloudMqttService.kt @@ -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) - liveCapacityKey = key - Log.d(CloudMqttLogTag, "live capacity published rc=$rcSn aircraft=$aircraftSn") + if (publish("thing/product/$rcSn/state", payload, qos = 1)) { + liveCapacityKey = key + 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 - lastOsdAt = now + 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,23 +740,146 @@ class CloudMqttService( publish(replyTopic, payload, qos = 1) } - private fun publish(topic: String, payload: JSONObject, qos: Int) { - val mqttClient = client ?: return - if (!mqttClient.isConnected) return - runCatching { - val message = MqttMessage(payload.toString().toByteArray(Charsets.UTF_8)).apply { - this.qos = qos - isRetained = false - } - 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()) } + 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 + isRetained = false + } + mqttClient.publish(topic, message) + Log.d(CloudMqttLogTag, "published topic=$topic payload=$payload") + }.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() + 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 { diff --git a/sample/src/main/java/com/zklh/dronecontroller/core/diagnostics/DroneWarningRepository.kt b/sample/src/main/java/com/zklh/dronecontroller/core/diagnostics/DroneWarningRepository.kt index 97a47f8..7677a86 100644 --- a/sample/src/main/java/com/zklh/dronecontroller/core/diagnostics/DroneWarningRepository.kt +++ b/sample/src/main/java/com/zklh/dronecontroller/core/diagnostics/DroneWarningRepository.kt @@ -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 = emptyList(), + val items: List = 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 = _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 = DeviceHealthManager.getInstance().currentDJIDeviceHealthInfos + ) { + val messages = healthInfos .map { it.toMessage() } + .plus(DeviceStatusManager.getInstance().currentDJIDeviceStatus.toMessage()) + .filterNotNull() .filter { it.text.isNotBlank() } + .filterNot { it.isNormalNotice() } .sortedWith(compareByDescending { 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,14 +111,68 @@ 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) { diff --git a/sample/src/main/java/com/zklh/dronecontroller/core/flight/FlightControlService.kt b/sample/src/main/java/com/zklh/dronecontroller/core/flight/FlightControlService.kt index adcab47..cece196 100644 --- a/sample/src/main/java/com/zklh/dronecontroller/core/flight/FlightControlService.kt +++ b/sample/src/main/java/com/zklh/dronecontroller/core/flight/FlightControlService.kt @@ -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)) + }) + } + } } diff --git a/sample/src/main/java/com/zklh/dronecontroller/core/flight/FlyToService.kt b/sample/src/main/java/com/zklh/dronecontroller/core/flight/FlyToService.kt index fe8ab27..9b78a4d 100644 --- a/sample/src/main/java/com/zklh/dronecontroller/core/flight/FlyToService.kt +++ b/sample/src/main/java/com/zklh/dronecontroller/core/flight/FlyToService.kt @@ -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 = _status.asStateFlow() + + private val missionInfoListener = object : IMissionInfoListener { + 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 { + 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, - 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)) } } diff --git a/sample/src/main/java/com/zklh/dronecontroller/core/gimbal/GimbalControlService.kt b/sample/src/main/java/com/zklh/dronecontroller/core/gimbal/GimbalControlService.kt index ebaad60..59a1947 100644 --- a/sample/src/main/java/com/zklh/dronecontroller/core/gimbal/GimbalControlService.kt +++ b/sample/src/main/java/com/zklh/dronecontroller/core/gimbal/GimbalControlService.kt @@ -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( ) } } - diff --git a/sample/src/main/java/com/zklh/dronecontroller/core/livestream/LiveStreamingService.kt b/sample/src/main/java/com/zklh/dronecontroller/core/livestream/LiveStreamingService.kt index 07b268a..f7dbabd 100644 --- a/sample/src/main/java/com/zklh/dronecontroller/core/livestream/LiveStreamingService.kt +++ b/sample/src/main/java/com/zklh/dronecontroller/core/livestream/LiveStreamingService.kt @@ -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 + } + is JSONArray -> { + val nested = value.arrayValueAsString() + if (nested.isNotBlank()) return nested + } + } } - return optString(length() - 1, "") + return "" } private fun JSONObject.optIntAny(defaultValue: Int, vararg names: String): Int { diff --git a/sample/src/main/java/com/zklh/dronecontroller/core/media/CameraControlService.kt b/sample/src/main/java/com/zklh/dronecontroller/core/media/CameraControlService.kt index 40ceb57..a2e0c53 100644 --- a/sample/src/main/java/com/zklh/dronecontroller/core/media/CameraControlService.kt +++ b/sample/src/main/java/com/zklh/dronecontroller/core/media/CameraControlService.kt @@ -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, value: Double, @@ -66,6 +204,23 @@ class CameraControlService( } ) } + + private suspend fun runAction( + key: DJIKey.ActionKey, + 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 + } diff --git a/sample/src/main/java/com/zklh/dronecontroller/core/media/CameraMediaService.kt b/sample/src/main/java/com/zklh/dronecontroller/core/media/CameraMediaService.kt index 1ec6e4e..63f99d3 100644 --- a/sample/src/main/java/com/zklh/dronecontroller/core/media/CameraMediaService.kt +++ b/sample/src/main/java/com/zklh/dronecontroller/core/media/CameraMediaService.kt @@ -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.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" - } } diff --git a/sample/src/main/java/com/zklh/dronecontroller/core/simulator/SimulatorService.kt b/sample/src/main/java/com/zklh/dronecontroller/core/simulator/SimulatorService.kt index 7ed8583..9ac853c 100644 --- a/sample/src/main/java/com/zklh/dronecontroller/core/simulator/SimulatorService.kt +++ b/sample/src/main/java/com/zklh/dronecontroller/core/simulator/SimulatorService.kt @@ -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 - } } diff --git a/sample/src/main/java/com/zklh/dronecontroller/core/telemetry/TelemetryRepository.kt b/sample/src/main/java/com/zklh/dronecontroller/core/telemetry/TelemetryRepository.kt index d34df8b..c86870a 100644 --- a/sample/src/main/java/com/zklh/dronecontroller/core/telemetry/TelemetryRepository.kt +++ b/sample/src/main/java/com/zklh/dronecontroller/core/telemetry/TelemetryRepository.kt @@ -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,20 +934,53 @@ 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.startNetworkRTKService(DefaultNetworkRtkCoordinateSystem, rtkStartCallback("QX")) + 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.startNetworkRTKService(DefaultNetworkRtkCoordinateSystem, rtkStartCallback("NTRIP")) + 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.startNetworkRTKService(rtkStartCallback("CUSTOM")) + 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 || diff --git a/sample/src/main/java/com/zklh/dronecontroller/ui/DroneControllerScreen.kt b/sample/src/main/java/com/zklh/dronecontroller/ui/DroneControllerScreen.kt index 5c6b1aa..3173197 100644 --- a/sample/src/main/java/com/zklh/dronecontroller/ui/DroneControllerScreen.kt +++ b/sample/src/main/java/com/zklh/dronecontroller/ui/DroneControllerScreen.kt @@ -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()