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

View File

@@ -9,9 +9,11 @@ import com.zklh.dronecontroller.core.media.CameraControlService
import com.zklh.dronecontroller.core.media.CameraMediaService import com.zklh.dronecontroller.core.media.CameraMediaService
import com.zklh.dronecontroller.core.mission.WaypointMissionService import com.zklh.dronecontroller.core.mission.WaypointMissionService
import com.zklh.dronecontroller.core.msdk.DjiCommandResult 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.StickPosition
import com.zklh.dronecontroller.core.virtualstick.VirtualStickService import com.zklh.dronecontroller.core.virtualstick.VirtualStickService
import dji.sdk.keyvalue.value.flightcontroller.FlyToMode import dji.sdk.keyvalue.value.flightcontroller.FlyToMode
import dji.sdk.keyvalue.value.flightcontroller.LookAtMode
import dji.v5.manager.aircraft.virtualstick.Stick import dji.v5.manager.aircraft.virtualstick.Stick
import java.io.File import java.io.File
import java.net.URL import java.net.URL
@@ -28,6 +30,10 @@ import org.json.JSONObject
private const val CloudCommandLogTag = "ZklhCloudCommand" private const val CloudCommandLogTag = "ZklhCloudCommand"
private const val DefaultFlyToHeightMeters = 20.0 private const val DefaultFlyToHeightMeters = 20.0
private const val MinimumTakeoffTargetHeightMeters = 2.0
private const val CoordPi = 3.1415926535897932384626
private const val CoordSemiMajorAxis = 6378245.0
private const val CoordEccentricity = 0.00669342162296594323
class CloudCommandExecutor( class CloudCommandExecutor(
private val flightControl: FlightControlService, private val flightControl: FlightControlService,
@@ -37,12 +43,15 @@ class CloudCommandExecutor(
private val virtualStick: VirtualStickService, private val virtualStick: VirtualStickService,
private val waypointMission: WaypointMissionService, private val waypointMission: WaypointMissionService,
private val cameraControl: CameraControlService = CameraControlService(), 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 scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val virtualStickEnabling = AtomicBoolean(false) private val virtualStickEnabling = AtomicBoolean(false)
private val preparedMissionIds = ConcurrentHashMap<String, String>() private val preparedMissionIds = ConcurrentHashMap<String, String>()
private var lastStickAt = 0L private var lastStickAt = 0L
private var lastStickLogAt = 0L
private var stickTimeoutJob: Job? = null private var stickTimeoutJob: Job? = null
suspend fun execute(request: CloudCommandRequest): DjiCommandResult { suspend fun execute(request: CloudCommandRequest): DjiCommandResult {
@@ -73,8 +82,9 @@ class CloudCommandExecutor(
command == "drc_mode_enter" -> enterDrcMode(data) command == "drc_mode_enter" -> enterDrcMode(data)
command == "drc_mode_exit" -> exitDrcMode() command == "drc_mode_exit" -> exitDrcMode()
command in StickCommands -> executeStickControl(data) command in StickCommands -> executeStickControl(data)
command in TakeoffToPointCommands -> startFlyTo(data)
command in TakeoffCommands && data.hasFlyToTarget() -> 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 StopTakeoffCommands -> flightControl.stopTakeoff()
command in GoHomeCommands -> flightControl.startGoHome() command in GoHomeCommands -> flightControl.startGoHome()
command in StopGoHomeCommands -> flightControl.stopGoHome() command in StopGoHomeCommands -> flightControl.stopGoHome()
@@ -82,23 +92,33 @@ class CloudCommandExecutor(
command in ForceLandingCommands -> forceLanding() command in ForceLandingCommands -> forceLanding()
command in StopLandingCommands -> flightControl.stopAutoLanding() command in StopLandingCommands -> flightControl.stopAutoLanding()
command in ConfirmLandingCommands -> flightControl.confirmLanding() command in ConfirmLandingCommands -> flightControl.confirmLanding()
command in PhotoCommands -> cameraMedia.takePhoto() command in PhotoCommands -> takePhotoAndUpload()
command in RecordStartCommands -> cameraMedia.startRecord() command in RecordStartCommands -> cameraMedia.startRecord()
command in RecordStopCommands -> cameraMedia.stopRecord() command in RecordStopCommands -> stopRecordAndUpload()
command in RecordToggleCommands -> cameraMedia.toggleRecord() command in RecordToggleCommands -> toggleRecordAndMaybeUpload()
command in CameraModeCommands -> setCameraMode(data) command in CameraModeCommands -> setCameraMode(data)
command in PanoramaCommands -> cameraMedia.shootPanorama() command in PanoramaCommands -> cameraMedia.shootPanorama()
command in LaserFillLightCommands -> setLaserFillLight(data) command in LaserFillLightCommands -> setLaserFillLight(data)
command in LaserMeasureCommands -> setLaserMeasure(data) command in LaserMeasureCommands -> setLaserMeasure(data)
command in FlyToUpdateCommands -> updateFlyTo(data)
command in FlyToCommands -> startFlyTo(data) command in FlyToCommands -> startFlyTo(data)
command in StopFlyToCommands -> flyToService.stopFlyTo() command in StopFlyToCommands -> stopFlyTo()
command in WaylinePrepareCommands -> prepareWayline(data) command in WaylinePrepareCommands -> prepareWayline(data)
command in WaylineExecuteCommands -> executeWayline(data) command in WaylineExecuteCommands -> executeWayline(data)
command in WaylinePauseCommands -> waypointMission.pauseMission() command in WaylinePauseCommands -> waypointMission.pauseMission()
command in WaylineRecoveryCommands -> waypointMission.resumeMission() command in WaylineRecoveryCommands -> waypointMission.resumeMission()
command in WaylineUndoCommands -> stopWayline(data) command in WaylineUndoCommands -> stopWayline(data)
command in CameraZoomCommands -> setCameraZoom(data) command in CameraZoomCommands -> setCameraZoom(data)
command in LinkageZoomCommands -> setLinkageZoom(data)
command in LensSwitchCommands -> switchLens(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 GimbalDragCommands -> rotateGimbal(data)
command in GimbalResetCommands -> gimbalControl.reset() command in GimbalResetCommands -> gimbalControl.reset()
command in LiveStartCommands -> liveStreaming.startFromCloud(data) command in LiveStartCommands -> liveStreaming.startFromCloud(data)
@@ -109,6 +129,7 @@ class CloudCommandExecutor(
} }
private suspend fun enterDrcMode(data: JSONObject): DjiCommandResult { private suspend fun enterDrcMode(data: JSONObject): DjiCommandResult {
stopFlyToForManualControl()
val result = ensureVirtualStickEnabled() val result = ensureVirtualStickEnabled()
if (result.success) { if (result.success) {
virtualStick.setSpeedLevel(data.optDoubleAny("speed_level", "speedLevel").takeIf { it > 0.0 } ?: 15.0) virtualStick.setSpeedLevel(data.optDoubleAny("speed_level", "speedLevel").takeIf { it > 0.0 } ?: 15.0)
@@ -136,6 +157,7 @@ class CloudCommandExecutor(
private suspend fun executeStickControl( private suspend fun executeStickControl(
data: JSONObject data: JSONObject
): DjiCommandResult { ): DjiCommandResult {
stopFlyToForManualControl()
val enabled = ensureVirtualStickEnabled() val enabled = ensureVirtualStickEnabled()
if (!enabled.success) return enabled if (!enabled.success) return enabled
val speedLevel = data.optDoubleAny("speed_level", "speedLevel", "speed") val speedLevel = data.optDoubleAny("speed_level", "speedLevel", "speed")
@@ -149,12 +171,48 @@ class CloudCommandExecutor(
} else { } else {
data.toProtocolStickPosition() 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) virtualStick.sendStickPosition(position)
lastStickAt = System.currentTimeMillis() lastStickAt = now
scheduleStickTimeout() scheduleStickTimeout()
return DjiCommandResult.ok("杆量控制已下发") return DjiCommandResult.ok("杆量控制已下发")
} }
private suspend fun stopFlyToForManualControl() {
if (!flyToService.isMissionActive()) return
val stopResult = flyToService.stopFlyTo()
if (!stopResult.success) {
Log.w(CloudCommandLogTag, "stop flyTo before manual control failed: ${stopResult.message}")
}
}
private suspend fun startTakeoff(data: JSONObject): DjiCommandResult {
val targetHeight = data.optDoubleAnyOrNull(
"targetHeight",
"target_height",
"height",
"altitude",
"alt",
"commander_flight_height",
"commanderFlightHeight"
)?.takeIf { it > MinimumTakeoffTargetHeightMeters }
if (targetHeight == null) {
return flightControl.startTakeoff()
}
return DjiCommandResult.failed(
"一键起飞带目标高度时必须同时下发经纬度,请使用 takeoff_to_point/fly_to_pointMSDK 端不再用虚拟杆兜底爬升"
)
}
private fun scheduleStickTimeout() { private fun scheduleStickTimeout() {
if (stickTimeoutJob?.isActive == true) return if (stickTimeoutJob?.isActive == true) return
stickTimeoutJob = scope.launch { 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 { private suspend fun setLaserFillLight(data: JSONObject): DjiCommandResult {
if (!data.hasAny("enable", "enabled", "ir_fill_light_enable", "irFillLightEnable")) { if (!data.hasAny("enable", "enabled", "ir_fill_light_enable", "irFillLightEnable")) {
return cameraMedia.toggleLaserFillLight() return cameraMedia.toggleLaserFillLight()
@@ -200,52 +288,55 @@ class CloudCommandExecutor(
} }
private suspend fun startFlyTo(data: JSONObject): DjiCommandResult { private suspend fun startFlyTo(data: JSONObject): DjiCommandResult {
val target = data.optJSONObject("target_location") val command = parseFlyToCommand(data)
?: data.optJSONObject("targetLocation") if (command == null) {
?: 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) {
return DjiCommandResult.failed("指点飞行参数无效:需要 latitude、longitude") return DjiCommandResult.failed("指点飞行参数无效:需要 latitude、longitude")
} }
Log.d( Log.d(
CloudCommandLogTag, 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() val releaseResult = releaseVirtualStickForAutonomousFlight()
if (!releaseResult.success) { if (!releaseResult.success) {
Log.w(CloudCommandLogTag, "release virtual stick before flyTo failed: ${releaseResult.message}") Log.w(CloudCommandLogTag, "release virtual stick before flyTo failed: ${releaseResult.message}")
} }
return flyToService.startFlyTo( val result = flyToService.startFlyTo(
latitude = latitude, latitude = command.latitude,
longitude = longitude, longitude = command.longitude,
height = height, height = command.height,
maxSpeed = maxSpeed, maxSpeed = command.maxSpeed,
securityTakeoffHeight = securityTakeoffHeight, securityTakeoffHeight = command.securityTakeoffHeight,
flyToMode = flyToMode 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 { 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 { private suspend fun setCameraMode(data: JSONObject): DjiCommandResult {
if (data.hasAny("camera_mode", "cameraMode")) { if (data.hasAny("camera_mode", "cameraMode")) {
return cameraMedia.setCloudCameraMode(data.optIntAny(-1, "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 { private suspend fun switchLens(data: JSONObject): DjiCommandResult {
val lens = data.optStringAny("lens", "camera_type", "cameraType", "video_type", "videoType", "type") val lens = data.optStringAny("lens", "camera_type", "cameraType", "video_type", "videoType", "type")
.ifBlank { .ifBlank {
@@ -341,6 +446,84 @@ class CloudCommandExecutor(
return cameraControl.setLens(lens) 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 { private suspend fun rotateGimbal(data: JSONObject): DjiCommandResult {
val pitchSpeed = data.optDoubleAny("pitch_speed", "pitchSpeed", "gimbal_pitch_speed", "gimbalPitchSpeed") val pitchSpeed = data.optDoubleAny("pitch_speed", "pitchSpeed", "gimbal_pitch_speed", "gimbalPitchSpeed")
val yawSpeed = data.optDoubleAny("yaw_speed", "yawSpeed", "gimbal_yaw_speed", "gimbalYawSpeed") 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( private val PassiveAckCommands = setOf(
"flight_authority_grab", "flight_authority_grab",
"payload_authority_grab", "payload_authority_grab",
@@ -357,6 +613,7 @@ private val PassiveAckCommands = setOf(
"drc_initial_state_subscribe" "drc_initial_state_subscribe"
) )
private val StickCommands = setOf("stick_control", "drone_control", "drc_drone_stick_control") 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 TakeoffCommands = setOf("start_takeoff", "takeoff", "take_off", "auto_takeoff")
private val StopTakeoffCommands = setOf("stop_takeoff", "cancel_takeoff") private val StopTakeoffCommands = setOf("stop_takeoff", "cancel_takeoff")
private val GoHomeCommands = setOf("start_go_home", "go_home", "return_home", "start_rth", "return_auto") 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" "drc_infrared_fill_light_enable"
) )
private val LaserMeasureCommands = setOf("laser_measure", "toggle_laser_measure") 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 StopFlyToCommands = setOf("stop_fly_to", "cancel_fly_to", "fly_to_point_stop")
private val WaylinePrepareCommands = setOf("flighttask_prepare", "wayline_prepare") private val WaylinePrepareCommands = setOf("flighttask_prepare", "wayline_prepare")
private val WaylineExecuteCommands = setOf("flighttask_execute", "wayline_execute", "start_wayline") private val WaylineExecuteCommands = setOf("flighttask_execute", "wayline_execute", "start_wayline")
private val WaylinePauseCommands = setOf("flighttask_pause", "wayline_pause") private val WaylinePauseCommands = setOf("flighttask_pause", "wayline_pause")
private val WaylineRecoveryCommands = setOf("flighttask_recovery", "wayline_resume", "wayline_recovery") private val WaylineRecoveryCommands = setOf("flighttask_recovery", "wayline_resume", "wayline_recovery")
private val WaylineUndoCommands = setOf("flighttask_undo", "wayline_stop", "stop_wayline") 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 CameraZoomCommands = setOf("camera_focal_length_set", "drc_camera_focal_length_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 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 GimbalDragCommands = setOf("camera_screen_drag", "drc_camera_screen_drag", "gimbal_rotate_by_speed")
private val GimbalResetCommands = setOf("gimbal_reset", "drc_gimbal_reset") private val GimbalResetCommands = setOf("gimbal_reset", "drc_gimbal_reset")
private val LiveStartCommands = setOf("live_start_push", "live_start", "start_live", "start_livestream") 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 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 = private fun String.toFlyToMode(defaultMode: FlyToMode): FlyToMode =
when (normalizeCloudCommand()) { when (normalizeCloudCommand()) {
"smart_height", "smartheight", "smart" -> FlyToMode.SMART_HEIGHT "smart_height", "smartheight", "smart" -> FlyToMode.SMART_HEIGHT
@@ -463,9 +742,78 @@ private fun String.toFlyToMode(defaultMode: FlyToMode): FlyToMode =
else -> defaultMode 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 = private fun JSONObject.hasAny(vararg names: String): Boolean =
names.any { has(it) && !isNull(it) } names.any { has(it) && !isNull(it) }
private fun isValidCoordinate(latitude: Double, longitude: Double): Boolean =
!latitude.isNaN() &&
!longitude.isNaN() &&
!(latitude == 0.0 && longitude == 0.0) &&
latitude in -90.0..90.0 &&
longitude in -180.0..180.0
private fun convertCloudTargetCoordinate(
latitude: Double,
longitude: Double,
coordinateType: String
): Pair<Double, Double> {
val normalized = coordinateType.normalizeCloudCommand()
return if (normalized in setOf("gcj02", "gcj_02", "gcj-02", "mars")) {
gcj02ToWgs84(latitude, longitude)
} else {
latitude to longitude
}
}
private fun gcj02ToWgs84(latitude: Double, longitude: Double): Pair<Double, Double> {
if (isOutOfChina(latitude, longitude)) return latitude to longitude
val dLat = transformLatitude(longitude - 105.0, latitude - 35.0)
val dLon = transformLongitude(longitude - 105.0, latitude - 35.0)
val radLat = latitude / 180.0 * CoordPi
var magic = kotlin.math.sin(radLat)
magic = 1 - CoordEccentricity * magic * magic
val sqrtMagic = kotlin.math.sqrt(magic)
val adjustedLat = (dLat * 180.0) / ((CoordSemiMajorAxis * (1 - CoordEccentricity)) / (magic * sqrtMagic) * CoordPi)
val adjustedLon = (dLon * 180.0) / (CoordSemiMajorAxis / sqrtMagic * kotlin.math.cos(radLat) * CoordPi)
val mgLat = latitude + adjustedLat
val mgLon = longitude + adjustedLon
return (latitude * 2 - mgLat) to (longitude * 2 - mgLon)
}
private fun isOutOfChina(latitude: Double, longitude: Double): Boolean =
longitude < 72.004 ||
longitude > 137.8347 ||
latitude < 0.8293 ||
latitude > 55.8271
private fun transformLatitude(longitude: Double, latitude: Double): Double {
var value = -100.0 + 2.0 * longitude + 3.0 * latitude + 0.2 * latitude * latitude +
0.1 * longitude * latitude + 0.2 * kotlin.math.sqrt(kotlin.math.abs(longitude))
value += (20.0 * kotlin.math.sin(6.0 * longitude * CoordPi) + 20.0 * kotlin.math.sin(2.0 * longitude * CoordPi)) * 2.0 / 3.0
value += (20.0 * kotlin.math.sin(latitude * CoordPi) + 40.0 * kotlin.math.sin(latitude / 3.0 * CoordPi)) * 2.0 / 3.0
value += (160.0 * kotlin.math.sin(latitude / 12.0 * CoordPi) + 320.0 * kotlin.math.sin(latitude * CoordPi / 30.0)) * 2.0 / 3.0
return value
}
private fun transformLongitude(longitude: Double, latitude: Double): Double {
var value = 300.0 + longitude + 2.0 * latitude + 0.1 * longitude * longitude +
0.1 * longitude * latitude + 0.1 * kotlin.math.sqrt(kotlin.math.abs(longitude))
value += (20.0 * kotlin.math.sin(6.0 * longitude * CoordPi) + 20.0 * kotlin.math.sin(2.0 * longitude * CoordPi)) * 2.0 / 3.0
value += (20.0 * kotlin.math.sin(longitude * CoordPi) + 40.0 * kotlin.math.sin(longitude / 3.0 * CoordPi)) * 2.0 / 3.0
value += (150.0 * kotlin.math.sin(longitude / 12.0 * CoordPi) + 300.0 * kotlin.math.sin(longitude / 30.0 * CoordPi)) * 2.0 / 3.0
return value
}
private fun Double.formatMeters(): String =
"%.1f".format(this)
private fun JSONObject.firstPoint(): JSONObject? { private fun JSONObject.firstPoint(): JSONObject? {
val points = optJSONArray("points") ?: return null val points = optJSONArray("points") ?: return null
if (points.length() <= 0) return null if (points.length() <= 0) return null

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,12 +2,25 @@ package com.zklh.dronecontroller.core.media
import com.zklh.dronecontroller.core.msdk.DjiCommandResult import com.zklh.dronecontroller.core.msdk.DjiCommandResult
import dji.sdk.keyvalue.key.CameraKey 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.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.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.CameraLensType
import dji.sdk.keyvalue.value.common.ComponentIndexType 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.common.error.IDJIError
import dji.v5.et.action
import dji.v5.et.create
import dji.v5.et.set import dji.v5.et.set
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine 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( private suspend fun setDoubleKey(
key: DJIKey<Double>, key: DJIKey<Double>,
value: Double, value: Double,
@@ -66,6 +204,23 @@ class CameraControlService(
} }
) )
} }
private suspend fun <Param, Result> runAction(
key: DJIKey.ActionKey<Param, Result>,
param: Param,
successMessage: String
): DjiCommandResult =
suspendCancellableCoroutine { continuation ->
key.action(
param,
{
continuation.resume(DjiCommandResult.ok(successMessage))
},
{ error: IDJIError ->
continuation.resume(DjiCommandResult.failed(error))
}
)
}
} }
private fun String.normalizeLensName(): String = private fun String.normalizeLensName(): String =
@@ -73,3 +228,17 @@ private fun String.normalizeLensName(): String =
.replace("-", "_") .replace("-", "_")
.lowercase() .lowercase()
private fun String.toLensType(): CameraLensType =
when (normalizeLensName()) {
"ir", "infrared", "thermal", "camera_lens_thermal" -> CameraLensType.CAMERA_LENS_THERMAL
"zoom", "visible_zoom", "camera_lens_zoom" -> CameraLensType.CAMERA_LENS_ZOOM
else -> CameraLensType.CAMERA_LENS_WIDE
}
private fun String.toLiveViewSourceCameraType(): LiveViewSourceCameraType =
when (normalizeLensName()) {
"wide", "normal", "visible", "visible_wide", "camera_lens_wide" -> LiveViewSourceCameraType.WIDE_CAMERA
"ir", "infrared", "thermal", "camera_lens_thermal" -> LiveViewSourceCameraType.INFRARED_CAMERA
"zoom", "visible_zoom", "camera_lens_zoom" -> LiveViewSourceCameraType.ZOOM_CAMERA
else -> LiveViewSourceCameraType.DEFAULT_CAMERA
}

View File

@@ -18,10 +18,6 @@ import dji.v5.et.action
import dji.v5.et.create import dji.v5.et.create
import dji.v5.et.get import dji.v5.et.get
import dji.v5.et.set 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 kotlin.coroutines.resume
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
@@ -38,7 +34,6 @@ class CameraMediaService(
Log.d(CameraMediaLogTag, "takePhoto candidates=${candidateCameraIndexes()}") Log.d(CameraMediaLogTag, "takePhoto candidates=${candidateCameraIndexes()}")
for (index in candidateCameraIndexes()) { for (index in candidateCameraIndexes()) {
val previousMedia = readLatestMediaFile(index) val previousMedia = readLatestMediaFile(index)
val previousSnapshot = readCachedMediaSnapshot("before photo")
val modeResult = setPhotoCaptureMode(index) val modeResult = setPhotoCaptureMode(index)
if (!modeResult.success) { if (!modeResult.success) {
lastFailure = modeResult lastFailure = modeResult
@@ -61,7 +56,6 @@ class CameraMediaService(
val saved = waitForGeneratedMediaFile( val saved = waitForGeneratedMediaFile(
cameraIndex = index, cameraIndex = index,
previousMedia = previousMedia, previousMedia = previousMedia,
previousSnapshot = previousSnapshot,
successMessage = "拍照完成,文件已写入飞机存储", successMessage = "拍照完成,文件已写入飞机存储",
timeoutMessage = "拍照指令已下发,但没有检测到新照片文件。请检查飞机 SD 卡/内置存储、相机存储位置和电量告警。" timeoutMessage = "拍照指令已下发,但没有检测到新照片文件。请检查飞机 SD 卡/内置存储、相机存储位置和电量告警。"
) )
@@ -122,7 +116,6 @@ class CameraMediaService(
val indexes = if (recordingIndexes.isNotEmpty()) recordingIndexes else candidateCameraIndexes() val indexes = if (recordingIndexes.isNotEmpty()) recordingIndexes else candidateCameraIndexes()
for (index in indexes) { for (index in indexes) {
val previousMedia = readLatestMediaFile(index) val previousMedia = readLatestMediaFile(index)
val previousSnapshot = readCachedMediaSnapshot("before stop record")
val stopResult = runEmptyAction( val stopResult = runEmptyAction(
CameraKey.KeyStopRecord.create(index), CameraKey.KeyStopRecord.create(index),
"录像停止指令已下发,正在等待文件写入" "录像停止指令已下发,正在等待文件写入"
@@ -142,7 +135,6 @@ class CameraMediaService(
val saved = waitForGeneratedMediaFile( val saved = waitForGeneratedMediaFile(
cameraIndex = index, cameraIndex = index,
previousMedia = previousMedia, previousMedia = previousMedia,
previousSnapshot = previousSnapshot,
successMessage = "录像已停止,文件已写入飞机存储", successMessage = "录像已停止,文件已写入飞机存储",
timeoutMessage = "录像已停止,但没有检测到新视频文件。请检查飞机 SD 卡/内置存储和相机存储状态。", timeoutMessage = "录像已停止,但没有检测到新视频文件。请检查飞机 SD 卡/内置存储和相机存储状态。",
timeoutMs = 12_000L timeoutMs = 12_000L
@@ -252,6 +244,9 @@ class CameraMediaService(
fun isRecording(): Boolean = fun isRecording(): Boolean =
isRecording(activeCameraIndex) isRecording(activeCameraIndex)
fun currentCameraIndex(): ComponentIndexType =
activeCameraIndex
private fun isRecording(index: ComponentIndexType): Boolean = private fun isRecording(index: ComponentIndexType): Boolean =
runCatching { runCatching {
DJICameraKey.KeyIsRecording.create(index).get(false) DJICameraKey.KeyIsRecording.create(index).get(false)
@@ -282,7 +277,6 @@ class CameraMediaService(
private suspend fun waitForGeneratedMediaFile( private suspend fun waitForGeneratedMediaFile(
cameraIndex: ComponentIndexType, cameraIndex: ComponentIndexType,
previousMedia: GeneratedMediaFileInfo?, previousMedia: GeneratedMediaFileInfo?,
previousSnapshot: MediaSnapshot?,
successMessage: String, successMessage: String,
timeoutMessage: String, timeoutMessage: String,
timeoutMs: Long = 8_000L timeoutMs: Long = 8_000L
@@ -306,23 +300,13 @@ class CameraMediaService(
if (afterStoreIdentity.isNotBlank() && afterStoreIdentity != previousIdentity) { if (afterStoreIdentity.isNotBlank() && afterStoreIdentity != previousIdentity) {
return DjiCommandResult.ok("$successMessage${afterStoreMedia.mediaDisplayName()}") return DjiCommandResult.ok("$successMessage${afterStoreMedia.mediaDisplayName()}")
} }
val afterStoreSnapshot = fetchMediaSnapshot(cameraIndex, "after store") return DjiCommandResult.ok(successMessage)
return if (afterStoreSnapshot.isNewerThan(previousSnapshot)) {
DjiCommandResult.ok("$successMessage${afterStoreSnapshot?.latestDisplayName ?: "飞机存储"}")
} else {
DjiCommandResult.ok(successMessage)
}
} }
delay(300) delay(300)
} }
val finalSnapshot = fetchMediaSnapshot(cameraIndex, "after timeout") return DjiCommandResult.failed(timeoutMessage)
return if (finalSnapshot.isNewerThan(previousSnapshot)) {
DjiCommandResult.ok("$successMessage${finalSnapshot?.latestDisplayName ?: "飞机存储"}")
} else {
DjiCommandResult.failed(timeoutMessage)
}
} }
private fun readLatestMediaFile(index: ComponentIndexType): GeneratedMediaFileInfo? = private fun readLatestMediaFile(index: ComponentIndexType): GeneratedMediaFileInfo? =
@@ -330,116 +314,6 @@ class CameraMediaService(
DJICameraKey.KeyNewlyGeneratedMediaFile.create(index).get(GeneratedMediaFileInfo()) DJICameraKey.KeyNewlyGeneratedMediaFile.create(index).get(GeneratedMediaFileInfo())
}.getOrNull() }.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 = private fun isCameraStoringFile(index: ComponentIndexType): Boolean =
runCatching { runCatching {
DJICameraKey.KeyCameraStoringFile.create(index).get(false) DJICameraKey.KeyCameraStoringFile.create(index).get(false)
@@ -607,29 +481,4 @@ class CameraMediaService(
} }
} }
private data class MediaSnapshot(
val count: Int,
val latestIdentity: String,
val latestDisplayName: String
)
private fun MediaSnapshot?.isNewerThan(previous: MediaSnapshot?): Boolean {
if (this == null) return false
if (previous == null) return latestIdentity.isNotBlank()
return count > previous.count ||
(latestIdentity.isNotBlank() && latestIdentity != previous.latestIdentity)
}
private fun List<MediaFile>.latestMediaFile(): MediaFile? =
maxByOrNull { it.fileIndex }
private fun MediaFile?.mediaIdentity(): String {
if (this == null) return ""
return "${fileIndex}|${fileName.orEmpty()}"
}
private fun MediaFile?.mediaDisplayName(): String {
if (this == null) return "飞机存储"
return fileName?.takeIf { it.isNotBlank() } ?: "Media#$fileIndex"
}
} }

View File

@@ -1,5 +1,6 @@
package com.zklh.dronecontroller.core.simulator package com.zklh.dronecontroller.core.simulator
import android.util.Log
import com.zklh.dronecontroller.core.msdk.DjiCommandResult import com.zklh.dronecontroller.core.msdk.DjiCommandResult
import dji.sdk.keyvalue.value.common.LocationCoordinate2D import dji.sdk.keyvalue.value.common.LocationCoordinate2D
import dji.v5.common.callback.CommonCallbacks import dji.v5.common.callback.CommonCallbacks
@@ -15,6 +16,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
private const val SimulatorLogTag = "ZklhSimulator"
data class SimulatorStatus( data class SimulatorStatus(
val enabled: Boolean = false, val enabled: Boolean = false,
val motorsOn: Boolean = false, val motorsOn: Boolean = false,
@@ -49,6 +52,7 @@ class SimulatorService {
init { init {
SimulatorManager.getInstance().addSimulatorStateListener(listener) SimulatorManager.getInstance().addSimulatorStateListener(listener)
disableStaleSimulator()
_status.update { _status.update {
it.copy( it.copy(
enabled = SimulatorManager.getInstance().isSimulatorEnabled, 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( suspend fun enable(
latitude: Double = DEFAULT_LATITUDE, latitude: Double,
longitude: Double = DEFAULT_LONGITUDE, longitude: Double,
satelliteCount: Int = 12 satelliteCount: Int = 12
): DjiCommandResult = ): DjiCommandResult =
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
@@ -106,8 +128,4 @@ class SimulatorService {
SimulatorManager.getInstance().removeSimulatorStateListener(listener) SimulatorManager.getInstance().removeSimulatorStateListener(listener)
} }
private companion object {
const val DEFAULT_LATITUDE = 31.2304
const val DEFAULT_LONGITUDE = 121.4737
}
} }

View File

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

View File

@@ -1,6 +1,7 @@
package com.zklh.dronecontroller.ui package com.zklh.dronecontroller.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Paint import android.graphics.Paint
import android.graphics.Typeface import android.graphics.Typeface
import android.util.Log 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.CloudCommandExecutor
import com.zklh.dronecontroller.core.cloud.CloudLoginClient import com.zklh.dronecontroller.core.cloud.CloudLoginClient
import com.zklh.dronecontroller.core.cloud.CloudLoginRequest 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.CloudMqttService
import com.zklh.dronecontroller.core.cloud.CloudMqttState import com.zklh.dronecontroller.core.cloud.CloudMqttState
import com.zklh.dronecontroller.core.flight.FlightControlService 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 CameraLogTag = "ZklhCamera"
private const val CloudLogTag = "ZklhCloud" private const val CloudLogTag = "ZklhCloud"
private const val FlightControlLogTag = "ZklhFlightControl" 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 { private enum class PilotPage {
Home, Home,
@@ -222,10 +237,78 @@ private enum class CameraQuickAction {
Panorama 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 @Composable
fun DroneControllerScreen() { fun DroneControllerScreen() {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val appContext = context.applicationContext
val savedCloudLoginConfig = remember(appContext) { appContext.readSavedCloudLoginConfig() }
val sdkState by DroneSdkManager.state.collectAsState() val sdkState by DroneSdkManager.state.collectAsState()
val flightControl = remember { FlightControlService() } val flightControl = remember { FlightControlService() }
val virtualStick = remember { VirtualStickService() } val virtualStick = remember { VirtualStickService() }
@@ -238,13 +321,26 @@ fun DroneControllerScreen() {
val warningRepository = remember { DroneWarningRepository() } val warningRepository = remember { DroneWarningRepository() }
val cloudLoginClient = remember { CloudLoginClient() } val cloudLoginClient = remember { CloudLoginClient() }
val cloudMqttService = remember { CloudMqttService(cloudLoginClient) } val cloudMqttService = remember { CloudMqttService(cloudLoginClient) }
val cloudCommandExecutor = remember(flightControl, cameraMedia, flyToService, liveStreaming, virtualStick, waypointMission) { val cloudMediaUpload = remember(appContext, cloudMqttService) {
CloudCommandExecutor(flightControl, cameraMedia, flyToService, liveStreaming, virtualStick, waypointMission) 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 stickStatus by virtualStick.status.collectAsState()
val simulatorStatus by simulator.status.collectAsState() val simulatorStatus by simulator.status.collectAsState()
val warningState by warningRepository.state.collectAsState() val warningState by warningRepository.state.collectAsState()
val telemetry by telemetryRepository.state.collectAsState()
val cloudState by cloudMqttService.state.collectAsState() val cloudState by cloudMqttService.state.collectAsState()
val liveState by liveStreaming.state.collectAsState() val liveState by liveStreaming.state.collectAsState()
@@ -260,16 +356,17 @@ fun DroneControllerScreen() {
var returnAltitude by remember { mutableStateOf(100) } var returnAltitude by remember { mutableStateOf(100) }
var maxAltitude by remember { mutableStateOf(500) } var maxAltitude by remember { mutableStateOf(500) }
var farLimitEnabled by remember { mutableStateOf(false) } var farLimitEnabled by remember { mutableStateOf(false) }
var thirdCloudUrl by remember { mutableStateOf("http://uav.zklhjs.com/p") } var thirdCloudUrl by remember { mutableStateOf(savedCloudLoginConfig.url) }
var thirdCloudTenantName by remember { mutableStateOf("中科联航") } var thirdCloudTenantName by remember { mutableStateOf(savedCloudLoginConfig.tenantName) }
var thirdCloudUsername by remember { mutableStateOf("zklh") } var thirdCloudUsername by remember { mutableStateOf(savedCloudLoginConfig.username) }
var thirdCloudPassword by remember { mutableStateOf("zklh@123") } var thirdCloudPassword by remember { mutableStateOf(savedCloudLoginConfig.password) }
var thirdCloudMqttAddress by remember { mutableStateOf("") } var thirdCloudMqttAddress by remember { mutableStateOf(savedCloudLoginConfig.mqttAddress) }
var thirdCloudConnected by remember { mutableStateOf(false) } var thirdCloudConnected by remember { mutableStateOf(false) }
var thirdCloudLoginVisible by remember { mutableStateOf(false) } var thirdCloudLoginVisible by remember { mutableStateOf(false) }
var thirdCloudStatusMessage by remember { mutableStateOf("") } var thirdCloudStatusMessage by remember { mutableStateOf("") }
var thirdCloudStatusIsError by remember { mutableStateOf(false) } var thirdCloudStatusIsError by remember { mutableStateOf(false) }
var thirdCloudReloadTick by remember { mutableStateOf(0) } var thirdCloudReloadTick by remember { mutableStateOf(0) }
var thirdCloudAutoLoginStarted by remember { mutableStateOf(false) }
var cameraRecording by remember { mutableStateOf(false) } var cameraRecording by remember { mutableStateOf(false) }
var laserFillLightEnabled by remember { mutableStateOf(false) } var laserFillLightEnabled by remember { mutableStateOf(false) }
var landingConfirmationAutoSent by remember { mutableStateOf(false) } var landingConfirmationAutoSent by remember { mutableStateOf(false) }
@@ -341,6 +438,15 @@ fun DroneControllerScreen() {
cloudMqttService.updateTelemetry(telemetry) cloudMqttService.updateTelemetry(telemetry)
} }
LaunchedEffect(
warningState,
cloudState.connected,
sdkState.remoteControllerSerialNumber,
sdkState.aircraftSerialNumber
) {
cloudMqttService.updateWarnings(warningState)
}
LaunchedEffect(cloudState.connected) { LaunchedEffect(cloudState.connected) {
thirdCloudConnected = cloudState.connected thirdCloudConnected = cloudState.connected
} }
@@ -576,7 +682,17 @@ fun DroneControllerScreen() {
Log.e(CameraLogTag, "takePhoto exception", error) Log.e(CameraLogTag, "takePhoto exception", error)
DjiCommandResult.failed("拍照异常:${error.message ?: error.javaClass.simpleName}") 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}") Log.d(CameraLogTag, "takePhoto success=${result.success}, message=${result.message}")
laserFillLightEnabled = cameraMedia.isLaserFillLightEnabled() laserFillLightEnabled = cameraMedia.isLaserFillLightEnabled()
toast(commandMessage) toast(commandMessage)
@@ -592,7 +708,17 @@ fun DroneControllerScreen() {
Log.e(CameraLogTag, "toggleRecord exception", error) Log.e(CameraLogTag, "toggleRecord exception", error)
DjiCommandResult.failed("录像异常:${error.message ?: error.javaClass.simpleName}") 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}") Log.d(CameraLogTag, "toggleRecord shouldStop=$shouldStop, success=${result.success}, message=${result.message}")
cameraRecording = if (result.success) !shouldStop else cameraMedia.isRecording() cameraRecording = if (result.success) !shouldStop else cameraMedia.isRecording()
laserFillLightEnabled = cameraMedia.isLaserFillLightEnabled() laserFillLightEnabled = cameraMedia.isLaserFillLightEnabled()
@@ -635,9 +761,13 @@ fun DroneControllerScreen() {
} }
val enableSimulator: () -> Unit = { val enableSimulator: () -> Unit = {
scope.launch { scope.launch {
val latitude = telemetry.latitude.takeIf { it != 0.0 } ?: 31.2304 val start = simulatorStartCoordinate(telemetry)
val longitude = telemetry.longitude.takeIf { it != 0.0 } ?: 121.4737 val result = simulator.enable(start.latitude, start.longitude)
commandMessage = simulator.enable(latitude, longitude).message commandMessage = if (start.source == "默认坐标") {
"${result.message}(未获取到定位,使用默认坐标)"
} else {
"${result.message}${start.source}"
}
toast(commandMessage) toast(commandMessage)
} }
} }
@@ -654,7 +784,7 @@ fun DroneControllerScreen() {
toast(commandMessage) toast(commandMessage)
} }
} }
val loginThirdCloud: () -> Unit = { fun loginThirdCloud(showToast: Boolean = true) {
scope.launch { scope.launch {
val request = CloudLoginRequest( val request = CloudLoginRequest(
baseUrl = thirdCloudUrl, baseUrl = thirdCloudUrl,
@@ -667,28 +797,43 @@ fun DroneControllerScreen() {
commandMessage = "正在登录第三方云" commandMessage = "正在登录第三方云"
thirdCloudStatusMessage = commandMessage thirdCloudStatusMessage = commandMessage
thirdCloudStatusIsError = false thirdCloudStatusIsError = false
toast(commandMessage) if (showToast) toast(commandMessage)
cloudLoginClient.login(request) cloudLoginClient.login(request)
.onSuccess { session -> .onSuccess { session ->
thirdCloudUrl = session.baseUrl thirdCloudUrl = session.baseUrl
if (thirdCloudMqttAddress.isBlank()) { if (thirdCloudMqttAddress.isBlank()) {
thirdCloudMqttAddress = session.mqttAddress thirdCloudMqttAddress = session.mqttAddress
} }
appContext.saveCloudLoginConfig(request.copy(baseUrl = session.baseUrl), session.mqttAddress)
commandMessage = "第三方云登录成功,正在连接 MQTT" commandMessage = "第三方云登录成功,正在连接 MQTT"
thirdCloudStatusMessage = commandMessage thirdCloudStatusMessage = commandMessage
thirdCloudStatusIsError = false thirdCloudStatusIsError = false
toast(commandMessage) if (showToast) toast(commandMessage)
cloudMqttService.connect(session, cloudCommandExecutor) cloudMqttService.connect(session, cloudCommandExecutor)
} }
.onFailure { error -> .onFailure { error ->
commandMessage = "第三方云登录失败:${error.message ?: error.javaClass.simpleName}" commandMessage = "第三方云登录失败:${error.message ?: error.javaClass.simpleName}"
thirdCloudStatusMessage = commandMessage thirdCloudStatusMessage = commandMessage
thirdCloudStatusIsError = true 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) { when (page) {
PilotPage.Home -> HomeScreen( PilotPage.Home -> HomeScreen(
sdkState = sdkState, sdkState = sdkState,
@@ -751,7 +896,7 @@ fun DroneControllerScreen() {
onCloseWeb = { onCloseWeb = {
thirdCloudLoginVisible = false thirdCloudLoginVisible = false
}, },
onLogin = loginThirdCloud, onLogin = loginThirdCloudFromUi,
onConnected = { onConnected = {
if (!thirdCloudConnected) { if (!thirdCloudConnected) {
toast("网页登录成功,请确认 MQTT 接入状态") toast("网页登录成功,请确认 MQTT 接入状态")
@@ -765,11 +910,13 @@ fun DroneControllerScreen() {
} else { } else {
cloudMqttService.updateDeviceState(sdkState) cloudMqttService.updateDeviceState(sdkState)
cloudMqttService.updateTelemetry(telemetry) cloudMqttService.updateTelemetry(telemetry)
cloudMqttService.updateWarnings(warningState)
toast("已刷新第三方云接入状态") toast("已刷新第三方云接入状态")
} }
}, },
onLogout = { onLogout = {
cloudMqttService.disconnect() cloudMqttService.disconnect()
appContext.disableCloudAutoConnect()
CookieManager.getInstance().removeAllCookies(null) CookieManager.getInstance().removeAllCookies(null)
CookieManager.getInstance().flush() CookieManager.getInstance().flush()
WebStorage.getInstance().deleteAllData() WebStorage.getInstance().deleteAllData()