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:
tajniak81andClaude Opus 5.5 committed 2026-10-20 23:06:16 +02:00
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 {
@@ -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")
}