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:
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 },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user