Give the Fly App GO 4-style gimbal controls

Plan P5: a value bubble on the pitch slider while dragging, press-and-drag
on the video as a speed joystick (re-sent at 10 Hz, zero on release), and a
gimbal settings sheet: mode, 30° extension, yaw follow, recenter / face down /
yaw recenter, roll fine-tune, auto calibration and advanced speed/smoothness
rows gated by the gimbal's capability ranges.

GimbalBridge reports mode, trim, calibration and stop/overload state in the
gimbal telemetry group, and getter-only settings in a new gimbalSettings slow
group. The reported-plus-pending overlay moves out of CameraState into a
SettingsOverlay mixin shared by camera and gimbal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-19 20:22:42 +02:00
1 parent c1770fff73
commit 60c2fd7f64
19 files changed
+1600 -102

No files matched your search

@@ -1,7 +1,12 @@
package com.pilotvault.flyapp
import dji.common.error.DJIError
import dji.common.gimbal.Axis
import dji.common.gimbal.CapabilityKey
import dji.common.gimbal.GimbalMode
import dji.common.gimbal.Rotation
import dji.common.gimbal.RotationMode
import dji.common.util.CommonCallbacks
import dji.sdk.base.BaseProduct
import dji.sdk.gimbal.Gimbal
import dji.sdk.products.Aircraft
@@ -9,11 +14,91 @@ import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* Gimbal pitch control (from the flight HUD's pitch slider) and gimbal attitude,
* published as the `gimbal` fast group of the [TelemetryHub] (`{pitch, roll, yaw}`).
* Gimbal control and state (plan P5).
*
* - **Attitude and status** go out as the `gimbal` fast group of the
* [TelemetryHub]: `{pitch, roll, yaw, mode, yawRelative, *FineTune,
* calibrating, calibrationProgress, calibrationSuccessful, pitchAtStop,
* yawAtStop, motorOverloaded}`.
* - **Settings** the SDK only exposes as getters (pitch extension, yaw follow,
* the controller's smoothing / max speed / speed coefficient per axis) are
* read on bind, again once the gimbal has handshaken, and after every write,
* into the `gimbalSettings` slow group. A setting the gimbal does not support
* is never read, so it stays absent and its row hides.
* - **Control**: `rotateGimbalPitch` (absolute, the pitch slider),
* `rotateGimbalSpeed` (speed mode, the drag-on-video joystick — the caller
* repeats it at 10 Hz and ends with zero), `setGimbalMode`, `resetGimbal`,
* `resetGimbalYaw`, `fineTuneGimbal`, `startGimbalCalibration`,
* `setGimbalSetting {id, value}` and `resetGimbalControlParameters`.
*/
class GimbalBridge(private val ctx: BridgeCtx) : SubBridge {
/** A setting backed by an SDK getter / setter pair, by its `gimbalSettings` id. */
private class Setting(
val capability: CapabilityKey?,
val read: (Gimbal, CommonCallbacks.CompletionCallbackWith<Any>) -> Unit,
val write: (Gimbal, Any, CommonCallbacks.CompletionCallback<DJIError>) -> Unit,
)
private fun intSetting(
capability: CapabilityKey,
get: (Gimbal, CommonCallbacks.CompletionCallbackWith<Int>) -> Unit,
set: (Gimbal, Int, CommonCallbacks.CompletionCallback<DJIError>) -> Unit,
) = Setting(
capability,
{ g, cb -> get(g, forward(cb)) },
{ g, v, cb -> set(g, (v as Number).toInt(), cb) },
)
private fun <T> forward(cb: CommonCallbacks.CompletionCallbackWith<Any>) = object : CommonCallbacks.CompletionCallbackWith<T> {
override fun onSuccess(value: T) = cb.onSuccess(value as Any)
override fun onFailure(error: DJIError) = cb.onFailure(error)
}
private val settings: Map<String, Setting> = mapOf(
"pitchExtension" to Setting(
CapabilityKey.PITCH_RANGE_EXTENSION,
{ g, cb -> g.getPitchRangeExtensionEnabled(forward(cb)) },
{ g, v, cb -> g.setPitchRangeExtensionEnabled(v as Boolean, cb) },
),
// No capability key: read it, and a gimbal without it answers with an error.
"yawFollow" to Setting(
null,
{ g, cb -> g.getYawSimultaneousFollowEnabled(forward(cb)) },
{ g, v, cb -> g.setYawSimultaneousFollowEnabled(v as Boolean, cb) },
),
"pitchSmoothing" to intSetting(
CapabilityKey.PITCH_CONTROLLER_SMOOTHING_FACTOR,
{ g, cb -> g.getControllerSmoothingFactor(Axis.PITCH, cb) },
{ g, v, cb -> g.setControllerSmoothingFactor(Axis.PITCH, v, cb) },
),
"yawSmoothing" to intSetting(
CapabilityKey.YAW_CONTROLLER_SMOOTHING_FACTOR,
{ g, cb -> g.getControllerSmoothingFactor(Axis.YAW, cb) },
{ g, v, cb -> g.setControllerSmoothingFactor(Axis.YAW, v, cb) },
),
"pitchMaxSpeed" to intSetting(
CapabilityKey.PITCH_CONTROLLER_MAX_SPEED,
{ g, cb -> g.getControllerMaxSpeed(Axis.PITCH, cb) },
{ g, v, cb -> g.setControllerMaxSpeed(Axis.PITCH, v, cb) },
),
"yawMaxSpeed" to intSetting(
CapabilityKey.YAW_CONTROLLER_MAX_SPEED,
{ g, cb -> g.getControllerMaxSpeed(Axis.YAW, cb) },
{ g, v, cb -> g.setControllerMaxSpeed(Axis.YAW, v, cb) },
),
"pitchSpeedCoefficient" to intSetting(
CapabilityKey.PITCH_CONTROLLER_SPEED_COEFFICIENT,
{ g, cb -> g.getControllerSpeedCoefficient(Axis.PITCH, cb) },
{ g, v, cb -> g.setControllerSpeedCoefficient(Axis.PITCH, v, cb) },
),
"yawSpeedCoefficient" to intSetting(
CapabilityKey.YAW_CONTROLLER_SPEED_COEFFICIENT,
{ g, cb -> g.getControllerSpeedCoefficient(Axis.YAW, cb) },
{ g, v, cb -> g.setControllerSpeedCoefficient(Axis.YAW, v, cb) },
),
)
private fun gimbal(): Gimbal? = ctx.aircraft()?.gimbal
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
@@ -28,7 +113,54 @@ class GimbalBridge(private val ctx: BridgeCtx) : SubBridge {
.build()
it.rotate(rotation, ctx.completion(result))
}
"rotateGimbalSpeed" -> guard(g, result) {
// Degrees per second; an axis left at NO_ROTATION does not move.
val b = Rotation.Builder().mode(RotationMode.SPEED)
call.argument<Double>("pitch")?.let { p -> b.pitch(p.toFloat()) }
call.argument<Double>("yaw")?.let { y -> b.yaw(y.toFloat()) }
it.rotate(b.build(), ctx.completion(result))
}
"setGimbalMode" -> guard(g, result) {
val mode = enumByName<GimbalMode>(call.argument<String>("mode"))
if (mode == null || mode == GimbalMode.UNKNOWN) ctx.fail(result, "Unknown gimbal mode")
else it.setMode(mode, ctx.completion(result))
}
// Pitch and yaw back to centre (GO 4 "Recenter Gimbal").
"resetGimbal" -> guard(g, result) { it.reset(ctx.completion(result)) }
"resetGimbalYaw" -> guard(g, result) { it.resetYaw(ctx.completion(result)) }
"fineTuneGimbal" -> guard(g, result) {
val deg = (call.argument<Double>("degrees") ?: 0.0).toFloat()
when (call.argument<String>("axis")) {
"roll" -> it.fineTuneRollInDegrees(deg, ctx.completion(result))
"pitch" -> it.fineTunePitchInDegrees(deg, ctx.completion(result))
"yaw" -> it.fineTuneYawInDegrees(deg, ctx.completion(result))
else -> ctx.fail(result, "Unknown axis")
}
}
"startGimbalCalibration" -> guard(g, result) { it.startCalibration(ctx.completion(result)) }
"setGimbalSetting" -> guard(g, result) {
val id = call.argument<String>("id")
val s = settings[id]
val value = call.argument<Any>("value")
if (s == null || id == null || value == null) {
ctx.fail(result, "Unknown gimbal setting $id")
} else {
s.write(it, value, object : CommonCallbacks.CompletionCallback<DJIError> {
override fun onResult(error: DJIError?) {
ctx.completion(result).onResult(error)
readSetting(it, id, s)
}
})
}
}
"resetGimbalControlParameters" -> guard(g, result) {
it.resetControlParameters(object : CommonCallbacks.CompletionCallback<DJIError> {
override fun onResult(error: DJIError?) {
ctx.completion(result).onResult(error)
readSettings(it)
}
})
}
else -> return false
}
return true
@@ -40,10 +172,59 @@ class GimbalBridge(private val ctx: BridgeCtx) : SubBridge {
override fun bind(product: BaseProduct?) {
if (product !is Aircraft) return
product.gimbal?.setStateCallback { state ->
val g = product.gimbal ?: return
g.setStateCallback { state ->
val a = state.attitudeInDegrees
val values = mapOf("pitch" to a?.pitch, "roll" to a?.roll, "yaw" to a?.yaw)
val values = mapOf(
"pitch" to a?.pitch,
"roll" to a?.roll,
"yaw" to a?.yaw,
"mode" to state.mode?.name,
"yawRelative" to state.yawRelativeToAircraftHeading,
"pitchFineTune" to state.pitchFineTuneInDegrees,
"rollFineTune" to state.rollFineTuneInDegrees,
"yawFineTune" to state.yawFineTuneInDegrees,
"calibrating" to state.isCalibrating,
"calibrationProgress" to state.calibrationProgress,
"calibrationSuccessful" to state.isCalibrationSuccessful,
"pitchAtStop" to state.isPitchAtStop,
"yawAtStop" to state.isYawAtStop,
"motorOverloaded" to state.isMotorOverloaded,
)
ctx.mainHandler.post { ctx.hub.putFast(mapOf("gimbal" to values)) }
}
readSettings(g)
// Capabilities are empty until the gimbal has handshaken; read again then.
ctx.mainHandler.postDelayed({ if (gimbal() === g) readSettings(g) }, 3000)
}
private fun readSettings(g: Gimbal) {
for ((id, s) in settings) readSetting(g, id, s)
}
private fun readSetting(g: Gimbal, id: String, s: Setting) {
if (s.capability != null) {
val caps = try {
g.capabilities
} catch (_: Throwable) {
null
}
if (caps?.get(s.capability)?.isSupported != true) return
}
try {
s.read(g, object : CommonCallbacks.CompletionCallbackWith<Any> {
override fun onSuccess(value: Any) {
val v = KeyValues.toDart(value)
ctx.mainHandler.post { ctx.hub.patchSlow("gimbalSettings", mapOf(id to v)) }
}
override fun onFailure(error: DJIError) {}
})
} catch (_: Throwable) {
// Not implemented for this gimbal: the setting stays absent.
}
}
private inline fun <reified T : Enum<T>> enumByName(name: String?): T? =
enumValues<T>().firstOrNull { it.name == name }
}
@@ -223,6 +223,15 @@ class ProductProfileBridge(private val ctx: BridgeCtx) : SubBridge {
"yawAdjustable" to supported(CapabilityKey.ADJUST_YAW),
"rollAdjustable" to supported(CapabilityKey.ADJUST_ROLL),
"pitchRangeExtensionSupported" to supported(CapabilityKey.PITCH_RANGE_EXTENSION),
// The controller tuning ranges behind the gimbal settings' advanced rows (P5).
"controller" to mapOf(
"pitchSmoothing" to range(CapabilityKey.PITCH_CONTROLLER_SMOOTHING_FACTOR),
"yawSmoothing" to range(CapabilityKey.YAW_CONTROLLER_SMOOTHING_FACTOR),
"pitchMaxSpeed" to range(CapabilityKey.PITCH_CONTROLLER_MAX_SPEED),
"yawMaxSpeed" to range(CapabilityKey.YAW_CONTROLLER_MAX_SPEED),
"pitchSpeedCoefficient" to range(CapabilityKey.PITCH_CONTROLLER_SPEED_COEFFICIENT),
"yawSpeedCoefficient" to range(CapabilityKey.YAW_CONTROLLER_SPEED_COEFFICIENT),
).filterValues { it != null },
)
}