Give the Fly App every remaining GO 4 flight mode and the missing settings
Intelligent modes: Point of Interest (orbit on the map, live radius and speed), Follow Me (follows the phone), QuickShots on a boxed subject, Course Lock / Home Lock, Tripod and Cinematic, a Free hyperlapse, and Waypoints 2.0 for V2-only aircraft. ActiveTrack gains Spotlight Pro and Circle. Every mode has a first-use tutorial and stops from the rail. Waypoints can resume an interrupted mission, change cruise speed in flight and check terrain clearance (Open-Meteo, on tap). Settings: units, C1/C2 actions, dual-operator master/slave, HDMI/SDI output, a link spectrum chart, Wi-Fi name and password, aircraft name, video cache, battery warning history and pairing, non-smart cell voltages. Calibration: compass interference meter, per-IMU calibration, lens infinity focus and a first-manual-focus prompt. Also an RTK chip, gimbal movement presets and motor tuning, Precision Takeoff gated on the flight controller, and the precise GEO database's server version. Built and unit/widget-tested only; nothing has flown yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
91ca150a35
commit
636da1ac2c
57 files changed
+5363
-87
No files matched your search
@@ -94,6 +94,8 @@ class CalibrationBridge(private val ctx: BridgeCtx) : SubBridge {
|
||||
"stopPropellerCalibration" -> withFc(result) { it.stopPropellerCalibration(ctx.completion(result)) }
|
||||
"startLensCalibration" -> withCamera(result) { it.startCalibration(ctx.completion(result)) }
|
||||
"stopLensCalibration" -> withCamera(result) { it.stopCalibration(ctx.completion(result)) }
|
||||
// Records the current focus position as infinity (lens focused on a far subject).
|
||||
"calibrateInfinityFocus" -> withCamera(result) { it.calibratedInfinityFocus(ctx.completion(result)) }
|
||||
"initSensorCleaning" -> withCamera(result) { it.initSensorCleaningMode(ctx.completion(result)) }
|
||||
"startSensorCleaning" -> withCamera(result) { it.startSensorCleaning(ctx.completion(result)) }
|
||||
"exitSensorCleaning" -> withCamera(result) { it.exitSensorCleaningMode(ctx.completion(result)) }
|
||||
|
||||
@@ -115,6 +115,10 @@ class DjiSdkBridge(
|
||||
AirSenseBridge(ctx),
|
||||
FlyZoneBridge(ctx),
|
||||
LiveStreamBridge(ctx),
|
||||
RemoteControllerBridge(ctx),
|
||||
VideoCacheBridge(ctx),
|
||||
HotpointBridge(ctx),
|
||||
FollowMeBridge(ctx),
|
||||
)
|
||||
|
||||
init {
|
||||
|
||||
+47
-3
@@ -1,6 +1,9 @@
|
||||
package com.pilotvault.flyapp
|
||||
|
||||
import dji.common.flightcontroller.CompassState
|
||||
import dji.common.flightcontroller.FlightControllerState
|
||||
import dji.common.flightcontroller.FlightOrientationMode
|
||||
import dji.common.flightcontroller.RTKState
|
||||
import dji.common.flightcontroller.simulator.InitializationData
|
||||
import dji.common.model.LocationCoordinate2D
|
||||
import dji.sdk.base.BaseProduct
|
||||
@@ -93,6 +96,18 @@ class FlightControllerBridge(private val ctx: BridgeCtx) : SubBridge {
|
||||
"setNoviceMode" -> guard(controller, result) {
|
||||
it.setNoviceModeEnabled(call.argument<Boolean>("value") ?: false, ctx.completion(result))
|
||||
}
|
||||
// Course Lock / Home Lock (plan P7): AIRCRAFT_HEADING / COURSE_LOCK / HOME_LOCK.
|
||||
"setFlightOrientationMode" -> guard(controller, result) {
|
||||
val mode = try {
|
||||
FlightOrientationMode.valueOf(call.argument<String>("mode") ?: "AIRCRAFT_HEADING")
|
||||
} catch (_: IllegalArgumentException) {
|
||||
FlightOrientationMode.AIRCRAFT_HEADING
|
||||
}
|
||||
it.setFlightOrientationMode(mode, ctx.completion(result))
|
||||
}
|
||||
"lockCourseUsingCurrentHeading" -> guard(controller, result) {
|
||||
it.lockCourseUsingCurrentHeading(ctx.completion(result))
|
||||
}
|
||||
"setObstacleAvoidance" -> {
|
||||
val assistant = controller?.flightAssistant
|
||||
if (assistant == null) {
|
||||
@@ -118,11 +133,40 @@ class FlightControllerBridge(private val ctx: BridgeCtx) : SubBridge {
|
||||
|
||||
override fun bind(product: BaseProduct?) {
|
||||
if (product !is Aircraft) return
|
||||
product.flightController?.setStateCallback { state ->
|
||||
val values = telemetry(state) + compass(product.flightController) +
|
||||
("simulatorActive" to (try { product.flightController?.simulator?.isSimulatorActive } catch (_: Throwable) { null }))
|
||||
val controller = product.flightController ?: return
|
||||
controller.setStateCallback { state ->
|
||||
val values = telemetry(state) + compass(controller) +
|
||||
("simulatorActive" to (try { controller.simulator?.isSimulatorActive } catch (_: Throwable) { null }))
|
||||
ctx.mainHandler.post { ctx.hub.putFast(values) }
|
||||
}
|
||||
// Magnetic interference per compass (P6b's interference meter): the
|
||||
// sensor value is the field's modulus, graded by the sensor state.
|
||||
try {
|
||||
controller.compass?.setCompassStateCallback { s: CompassState? ->
|
||||
if (s == null) return@setCompassStateCallback
|
||||
val all = listOf(s) + s.subCompassStates.orEmpty().filterNotNull()
|
||||
val values = all.map { c -> mapOf("index" to c.index, "state" to c.sensorState?.name, "value" to c.sensorValue) }
|
||||
ctx.mainHandler.post { ctx.hub.putFast(mapOf("compassSensors" to values)) }
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
// RTK (status-bar chip): only on aircraft that have the module.
|
||||
try {
|
||||
if (controller.isRTKSupported) {
|
||||
controller.rtk?.setStateCallback { s: RTKState? ->
|
||||
if (s == null) return@setStateCallback
|
||||
val m = mapOf(
|
||||
"solution" to s.positioningSolution?.name,
|
||||
"inUse" to s.isRTKBeingUsed,
|
||||
"satellites" to s.satelliteCount,
|
||||
"headingValid" to s.isHeadingValid,
|
||||
"error" to s.error?.description,
|
||||
)
|
||||
ctx.mainHandler.post { ctx.hub.putSlow("rtk", m) }
|
||||
}
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun telemetry(state: FlightControllerState): Map<String, Any?> {
|
||||
|
||||
@@ -132,6 +132,7 @@ class FlyZoneBridge(private val ctx: BridgeCtx) : SubBridge {
|
||||
"getFlyZoneDatabase" -> {
|
||||
val m = manager() ?: return unavailable(result)
|
||||
val out = LinkedHashMap<String, Any?>()
|
||||
out["preciseRemoteVersion"] = preciseRemote
|
||||
var pending = 4
|
||||
fun done() {
|
||||
if (--pending == 0) result.success(out)
|
||||
@@ -181,8 +182,23 @@ class FlyZoneBridge(private val ctx: BridgeCtx) : SubBridge {
|
||||
} catch (_: Throwable) {
|
||||
// No GEO database on this SDK build.
|
||||
}
|
||||
// The newest precise database on DJI's server, for the "out of date" hint.
|
||||
try {
|
||||
m.setPreciseRemoteDbVersionCallback(object : CommonCallbacks.CompletionCallbackWith<String> {
|
||||
override fun onSuccess(v: String?) {
|
||||
preciseRemote = v
|
||||
ctx.emit(mapOf("type" to "flyZoneDbRemote", "preciseRemoteVersion" to v))
|
||||
}
|
||||
|
||||
override fun onFailure(error: DJIError?) = Unit
|
||||
})
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Last remote precise-database version the SDK reported. */
|
||||
@Volatile private var preciseRemote: String? = null
|
||||
|
||||
private fun notification(n: FlySafeNotification?): Map<String, Any?>? {
|
||||
if (n == null) return null
|
||||
val base = mapOf("type" to "flySafe", "kind" to n.type?.name)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.pilotvault.flyapp
|
||||
|
||||
import dji.common.error.DJIError
|
||||
import dji.common.mission.followme.FollowMeHeading
|
||||
import dji.common.mission.followme.FollowMeMission
|
||||
import dji.common.mission.followme.FollowMeMissionEvent
|
||||
import dji.common.model.LocationCoordinate2D
|
||||
import dji.sdk.base.BaseProduct
|
||||
import dji.sdk.mission.followme.FollowMeMissionOperator
|
||||
import dji.sdk.mission.followme.FollowMeMissionOperatorListener
|
||||
import dji.sdk.sdkmanager.DJISDKManager
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/**
|
||||
* Follow Me (plan P7) → [FollowMeMissionOperator]: the aircraft keeps its
|
||||
* distance to the phone. Dart streams the phone's fix with
|
||||
* `updateFollowMeTarget {lat, lon}` (≠2 Hz while the mode runs; the
|
||||
* operator coasts and then stops if the updates dry up).
|
||||
*
|
||||
* `startFollowMe {lat, lon, altitude, heading}` (heading
|
||||
* `TOWARD_FOLLOW_POSITION` or `CONTROLLED_BY_REMOTE_CONTROLLER`),
|
||||
* `stopFollowMe`. The listener goes out as `mission` events with
|
||||
* `operator: 'followMe'`: `state`, `distance` (m to the target), `event`
|
||||
* (`start` / `finish`) and `error`.
|
||||
*/
|
||||
class FollowMeBridge(private val ctx: BridgeCtx) : SubBridge {
|
||||
|
||||
private fun op(): FollowMeMissionOperator? = DJISDKManager.getInstance().missionControl?.followMeMissionOperator
|
||||
|
||||
private var listened: FollowMeMissionOperator? = null
|
||||
|
||||
private val listener = object : FollowMeMissionOperatorListener {
|
||||
override fun onExecutionUpdate(e: FollowMeMissionEvent) =
|
||||
emit(e.currentState?.name, distance = e.distanceToTarget, error = e.error)
|
||||
|
||||
override fun onExecutionStart() = emit(op()?.currentState?.name, event = "start")
|
||||
|
||||
override fun onExecutionFinish(error: DJIError?) = emit(op()?.currentState?.name, event = "finish", error = error)
|
||||
}
|
||||
|
||||
private fun emit(state: String?, distance: Float? = null, event: String? = null, error: DJIError? = null) {
|
||||
val m = linkedMapOf<String, Any?>("type" to "mission", "operator" to "followMe")
|
||||
if (state != null) m["state"] = state
|
||||
if (distance != null) m["distance"] = distance.toDouble()
|
||||
if (event != null) m["event"] = event
|
||||
if (error != null) m["error"] = error.description
|
||||
ctx.emit(m)
|
||||
}
|
||||
|
||||
override fun bind(product: BaseProduct?) {
|
||||
val o = op() ?: return
|
||||
if (listened !== o) {
|
||||
listened?.removeListener(listener)
|
||||
o.addListener(listener)
|
||||
listened = o
|
||||
}
|
||||
emit(o.currentState?.name)
|
||||
}
|
||||
|
||||
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
|
||||
when (call.method) {
|
||||
"startFollowMe" -> withOp(result) { o ->
|
||||
val heading = try {
|
||||
FollowMeHeading.valueOf(call.argument<String>("heading") ?: "TOWARD_FOLLOW_POSITION")
|
||||
} catch (_: IllegalArgumentException) {
|
||||
FollowMeHeading.TOWARD_FOLLOW_POSITION
|
||||
}
|
||||
val mission = FollowMeMission(
|
||||
heading,
|
||||
call.argument<Double>("lat") ?: 0.0,
|
||||
call.argument<Double>("lon") ?: 0.0,
|
||||
(call.argument<Number>("altitude") ?: 10).toFloat(),
|
||||
)
|
||||
o.startMission(mission, ctx.completion(result))
|
||||
}
|
||||
"updateFollowMeTarget" -> withOp(result) {
|
||||
val lat = call.argument<Double>("lat")
|
||||
val lon = call.argument<Double>("lon")
|
||||
if (lat == null || lon == null || !LocationCoordinate2D.isValid(lat, lon)) {
|
||||
ctx.fail(result, "Invalid target")
|
||||
} else {
|
||||
it.updateFollowingTarget(LocationCoordinate2D(lat, lon), ctx.completion(result))
|
||||
}
|
||||
}
|
||||
"stopFollowMe" -> withOp(result) { it.stopMission(ctx.completion(result)) }
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private inline fun withOp(result: MethodChannel.Result, block: (FollowMeMissionOperator) -> Unit) {
|
||||
val o = op() ?: run { ctx.fail(result, "Follow Me unavailable"); return }
|
||||
block(o)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.pilotvault.flyapp
|
||||
|
||||
import dji.common.error.DJIError
|
||||
import dji.common.mission.hotpoint.HotpointHeading
|
||||
import dji.common.mission.hotpoint.HotpointMission
|
||||
import dji.common.mission.hotpoint.HotpointMissionEvent
|
||||
import dji.common.mission.hotpoint.HotpointStartPoint
|
||||
import dji.common.model.LocationCoordinate2D
|
||||
import dji.common.util.CommonCallbacks
|
||||
import dji.sdk.base.BaseProduct
|
||||
import dji.sdk.mission.hotpoint.HotpointMissionOperator
|
||||
import dji.sdk.mission.hotpoint.HotpointMissionOperatorListener
|
||||
import dji.sdk.sdkmanager.DJISDKManager
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/**
|
||||
* Point of Interest (plan P7) → [HotpointMissionOperator].
|
||||
*
|
||||
* `startHotpoint {lat, lon, altitude, radius, angularVelocity, clockwise,
|
||||
* startPoint, heading}` checks the mission (`checkParameters`: radius 5–500 m,
|
||||
* altitude 5–500 m) and starts it; the aircraft flies to the circle first.
|
||||
* In flight: `pauseHotpoint`, `resumeHotpoint`, `stopHotpoint`,
|
||||
* `setHotpointRadius {radius}`, `setHotpointAngularVelocity {value}` (°/s,
|
||||
* signed by direction) and `resetHotpointHeading`.
|
||||
* `getHotpointMaxAngularVelocity {radius}` answers the fastest °/s the
|
||||
* aircraft will circle at that radius.
|
||||
*
|
||||
* The listener goes out as `mission` events with `operator: 'hotpoint'`:
|
||||
* `state` (HotpointMissionState), `radius`, `maxAngularVelocity`, `event`
|
||||
* (`start` / `finish`) and `error`.
|
||||
*/
|
||||
class HotpointBridge(private val ctx: BridgeCtx) : SubBridge {
|
||||
|
||||
private fun op(): HotpointMissionOperator? = DJISDKManager.getInstance().missionControl?.hotpointMissionOperator
|
||||
|
||||
private var listened: HotpointMissionOperator? = null
|
||||
|
||||
private val listener = object : HotpointMissionOperatorListener {
|
||||
override fun onExecutionUpdate(e: HotpointMissionEvent) {
|
||||
emit(e.currentState?.name, radius = e.radius, maxAngular = e.maxAngularVelocity, error = e.error)
|
||||
}
|
||||
|
||||
override fun onExecutionStart() = emit(op()?.currentState?.name, event = "start")
|
||||
|
||||
override fun onExecutionFinish(error: DJIError?) = emit(op()?.currentState?.name, event = "finish", error = error)
|
||||
}
|
||||
|
||||
private fun emit(state: String?, radius: Float? = null, maxAngular: Float? = null, event: String? = null, error: DJIError? = null) {
|
||||
val m = linkedMapOf<String, Any?>("type" to "mission", "operator" to "hotpoint")
|
||||
if (state != null) m["state"] = state
|
||||
if (radius != null) m["radius"] = radius.toDouble()
|
||||
if (maxAngular != null) m["maxAngularVelocity"] = maxAngular.toDouble()
|
||||
if (event != null) m["event"] = event
|
||||
if (error != null) m["error"] = error.description
|
||||
ctx.emit(m)
|
||||
}
|
||||
|
||||
override fun bind(product: BaseProduct?) {
|
||||
val o = op() ?: return
|
||||
if (listened !== o) {
|
||||
listened?.removeListener(listener)
|
||||
o.addListener(listener)
|
||||
listened = o
|
||||
}
|
||||
emit(o.currentState?.name)
|
||||
}
|
||||
|
||||
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
|
||||
when (call.method) {
|
||||
"startHotpoint" -> withOp(result) { o ->
|
||||
val mission = HotpointMission(
|
||||
LocationCoordinate2D(call.argument<Double>("lat") ?: 0.0, call.argument<Double>("lon") ?: 0.0),
|
||||
(call.argument<Number>("altitude") ?: 30).toDouble(),
|
||||
(call.argument<Number>("radius") ?: 20).toDouble(),
|
||||
(call.argument<Number>("angularVelocity") ?: 10).toFloat(),
|
||||
call.argument<Boolean>("clockwise") ?: true,
|
||||
enumArg(call, "startPoint", HotpointStartPoint.NEAREST),
|
||||
enumArg(call, "heading", HotpointHeading.TOWARDS_HOT_POINT),
|
||||
)
|
||||
mission.checkParameters()?.let {
|
||||
ctx.fail(result, it.description)
|
||||
return@withOp
|
||||
}
|
||||
o.startMission(mission, ctx.completion(result))
|
||||
}
|
||||
"pauseHotpoint" -> withOp(result) { it.pause(ctx.completion(result)) }
|
||||
"resumeHotpoint" -> withOp(result) { it.resume(ctx.completion(result)) }
|
||||
"stopHotpoint" -> withOp(result) { it.stop(ctx.completion(result)) }
|
||||
"setHotpointRadius" -> withOp(result) {
|
||||
it.setRadius((call.argument<Number>("radius") ?: 20).toFloat(), ctx.completion(result))
|
||||
}
|
||||
"setHotpointAngularVelocity" -> withOp(result) {
|
||||
it.setAngularVelocity((call.argument<Number>("value") ?: 10).toFloat(), ctx.completion(result))
|
||||
}
|
||||
"resetHotpointHeading" -> withOp(result) { it.resetHeading(ctx.completion(result)) }
|
||||
"getHotpointMaxAngularVelocity" -> HotpointMissionOperator.getMaxAngularVelocityForRadius(
|
||||
(call.argument<Number>("radius") ?: 20).toDouble(),
|
||||
object : CommonCallbacks.CompletionCallbackWith<Float> {
|
||||
override fun onSuccess(v: Float?) {
|
||||
ctx.mainHandler.post { result.success(v?.toDouble()) }
|
||||
}
|
||||
|
||||
override fun onFailure(e: DJIError) {
|
||||
ctx.mainHandler.post { result.error("DJI_ERROR", e.description, null) }
|
||||
}
|
||||
},
|
||||
)
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private inline fun withOp(result: MethodChannel.Result, block: (HotpointMissionOperator) -> Unit) {
|
||||
val o = op() ?: run { ctx.fail(result, "Point of Interest unavailable"); return }
|
||||
block(o)
|
||||
}
|
||||
|
||||
private inline fun <reified T : Enum<T>> enumArg(call: MethodCall, name: String, fallback: T): T {
|
||||
val raw = call.argument<String>(name) ?: return fallback
|
||||
return try {
|
||||
java.lang.Enum.valueOf(T::class.java, raw)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.pilotvault.flyapp
|
||||
|
||||
import dji.common.battery.WarningRecord
|
||||
import dji.common.camera.ResolutionAndFrameRate
|
||||
import dji.common.remotecontroller.HardwareState
|
||||
import dji.common.remotecontroller.Information
|
||||
import dji.common.error.DJIError
|
||||
import dji.common.flightcontroller.LEDsSettings
|
||||
import dji.common.flightcontroller.LocationCoordinate3D
|
||||
@@ -249,6 +252,20 @@ object KeyValues {
|
||||
"statusIndicator" to v.isStatusIndicatorOn(),
|
||||
"beacons" to v.areBeaconsOn(),
|
||||
)
|
||||
// Battery history (`HISTORICAL_WARNING_RECORDS`, `LATEST_WARNING_RECORD`).
|
||||
is WarningRecord -> mapOf(
|
||||
"error" to v.hasError(),
|
||||
"overCurrent" to v.isCurrentOverloaded,
|
||||
"overHeated" to v.isOverHeated,
|
||||
"lowTemperature" to v.isLowTemperature,
|
||||
"shortCircuit" to v.isShortCircuited,
|
||||
"underVoltage" to v.isBatteryUnderVoltage,
|
||||
"lowVoltageCell" to v.lowVoltageCellIndex,
|
||||
"damagedCell" to v.damagedCellIndex,
|
||||
)
|
||||
// Master / slave remotes (`SLAVE_LIST`, `AVAILABLE_MASTERS`).
|
||||
is Information -> mapOf("id" to v.id, "name" to v.name, "signal" to v.signalQuality.toInt())
|
||||
is HardwareState.Button -> mapOf("present" to v.isPresent, "clicked" to v.isClicked)
|
||||
else -> v.toString()
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import dji.common.mission.waypoint.WaypointMissionFinishedAction
|
||||
import dji.common.mission.waypoint.WaypointMissionFlightPathMode
|
||||
import dji.common.mission.waypoint.WaypointMissionGotoWaypointMode
|
||||
import dji.common.mission.waypoint.WaypointMissionHeadingMode
|
||||
import dji.common.mission.waypoint.WaypointMissionInterruption
|
||||
import dji.common.mission.waypoint.WaypointMissionUploadEvent
|
||||
import dji.common.mission.waypoint.WaypointTurnMode
|
||||
import dji.common.model.LocationCoordinate2D
|
||||
@@ -183,6 +184,7 @@ class MissionBridge(private val ctx: BridgeCtx) : SubBridge {
|
||||
}
|
||||
|
||||
override fun bind(product: BaseProduct?) {
|
||||
v2.bind()
|
||||
trackOp()?.let { op ->
|
||||
if (listenedTrack !== op) {
|
||||
listenedTrack?.removeListener(trackListener)
|
||||
@@ -209,20 +211,56 @@ class MissionBridge(private val ctx: BridgeCtx) : SubBridge {
|
||||
|
||||
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
|
||||
when (call.method) {
|
||||
"uploadWaypointMission" -> uploadWaypoint(call, result)
|
||||
"uploadWaypointMission" -> if (call.argument<Boolean>("v2") == true) v2.upload(call, result) else {
|
||||
v2Active = false
|
||||
uploadWaypoint(call, result)
|
||||
}
|
||||
"getWaypointMissionState" -> {
|
||||
val op = waypointOp()
|
||||
result.success(op?.currentState?.name)
|
||||
if (op != null) emitWaypoint(state = op.currentState?.name)
|
||||
}
|
||||
"startWaypointMission" -> waypoint(result) { it.startMission(ctx.completion(result)) }
|
||||
"stopWaypointMission" -> waypoint(result) { it.stopMission(ctx.completion(result)) }
|
||||
"pauseWaypointMission" -> waypoint(result) { it.pauseMission(ctx.completion(result)) }
|
||||
"resumeWaypointMission" -> waypoint(result) { it.resumeMission(ctx.completion(result)) }
|
||||
"cancelWaypointUpload" -> waypoint(result) { it.cancelUploadingMission(ctx.completion(result)) }
|
||||
"setWaypointAutoFlightSpeed" -> waypoint(result) {
|
||||
// A V2 mission (Matrice 300) uploaded last answers these through its own operator.
|
||||
"startWaypointMission" -> if (v2Active) v2.start(result) else waypoint(result) { it.startMission(ctx.completion(result)) }
|
||||
"stopWaypointMission" -> if (v2Active) v2.stop(result) else waypoint(result) { it.stopMission(ctx.completion(result)) }
|
||||
"pauseWaypointMission" -> if (v2Active) v2.pause(result) else waypoint(result) { it.pauseMission(ctx.completion(result)) }
|
||||
"resumeWaypointMission" -> if (v2Active) v2.resume(result) else waypoint(result) { it.resumeMission(ctx.completion(result)) }
|
||||
"cancelWaypointUpload" -> if (v2Active) v2.cancelUpload(result) else waypoint(result) { it.cancelUploadingMission(ctx.completion(result)) }
|
||||
"setWaypointAutoFlightSpeed" -> {
|
||||
val speed = (call.argument<Number>("speed") ?: 8).toFloat()
|
||||
it.setAutoFlightSpeed(speed, ctx.completion(result))
|
||||
if (v2Active) v2.setSpeed(speed, result) else waypoint(result) { it.setAutoFlightSpeed(speed, ctx.completion(result)) }
|
||||
}
|
||||
// Where the last mission was cut short (RTH, low battery, a crash of the app),
|
||||
// to resume from there: {missionId, index, lat, lon, altitude} or null.
|
||||
"getWaypointInterruption" -> waypoint(result) { op ->
|
||||
op.getPreviousInterruption(object : CommonCallbacks.CompletionCallbackWith<WaypointMissionInterruption> {
|
||||
override fun onSuccess(i: WaypointMissionInterruption?) {
|
||||
val m = i?.let {
|
||||
mapOf(
|
||||
"missionId" to it.missionID,
|
||||
"index" to it.index,
|
||||
"lat" to it.coordinate?.latitude,
|
||||
"lon" to it.coordinate?.longitude,
|
||||
"altitude" to it.altitude.toDouble(),
|
||||
)
|
||||
}
|
||||
ctx.mainHandler.post { result.success(m) }
|
||||
}
|
||||
|
||||
override fun onFailure(e: DJIError) {
|
||||
ctx.mainHandler.post { result.error("DJI_ERROR", e.description, null) }
|
||||
}
|
||||
})
|
||||
}
|
||||
// ActiveTrack Circle's orbit speed, m/s (negative = anticlockwise).
|
||||
"setActiveTrackCircularSpeed" -> track(result) {
|
||||
it.setCircularSpeed((call.argument<Number>("speed") ?: 5).toFloat(), ctx.completion(result))
|
||||
}
|
||||
"getActiveTrackCircularSpeed" -> track(result) { op ->
|
||||
op.getCircularSpeed(object : CommonCallbacks.CompletionCallbackWith<Float> {
|
||||
override fun onSuccess(v: Float?) { ctx.mainHandler.post { result.success(v?.toDouble()) } }
|
||||
override fun onFailure(e: DJIError) { ctx.mainHandler.post { result.error("DJI_ERROR", e.description, null) } }
|
||||
})
|
||||
}
|
||||
"startActiveTrack" -> startActiveTrack(call, result)
|
||||
"stopActiveTrack" -> track(result) { it.stopTracking(ctx.completion(result)) }
|
||||
@@ -404,6 +442,11 @@ class MissionBridge(private val ctx: BridgeCtx) : SubBridge {
|
||||
op.startTracking(mission, ctx.completion(result))
|
||||
}
|
||||
|
||||
/**
|
||||
* A QuickShot on the subject boxed at `{x, y, w, h}` (0..1 of the live
|
||||
* view; the centre when omitted). The operator reports it as
|
||||
* PERFORMING_QUICK_SHOT with `tracking.percent` until the aircraft is back.
|
||||
*/
|
||||
private fun startQuickShot(call: MethodCall, result: MethodChannel.Result) {
|
||||
val op = trackOp() ?: run { ctx.fail(result, "QuickShots unavailable"); return }
|
||||
val name = call.argument<String>("name")?.uppercase() ?: ""
|
||||
@@ -413,9 +456,21 @@ class MissionBridge(private val ctx: BridgeCtx) : SubBridge {
|
||||
ctx.fail(result, "$name is not supported on this aircraft")
|
||||
return
|
||||
}
|
||||
val mission = ActiveTrackMission(RectF(0.4f, 0.35f, 0.6f, 0.65f), ActiveTrackMode.QUICK_SHOT)
|
||||
val x = (call.argument<Number>("x") ?: 0.4).toFloat()
|
||||
val y = (call.argument<Number>("y") ?: 0.35).toFloat()
|
||||
val w = (call.argument<Number>("w") ?: 0.2).toFloat()
|
||||
val h = (call.argument<Number>("h") ?: 0.3).toFloat()
|
||||
val mission = ActiveTrackMission(RectF(x, y, x + w, y + h), ActiveTrackMode.QUICK_SHOT)
|
||||
mission.quickShotMode = quick
|
||||
ctx.emit(mapOf("type" to "mission", "state" to "quickshot"))
|
||||
op.canStartTracking(mission)?.let {
|
||||
ctx.fail(result, it.description)
|
||||
return
|
||||
}
|
||||
op.startTracking(mission, ctx.completion(result))
|
||||
}
|
||||
|
||||
// ── Waypoints 2.0 (Matrice 300; plan P7) ─────────────────────────────────────
|
||||
|
||||
@Volatile private var v2Active = false
|
||||
private val v2 = WaypointV2(ctx) { v2Active = it }
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.pilotvault.flyapp
|
||||
import dji.common.gimbal.CapabilityKey
|
||||
import dji.common.util.DJIParamMinMaxCapability
|
||||
import dji.keysdk.CameraKey
|
||||
import dji.keysdk.FlightControllerKey
|
||||
import dji.keysdk.KeyManager
|
||||
import dji.sdk.base.BaseProduct
|
||||
import dji.sdk.camera.Camera
|
||||
@@ -107,6 +108,10 @@ class ProductProfileBridge(private val ctx: BridgeCtx) : SubBridge {
|
||||
"accessLockerSupported" to probe { fc.isAccessLockerSupported },
|
||||
"onboardSdkAvailable" to probe { fc.isOnboardSDKDeviceAvailable },
|
||||
"simulatorAvailable" to (probe { fc.simulator } != null),
|
||||
// From the key cache: null until the FC has answered it once.
|
||||
"precisionTakeOffSupported" to probe {
|
||||
KeyManager.getInstance()?.getValue(FlightControllerKey.create(FlightControllerKey.IS_PRECISION_TAKE_OFF_SUPPORTED)) as? Boolean
|
||||
},
|
||||
)
|
||||
},
|
||||
"remoteController" to aircraft?.remoteController?.let { rc ->
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.pilotvault.flyapp
|
||||
|
||||
import dji.common.airlink.ChannelInterference
|
||||
import dji.common.airlink.FrequencyInterference
|
||||
import dji.common.airlink.WifiChannelInterference
|
||||
import dji.common.error.DJIError
|
||||
import dji.common.remotecontroller.ConnectToMasterResult
|
||||
import dji.common.remotecontroller.Credentials
|
||||
import dji.common.remotecontroller.HardwareState
|
||||
import dji.common.remotecontroller.Information
|
||||
import dji.common.util.CommonCallbacks
|
||||
import dji.keysdk.RemoteControllerKey
|
||||
import dji.sdk.base.BaseProduct
|
||||
import dji.sdk.products.Aircraft
|
||||
import dji.sdk.remotecontroller.RemoteController
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/**
|
||||
* The remote controller's extras (plan P6) that are not plain key values:
|
||||
*
|
||||
* - **C1 / C2**: the consumer remotes only report the presses
|
||||
* (`CUSTOM_BUTTON_1/2`); what they do is the app's, as in GO 4. Each press
|
||||
* goes out as `{type: "rcButton", button: "C1" | "C2"}`. KeyManager listeners
|
||||
* stack, so this never steals the hardware-state callback the RC
|
||||
* calibration wizard uses.
|
||||
* - **Master / slave** (dual operator): `searchMasters {on}` publishes the
|
||||
* remotes in range, as the search finds them, as `rcMasters` events
|
||||
* `{masters: [{id, name, signal}]}`;
|
||||
* `connectToMaster {id, name, password}` answers the SDK's result name.
|
||||
* - **Spectrum** for the video-link tab: `watchSpectrum {on}` registers the
|
||||
* link's interference callback and publishes `spectrum` events, `{link,
|
||||
* bands: [{from, to, rssi}]}` on OcuSync or `{link, channels: [{channel,
|
||||
* power}]}` on Lightbridge / Wi-Fi.
|
||||
* - **Wi-Fi credentials**: `setWifiCredentials {ssid?, password?}` then
|
||||
* `rebootWifi` (the link only takes the new ones after a restart).
|
||||
*/
|
||||
class RemoteControllerBridge(private val ctx: BridgeCtx) : SubBridge {
|
||||
|
||||
private val buttons = KeyWatch(ctx)
|
||||
|
||||
/** Last press state per button, so one click is one event. */
|
||||
private val down = HashMap<String, Boolean>()
|
||||
|
||||
private var spectrumOn = false
|
||||
|
||||
private fun rc(): RemoteController? = ctx.aircraft()?.remoteController
|
||||
|
||||
override fun bind(product: BaseProduct?) {
|
||||
buttons.clear()
|
||||
down.clear()
|
||||
if (product !is Aircraft || product.remoteController == null) return
|
||||
watchButton("C1", RemoteControllerKey.CUSTOM_BUTTON_1)
|
||||
watchButton("C2", RemoteControllerKey.CUSTOM_BUTTON_2)
|
||||
if (spectrumOn) setSpectrum(product, true)
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
buttons.clear()
|
||||
down.clear()
|
||||
}
|
||||
|
||||
private fun watchButton(name: String, key: String) {
|
||||
buttons.watch(RemoteControllerKey.create(key)) { v ->
|
||||
val b = v as? HardwareState.Button ?: return@watch
|
||||
val pressed = b.isClicked
|
||||
val was = down[name] ?: false
|
||||
down[name] = pressed
|
||||
// The rising edge only: holding the button is one press.
|
||||
if (pressed && !was) ctx.emit(mapOf("type" to "rcButton", "button" to name))
|
||||
}
|
||||
}
|
||||
|
||||
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
|
||||
when (call.method) {
|
||||
"searchMasters" -> {
|
||||
val r = rc() ?: run { ctx.fail(result, "No remote controller"); return true }
|
||||
if (call.argument<Boolean>("on") == true) {
|
||||
var answered = false
|
||||
r.startMasterSearching(object : RemoteController.MasterSearchingCallback {
|
||||
override fun onResult(e: DJIError?) {
|
||||
ctx.mainHandler.post {
|
||||
if (answered) return@post
|
||||
answered = true
|
||||
if (e == null) result.success(null) else result.error("DJI_ERROR", e.description, null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMasterListUpdate(list: Array<Information>) {
|
||||
ctx.emit(mapOf("type" to "rcMasters", "masters" to list.map(::info)))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
r.stopMasterSearching(ctx.completion(result))
|
||||
}
|
||||
}
|
||||
"getSlaves" -> {
|
||||
val r = rc() ?: run { ctx.fail(result, "No remote controller"); return true }
|
||||
r.getSlaveList(object : CommonCallbacks.CompletionCallbackWith<Array<Information>> {
|
||||
override fun onSuccess(list: Array<Information>?) {
|
||||
ctx.mainHandler.post { result.success(list.orEmpty().map(::info)) }
|
||||
}
|
||||
|
||||
override fun onFailure(e: DJIError) {
|
||||
ctx.mainHandler.post { result.error("DJI_ERROR", e.description, null) }
|
||||
}
|
||||
})
|
||||
}
|
||||
"connectToMaster" -> {
|
||||
val r = rc() ?: run { ctx.fail(result, "No remote controller"); return true }
|
||||
val creds = Credentials(
|
||||
call.argument<Int>("id") ?: 0,
|
||||
call.argument<String>("name") ?: "",
|
||||
call.argument<String>("password") ?: "",
|
||||
)
|
||||
r.connectToMaster(creds, object : CommonCallbacks.CompletionCallbackWith<ConnectToMasterResult> {
|
||||
override fun onSuccess(v: ConnectToMasterResult?) {
|
||||
ctx.mainHandler.post { result.success(v?.name ?: "UNKNOWN") }
|
||||
}
|
||||
|
||||
override fun onFailure(e: DJIError) {
|
||||
ctx.mainHandler.post { result.error("DJI_ERROR", e.description, null) }
|
||||
}
|
||||
})
|
||||
}
|
||||
"watchSpectrum" -> {
|
||||
spectrumOn = call.argument<Boolean>("on") == true
|
||||
setSpectrum(ctx.product(), spectrumOn)
|
||||
result.success(null)
|
||||
}
|
||||
"setWifiCredentials" -> {
|
||||
val wifi = ctx.product()?.airLink?.wiFiLink ?: run { ctx.fail(result, "No Wi-Fi link"); return true }
|
||||
val ssid = call.argument<String>("ssid")
|
||||
val password = call.argument<String>("password")
|
||||
val done = ctx.completion(result)
|
||||
// SSID first, then the password: each is its own write on the link.
|
||||
val setPassword = {
|
||||
if (password == null) done.onResult(null) else wifi.setPassword(password, done)
|
||||
}
|
||||
if (ssid == null) {
|
||||
setPassword()
|
||||
} else {
|
||||
wifi.setSSID(ssid) { e: DJIError? -> if (e != null) done.onResult(e) else setPassword() }
|
||||
}
|
||||
}
|
||||
"rebootWifi" -> {
|
||||
val wifi = ctx.product()?.airLink?.wiFiLink ?: run { ctx.fail(result, "No Wi-Fi link"); return true }
|
||||
wifi.reboot(ctx.completion(result))
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun info(i: Information?): Map<String, Any?> =
|
||||
mapOf("id" to i?.id, "name" to i?.name, "signal" to i?.signalQuality?.toInt(), "gimbalControl" to i?.hasGimbalControlPermission())
|
||||
|
||||
private fun setSpectrum(product: BaseProduct?, on: Boolean) {
|
||||
val link = product?.airLink ?: return
|
||||
try {
|
||||
link.ocuSyncLink?.setChannelInterferenceCallback(
|
||||
if (!on) null else dji.sdk.airlink.OcuSyncLink.ChannelInterferenceCallback { bands: Array<FrequencyInterference>? ->
|
||||
ctx.emit(mapOf("type" to "spectrum", "link" to "ocusync", "bands" to bands.orEmpty().map { b ->
|
||||
mapOf("from" to b.frequencyFrom.toDouble(), "to" to b.frequencyTo.toDouble(), "rssi" to b.rssi.toInt())
|
||||
}))
|
||||
},
|
||||
)
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
try {
|
||||
link.lightbridgeLink?.setChannelInterferenceCallback(
|
||||
if (!on) null else dji.sdk.airlink.LightbridgeLink.ChannelInterferenceCallback { channels: Array<ChannelInterference>? ->
|
||||
ctx.emit(mapOf("type" to "spectrum", "link" to "lightbridge", "channels" to channels.orEmpty().map { c ->
|
||||
mapOf("channel" to c.channel, "power" to c.power)
|
||||
}))
|
||||
},
|
||||
)
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
try {
|
||||
link.wiFiLink?.setChannelInterferenceCallback(
|
||||
if (!on) null else dji.sdk.airlink.WiFiLink.ChannelInterferenceCallback { channels: Array<WifiChannelInterference>? ->
|
||||
ctx.emit(mapOf("type" to "spectrum", "link" to "wifi", "channels" to channels.orEmpty().map { c ->
|
||||
mapOf("channel" to c.channel, "power" to c.power, "band" to c.band?.name)
|
||||
}))
|
||||
},
|
||||
)
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.pilotvault.flyapp
|
||||
|
||||
import dji.sdk.camera.VideoFeeder
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* GO 4's video cache (plan P6): the live feed recorded to the phone while the
|
||||
* aircraft flies, so there is footage even when the SD card fails.
|
||||
*
|
||||
* The primary feed's raw H.264 elementary stream (what the decoder is fed) is
|
||||
* written as is to `files/video_cache/<yyyyMMdd_HHmmss>.h264` — playable in
|
||||
* VLC / ffmpeg, no transcoding on the phone. Dart decides when to record
|
||||
* (`setVideoCacheRecording {on, limitMb}`, on take-off and landing while the
|
||||
* pref is on); the oldest files are deleted once the folder outgrows the cap.
|
||||
* `getVideoCacheInfo` answers `{bytes, files, recording}`, `clearVideoCache`
|
||||
* empties it (not while recording).
|
||||
*/
|
||||
class VideoCacheBridge(private val ctx: BridgeCtx) : SubBridge {
|
||||
|
||||
private val io = Executors.newSingleThreadExecutor()
|
||||
private var out: BufferedOutputStream? = null
|
||||
private var limitBytes = 2048L shl 20
|
||||
private var written = 0L
|
||||
|
||||
private val dir: File get() = File(ctx.appContext.filesDir, "video_cache").apply { mkdirs() }
|
||||
|
||||
private val listener = VideoFeeder.VideoDataListener { data: ByteArray?, size: Int ->
|
||||
if (data == null || size <= 0) return@VideoDataListener
|
||||
val copy = data.copyOf(size)
|
||||
io.execute {
|
||||
val o = out ?: return@execute
|
||||
try {
|
||||
o.write(copy)
|
||||
written += copy.size
|
||||
// Re-check the cap every ~8 MB rather than per packet.
|
||||
if (written > 8L shl 20) {
|
||||
written = 0
|
||||
trim()
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
stopOnIo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
|
||||
when (call.method) {
|
||||
"setVideoCacheRecording" -> {
|
||||
val on = call.argument<Boolean>("on") == true
|
||||
call.argument<Int>("limitMb")?.let { limitBytes = it.toLong() shl 20 }
|
||||
if (on) start() else stop()
|
||||
result.success(null)
|
||||
}
|
||||
"getVideoCacheInfo" -> io.execute {
|
||||
val files = dir.listFiles().orEmpty().filter { it.isFile }
|
||||
val info = mapOf("bytes" to files.sumOf { it.length() }, "files" to files.size, "recording" to (out != null))
|
||||
ctx.mainHandler.post { result.success(info) }
|
||||
}
|
||||
"clearVideoCache" -> io.execute {
|
||||
val current = out != null
|
||||
if (!current) dir.listFiles().orEmpty().forEach { it.delete() }
|
||||
ctx.mainHandler.post {
|
||||
if (current) result.error("BUSY", "Recording to the cache: land first", null) else result.success(null)
|
||||
}
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun reset() = stop()
|
||||
|
||||
private fun start() {
|
||||
val feed = VideoFeeder.getInstance()?.primaryVideoFeed ?: return
|
||||
io.execute {
|
||||
if (out != null) return@execute
|
||||
val name = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + ".h264"
|
||||
out = BufferedOutputStream(FileOutputStream(File(dir, name)), 1 shl 16)
|
||||
trim()
|
||||
ctx.mainHandler.post { feed.addVideoDataListener(listener) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun stop() {
|
||||
try {
|
||||
VideoFeeder.getInstance()?.primaryVideoFeed?.removeVideoDataListener(listener)
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
io.execute { stopOnIo() }
|
||||
}
|
||||
|
||||
private fun stopOnIo() {
|
||||
try {
|
||||
out?.close()
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
out = null
|
||||
}
|
||||
|
||||
/** Deletes the oldest recordings until the folder fits under the cap. */
|
||||
private fun trim() {
|
||||
val files = dir.listFiles().orEmpty().filter { it.isFile }.sortedBy { it.lastModified() }.toMutableList()
|
||||
var total = files.sumOf { it.length() }
|
||||
// Never the newest: that is the one being written.
|
||||
while (total > limitBytes && files.size > 1) {
|
||||
val f = files.removeAt(0)
|
||||
total -= f.length()
|
||||
f.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.pilotvault.flyapp
|
||||
|
||||
import dji.common.error.DJIWaypointV2Error
|
||||
import dji.common.mission.waypointv2.WaypointV2
|
||||
import dji.common.mission.waypointv2.WaypointV2Mission
|
||||
import dji.common.mission.waypointv2.WaypointV2MissionDownloadEvent
|
||||
import dji.common.mission.waypointv2.WaypointV2MissionExecutionEvent
|
||||
import dji.common.mission.waypointv2.WaypointV2MissionTypes
|
||||
import dji.common.mission.waypointv2.WaypointV2MissionUploadEvent
|
||||
import dji.common.model.LocationCoordinate2D
|
||||
import dji.common.util.CommonCallbacks
|
||||
import dji.sdk.mission.waypoint.WaypointV2MissionOperator
|
||||
import dji.sdk.mission.waypoint.WaypointV2MissionOperatorListener
|
||||
import dji.sdk.sdkmanager.DJISDKManager
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/**
|
||||
* Waypoints 2.0 for [MissionBridge] (Matrice 300, the only aircraft whose
|
||||
* flight controller runs V2): the same resolved mission map the V1 upload
|
||||
* takes (`WaypointMissionSpec.toBridge`), built into a [WaypointV2Mission].
|
||||
*
|
||||
* Per waypoint: position, altitude, a custom heading when the map has one,
|
||||
* its own speed when > 0, the turn direction and the corner radius as the
|
||||
* damping distance. Camera actions are not carried over — V2 actions are a
|
||||
* separate trigger/actuator list — and the Dart gate warns about that.
|
||||
*
|
||||
* Progress goes out as the V1 events do (`mission`, `operator: 'waypoint'`),
|
||||
* so the run panel reads both; V2's `INTERRUPTED` is its paused state.
|
||||
* Pause / resume are V2's interrupt / recover.
|
||||
*/
|
||||
class WaypointV2(private val ctx: BridgeCtx, private val setActive: (Boolean) -> Unit) {
|
||||
|
||||
private fun op(): WaypointV2MissionOperator? =
|
||||
try {
|
||||
DJISDKManager.getInstance().missionControl?.waypointMissionV2Operator
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
|
||||
private var listened: WaypointV2MissionOperator? = null
|
||||
|
||||
private val listener = object : WaypointV2MissionOperatorListener {
|
||||
override fun onDownloadUpdate(e: WaypointV2MissionDownloadEvent) {}
|
||||
|
||||
override fun onUploadUpdate(e: WaypointV2MissionUploadEvent) {
|
||||
val p = e.progress
|
||||
emit(
|
||||
e.currentState?.name,
|
||||
upload = p?.let { mapOf("index" to it.lastUploadedWaypointIndex, "total" to it.totalWaypointCount, "summary" to it.isSummaryUploaded) },
|
||||
error = e.error,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onExecutionUpdate(e: WaypointV2MissionExecutionEvent) {
|
||||
val p = e.progress
|
||||
emit(
|
||||
e.currentState?.name,
|
||||
execution = p?.let {
|
||||
mapOf("target" to it.targetWaypointIndex, "reached" to it.isWaypointReached, "executeState" to it.executeState?.name)
|
||||
},
|
||||
error = e.error,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onExecutionStart() = emit(op()?.currentState?.name, event = "start")
|
||||
|
||||
override fun onExecutionFinish(error: DJIWaypointV2Error?) = emit(op()?.currentState?.name, event = "finish", error = error)
|
||||
|
||||
override fun onExecutionStopped() = emit(op()?.currentState?.name, event = "finish")
|
||||
}
|
||||
|
||||
private fun emit(
|
||||
state: String?,
|
||||
upload: Map<String, Any?>? = null,
|
||||
execution: Map<String, Any?>? = null,
|
||||
event: String? = null,
|
||||
error: DJIWaypointV2Error? = null,
|
||||
) {
|
||||
val m = linkedMapOf<String, Any?>("type" to "mission", "operator" to "waypoint", "v2" to true)
|
||||
// V2 calls a paused mission INTERRUPTED; the Dart side speaks V1.
|
||||
if (state != null) m["state"] = if (state == "INTERRUPTED") "EXECUTION_PAUSED" else state
|
||||
if (upload != null) m["upload"] = upload
|
||||
if (execution != null) m["execution"] = execution
|
||||
if (event != null) m["event"] = event
|
||||
if (error != null) m["error"] = error.description
|
||||
ctx.emit(m)
|
||||
}
|
||||
|
||||
fun bind() {
|
||||
val o = op() ?: return
|
||||
if (listened !== o) {
|
||||
listened?.removeWaypointListener(listener)
|
||||
o.addWaypointEventListener(listener)
|
||||
listened = o
|
||||
}
|
||||
}
|
||||
|
||||
private fun done(result: MethodChannel.Result) = CommonCallbacks.CompletionCallback<DJIWaypointV2Error> { e ->
|
||||
ctx.mainHandler.post { if (e == null) result.success(null) else result.error("DJI_ERROR", e.description, null) }
|
||||
}
|
||||
|
||||
fun upload(call: MethodCall, result: MethodChannel.Result) {
|
||||
val o = op() ?: run { ctx.fail(result, "Waypoints 2.0 unavailable"); return }
|
||||
val raw = call.argument<List<Map<String, Any?>>>("waypoints") ?: emptyList()
|
||||
if (raw.size < 2) {
|
||||
ctx.fail(result, "A route needs at least 2 waypoints")
|
||||
return
|
||||
}
|
||||
val curved = call.argument<String>("flightPathMode") == "CURVED"
|
||||
val points = raw.map { p ->
|
||||
val b = WaypointV2.Builder()
|
||||
.setCoordinate(LocationCoordinate2D((p["lat"] as Number).toDouble(), (p["lon"] as Number).toDouble()))
|
||||
.setAltitude((p["altitude"] as? Number)?.toDouble() ?: 50.0)
|
||||
.setFlightPathMode(
|
||||
if (curved) WaypointV2MissionTypes.WaypointV2FlightPathMode.CURVATURE_CONTINUOUS_PASSED
|
||||
else WaypointV2MissionTypes.WaypointV2FlightPathMode.GOTO_POINT_STRAIGHT_LINE_AND_STOP,
|
||||
)
|
||||
.setTurnMode(
|
||||
if (p["turnMode"] == "COUNTER_CLOCKWISE") WaypointV2MissionTypes.WaypointV2TurnMode.COUNTER_CLOCKWISE
|
||||
else WaypointV2MissionTypes.WaypointV2TurnMode.CLOCKWISE,
|
||||
)
|
||||
val heading = (p["heading"] as? Number)?.toFloat()
|
||||
if (heading != null) {
|
||||
b.setHeadingMode(WaypointV2MissionTypes.WaypointV2HeadingMode.WAYPOINT_CUSTOM).setHeading(heading)
|
||||
} else {
|
||||
b.setHeadingMode(WaypointV2MissionTypes.WaypointV2HeadingMode.AUTO)
|
||||
}
|
||||
(p["speed"] as? Number)?.toFloat()?.takeIf { it > 0 }?.let { s ->
|
||||
b.setUsingWaypointAutoFlightSpeed(true).setAutoFlightSpeed(s)
|
||||
}
|
||||
(p["cornerRadius"] as? Number)?.toFloat()?.let { b.setDampingDistance(it) }
|
||||
b.build()
|
||||
}
|
||||
val finished = when (call.argument<String>("finishedAction")) {
|
||||
"GO_HOME" -> WaypointV2MissionTypes.MissionFinishedAction.GO_HOME
|
||||
"AUTO_LAND" -> WaypointV2MissionTypes.MissionFinishedAction.AUTO_LAND
|
||||
"GO_FIRST_WAYPOINT" -> WaypointV2MissionTypes.MissionFinishedAction.GO_FIRST_WAYPOINT
|
||||
"CONTINUE_UNTIL_END" -> WaypointV2MissionTypes.MissionFinishedAction.CONTINUE_UNTIL_STOP
|
||||
else -> WaypointV2MissionTypes.MissionFinishedAction.NO_ACTION
|
||||
}
|
||||
val builder = WaypointV2Mission.Builder()
|
||||
.setMissionID(System.currentTimeMillis() and 0x7FFFFFFF)
|
||||
.setAutoFlightSpeed((call.argument<Number>("autoFlightSpeed") ?: 8).toFloat())
|
||||
.setMaxFlightSpeed((call.argument<Number>("maxFlightSpeed") ?: 15).toFloat())
|
||||
.setFinishedAction(finished)
|
||||
.setGotoFirstWaypointMode(
|
||||
if (call.argument<String>("gotoFirstWaypointMode") == "POINT_TO_POINT") WaypointV2MissionTypes.MissionGotoWaypointMode.POINT_TO_POINT
|
||||
else WaypointV2MissionTypes.MissionGotoWaypointMode.SAFELY,
|
||||
)
|
||||
.setExitMissionOnRCSignalLostEnabled(call.argument<Boolean>("exitMissionOnRCSignalLost") ?: true)
|
||||
.setRepeatTimes(call.argument<Number>("repeatTimes")?.toInt() ?: 1)
|
||||
.addwaypoints(points)
|
||||
call.argument<Map<String, Any?>>("pointOfInterest")?.let { poi ->
|
||||
builder.setPointOfInterest(LocationCoordinate2D((poi["lat"] as Number).toDouble(), (poi["lon"] as Number).toDouble()))
|
||||
}
|
||||
setActive(true)
|
||||
o.loadMission(builder.build()) { e: DJIWaypointV2Error? ->
|
||||
if (e != null) {
|
||||
ctx.mainHandler.post { result.error("DJI_ERROR", e.description, null) }
|
||||
} else {
|
||||
o.uploadMission(done(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun start(result: MethodChannel.Result) = op()?.startMission(done(result)) ?: ctx.fail(result, "Waypoints 2.0 unavailable")
|
||||
fun stop(result: MethodChannel.Result) = op()?.stopMission(done(result)) ?: ctx.fail(result, "Waypoints 2.0 unavailable")
|
||||
fun pause(result: MethodChannel.Result) = op()?.interruptMission(done(result)) ?: ctx.fail(result, "Waypoints 2.0 unavailable")
|
||||
fun resume(result: MethodChannel.Result) = op()?.recoverMission(done(result)) ?: ctx.fail(result, "Waypoints 2.0 unavailable")
|
||||
fun cancelUpload(result: MethodChannel.Result) = op()?.cancelUploadingMission(done(result)) ?: ctx.fail(result, "Waypoints 2.0 unavailable")
|
||||
fun setSpeed(speed: Float, result: MethodChannel.Result) =
|
||||
op()?.setAutoFlightSpeed(speed, done(result)) ?: ctx.fail(result, "Waypoints 2.0 unavailable")
|
||||
}
|
||||
Reference in new issue
Block a user