Give the Fly App GO 4-style calibration wizards

P6b: a new CalibrationBridge owns every calibration call and publishes
a `calibration` telemetry group (IMU per-unit and per-side progress,
gravity centre, lens, sensor cleaning, RC calibration state and live
sticks). Guided full-screen wizards cover compass, IMU, remote
controller, gimbal auto calibration, lens, sensor cleaning, centre of
gravity, propellers and a vision-sensor guide; gimbal adjust is a
horizon-line overlay on the live video. Every start goes through
SafetyGate.runMaintenance, and closing mid-run stops what the SDK can
stop.

Reachable from Settings > Safety > Calibration & maintenance, the
Aircraft Status calibrate buttons and the gimbal sheet. Not yet run on
an aircraft; the RC key sequence still needs checking per remote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-19 21:41:19 +02:00
1 parent 284de5442a
commit a892bfb209
20 files changed
+3119 -77

No files matched your search

@@ -0,0 +1,294 @@
package com.pilotvault.flyapp
import dji.common.camera.DJICameraCalibrateResult
import dji.common.camera.DJICameraCalibrateState
import dji.common.error.DJIError
import dji.common.flightcontroller.GravityCenterState
import dji.common.flightcontroller.imu.IMUState
import dji.common.remotecontroller.CalibrationState
import dji.keysdk.CameraKey
import dji.keysdk.DJIKey
import dji.keysdk.FlightControllerKey
import dji.keysdk.KeyManager
import dji.keysdk.RemoteControllerKey
import dji.keysdk.callback.SetCallback
import dji.sdk.base.BaseProduct
import dji.sdk.camera.Camera
import dji.sdk.flightcontroller.FlightController
import dji.sdk.products.Aircraft
import dji.sdk.remotecontroller.RemoteController
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* The calibration and maintenance wizards' native half (plan P6b).
*
* State goes out as the `calibration` fast group of the [TelemetryHub]:
*
* ```
* calibration: {
* support: {compassCount, imuCount, gravityCenter, propeller, lens, sensorCleaning, rc},
* imu: {units: [{index, state, progress, gyroscope, accelerometer, connected}],
* multiSide, orientationState, toCalibrate: [NOSE_DOWN …], calibrated: [...]},
* gravityCenter: {state, error},
* lens: {state, result},
* sensorCleaning: SensorCleaningState name,
* rc: {state, segments, axes: {A: …, …}},
* rcHardware: {lx, ly, rx, ry, leftDial, rightDial} // only while watched
* }
* ```
*
* The compass walks its own `compassCalibrationState` in the flight-controller
* keys ([FlightControllerBridge]); the gimbal its `gimbal` group ([GimbalBridge]).
*
* Methods: `startCompassCalibration`, `stopCompassCalibration`,
* `startIMUCalibration {index?}`, `startGravityCenterCalibration`,
* `stopGravityCenterCalibration`, `startPropellerCalibration`,
* `stopPropellerCalibration`, `startLensCalibration`, `stopLensCalibration`,
* `initSensorCleaning`, `startSensorCleaning`, `exitSensorCleaning`,
* `setRcCalibrationStep {step: MIDDLE | LIMITS | QUIT}` and
* `watchRcHardware {on}` (the sticks and dials, for the RC wizard's live view).
*
* The RC calibration key sequence (MIDDLE → LIMITS → QUIT) is what DJI's UX SDK
* drives; which remotes accept it is still to be confirmed on hardware (R2).
*/
class CalibrationBridge(private val ctx: BridgeCtx) : SubBridge {
/** The `calibration` group as last built; re-published whole on every change. */
private val group = LinkedHashMap<String, Any?>()
/** IMU units by index — the callback reports one unit at a time. */
private val imuUnits = sortedMapOf<Int, Map<String, Any?>>()
private val watch = KeyWatch(ctx)
private val rcWatch = KeyWatch(ctx)
private var rcHardwareOn = false
private fun fc(): FlightController? = ctx.aircraft()?.flightController
private fun camera(): Camera? = ctx.product()?.camera
private fun rc(): RemoteController? = ctx.aircraft()?.remoteController
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
when (call.method) {
"startCompassCalibration" -> withFc(result) {
val compass = it.compass
if (compass == null) ctx.fail(result, "No compass on this aircraft")
else compass.startCalibration(ctx.completion(result))
}
"stopCompassCalibration" -> withFc(result) {
val compass = it.compass
if (compass == null) ctx.fail(result, "No compass on this aircraft")
else compass.stopCalibration(ctx.completion(result))
}
"startIMUCalibration" -> withFc(result) {
val index = call.argument<Int>("index")
if (index == null) it.startIMUCalibration(ctx.completion(result))
else it.startIMUCalibration(index, ctx.completion(result))
}
"startGravityCenterCalibration" -> withFc(result) { it.startGravityCenterCalibration(ctx.completion(result)) }
"stopGravityCenterCalibration" -> withFc(result) { it.stopGravityCenterCalibration(ctx.completion(result)) }
"startPropellerCalibration" -> withFc(result) {
if (!it.isPropellerCalibrationSupported) ctx.fail(result, "Propeller calibration is not supported on this aircraft")
else it.startPropellerCalibration(ctx.completion(result))
}
"stopPropellerCalibration" -> withFc(result) { it.stopPropellerCalibration(ctx.completion(result)) }
"startLensCalibration" -> withCamera(result) { it.startCalibration(ctx.completion(result)) }
"stopLensCalibration" -> withCamera(result) { it.stopCalibration(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)) }
"setRcCalibrationStep" -> {
val step = CalibrationState.values().firstOrNull { it.name == call.argument<String>("step") }
val km = KeyManager.getInstance()
if (step == null) {
ctx.fail(result, "Unknown RC calibration step")
} else if (km == null || rc() == null) {
ctx.fail(result, "No remote controller connected")
} else {
km.setValue(RemoteControllerKey.create(RemoteControllerKey.CALIBRATION_STATE), step, object : SetCallback {
override fun onSuccess() {
ctx.mainHandler.post { result.success(null) }
}
override fun onFailure(error: DJIError) {
ctx.mainHandler.post { result.error("DJI_ERROR", error.description, null) }
}
})
}
}
"watchRcHardware" -> {
val on = call.argument<Boolean>("on") ?: false
ctx.mainHandler.post {
setRcHardware(on)
result.success(null)
}
}
else -> return false
}
return true
}
private inline fun withFc(result: MethodChannel.Result, block: (FlightController) -> Unit) {
val f = fc()
if (f == null) ctx.fail(result, "No aircraft connected") else block(f)
}
private inline fun withCamera(result: MethodChannel.Result, block: (Camera) -> Unit) {
val c = camera()
if (c == null) ctx.fail(result, "No camera connected") else block(c)
}
override fun bind(product: BaseProduct?) {
if (product !is Aircraft) return
watch.clear()
val fc = product.flightController
safe {
fc?.setIMUStateCallback { s -> ctx.mainHandler.post { onImu(s) } }
}
safe {
fc?.setGravityCenterStateCallback { s: GravityCenterState ->
val v = mapOf("state" to s.calibrationState?.name, "error" to s.calibrationError?.description)
ctx.mainHandler.post { put("gravityCenter", v) }
}
}
safe {
product.camera?.setCalibrationCallback(object : Camera.CalibrationCallback {
override fun OnStateUpdate(state: DJICameraCalibrateState?) {
ctx.mainHandler.post { patch("lens", mapOf("state" to state?.name)) }
}
override fun OnResultUpdate(r: DJICameraCalibrateResult?) {
ctx.mainHandler.post { patch("lens", mapOf("result" to r?.name)) }
}
})
}
watch.watch(key { CameraKey.create(CameraKey.SENSOR_CLEANING_STATE) }) { v -> put("sensorCleaning", KeyValues.toDart(v)) }
watch.watch(key { CameraKey.create(CameraKey.IS_SENSOR_CLEANING_SUPPORTED) }) { v -> patch("support", mapOf("sensorCleaning" to (v as? Boolean))) }
watch.watch(key { RemoteControllerKey.create(RemoteControllerKey.CALIBRATION_STATE) }) { v -> patch("rc", mapOf("state" to KeyValues.toDart(v))) }
readSupport()
// Counts and key support are often unknown until the handshake finishes.
ctx.mainHandler.postDelayed({ if (ctx.aircraft() === product) readSupport() }, 3000)
}
/** What the wizard hub offers; a missing fact is unknown, not unsupported. */
private fun readSupport() {
val a = ctx.aircraft() ?: return
val km = KeyManager.getInstance()
val fc = a.flightController
fun supported(k: DJIKey?): Boolean? = if (km == null || k == null) null else safe { km.isKeySupported(k) }
patch(
"support",
mapOf(
"compassCount" to safe { fc?.compassCount },
"imuCount" to safe { fc?.imuCount },
"gravityCenter" to supported(key { FlightControllerKey.create(FlightControllerKey.START_GRAVITY_CENTER_CALIBRATION) }),
"propeller" to safe { fc?.isPropellerCalibrationSupported },
"lens" to safe { a.camera?.isInterchangeableLensSupported },
"rc" to (if (a.remoteController == null) false else supported(key { RemoteControllerKey.create(RemoteControllerKey.CALIBRATION_STATE) })),
),
)
}
private fun onImu(s: IMUState) {
val units = mutableListOf(s)
s.subIMUState?.filterNotNull()?.let { units += it }
for (u in units) {
imuUnits[u.index] = mapOf(
"index" to u.index,
"state" to u.calibrationState?.name,
"progress" to u.calibrationProgress,
"gyroscope" to u.gyroscopeState?.name,
"accelerometer" to u.accelerometerState?.name,
"connected" to u.isConnected,
)
}
val hint = s.multipleOrientationCalibrationHint
put(
"imu",
mapOf(
"units" to imuUnits.values.toList(),
"multiSide" to s.isMultiSideCalibrationType,
"orientationState" to hint?.state?.name,
"toCalibrate" to hint?.orientationsToCalibrate?.map { it.name }?.sorted(),
"calibrated" to hint?.orientationsCalibrated?.map { it.name }?.sorted(),
),
)
}
/** The RC wizard's live sticks and dials, plus the per-axis calibration keys. */
private fun setRcHardware(on: Boolean) {
if (on == rcHardwareOn) return
rcHardwareOn = on
val remote = rc()
if (!on) {
safe { remote?.setHardwareStateCallback(null) }
rcWatch.clear()
group.remove("rcHardware")
publish()
return
}
safe {
remote?.setHardwareStateCallback { h ->
val v = mapOf(
"lx" to h.leftStick?.horizontalPosition,
"ly" to h.leftStick?.verticalPosition,
"rx" to h.rightStick?.horizontalPosition,
"ry" to h.rightStick?.verticalPosition,
"leftDial" to h.leftDial,
"rightDial" to h.rightDial?.value,
)
ctx.mainHandler.post { if (rcHardwareOn) put("rcHardware", v) }
}
}
rcWatch.watch(key { RemoteControllerKey.create(RemoteControllerKey.REMOTE_CONTROLLER_CALIBRATION_NUMBER_OF_SEGMENT) }) { v ->
patch("rc", mapOf("segments" to KeyValues.toDart(v)))
}
for (axis in "ABCDEFGH") {
val name = "REMOTE_CONTROLLER_CALIBRATION_${axis}_AXIS_STATUS"
val param = safe { RemoteControllerKey::class.java.getField(name).get(null) as? String } ?: continue
rcWatch.watch(key { RemoteControllerKey.create(param) }) { v ->
@Suppress("UNCHECKED_CAST")
val rcGroup = group["rc"] as? Map<String, Any?>
@Suppress("UNCHECKED_CAST")
val axes = (rcGroup?.get("axes") as? Map<String, Any?>).orEmpty()
patch("rc", mapOf("axes" to axes + (axis.toString() to KeyValues.toDart(v))))
}
}
}
// Main thread only.
private fun put(k: String, v: Any?) {
if (group[k] == v && group.containsKey(k)) return
group[k] = v
publish()
}
private fun patch(k: String, values: Map<String, Any?>) {
@Suppress("UNCHECKED_CAST")
val current = (group[k] as? Map<String, Any?>).orEmpty()
put(k, current + values)
}
private fun publish() = ctx.hub.putFast(mapOf("calibration" to LinkedHashMap(group)))
override fun reset() {
watch.clear()
rcWatch.clear()
rcHardwareOn = false
imuUnits.clear()
group.clear()
}
private inline fun key(make: () -> DJIKey?): DJIKey? = try {
make()
} catch (_: Throwable) {
null
}
private inline fun <T> safe(block: () -> T): T? = try {
block()
} catch (_: Throwable) {
null
}
}
@@ -23,7 +23,7 @@ import io.flutter.plugin.common.MethodChannel
* helpers ([FlightControllerBridge], [BatteryBridge], [AirLinkBridge],
* [FlightAssistantBridge], [DiagnosticsBridge], [CameraBridge], [GimbalBridge],
* [MissionBridge], [MediaBridge], [AccountBridge], [ProductProfileBridge],
* [KeyBridge]).
* [KeyBridge], [CalibrationBridge]).
* - [EVENT_CHANNEL] streams every subsystem's async updates to Dart. Each event
* is a map carrying a `type` key. Telemetry from every subsystem is batched by
* the shared [TelemetryHub] into one `telemetry` event per tick.
@@ -110,6 +110,7 @@ class DjiSdkBridge(
AccountBridge(ctx),
ProductProfileBridge(ctx),
KeyBridge(ctx),
CalibrationBridge(ctx),
)
init {
@@ -76,18 +76,6 @@ class FlightControllerBridge(private val ctx: BridgeCtx) : SubBridge {
it.setHomeLocation(LocationCoordinate2D(lat, lon), ctx.completion(result))
}
}
// Calibration starts (Aircraft Status sheet); the guided wizards are P6b.
"startCompassCalibration" -> guard(controller, result) {
val compass = it.compass
if (compass == null) ctx.fail(result, "No compass on this aircraft")
else compass.startCalibration(ctx.completion(result))
}
"stopCompassCalibration" -> guard(controller, result) {
val compass = it.compass
if (compass == null) ctx.fail(result, "No compass on this aircraft")
else compass.stopCalibration(ctx.completion(result))
}
"startIMUCalibration" -> guard(controller, result) { it.startIMUCalibration(ctx.completion(result)) }
"setMaxFlightHeight" -> guard(controller, result) {
it.setMaxFlightHeight((call.argument<Int>("value") ?: 120), ctx.completion(result))
}