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))
}
+27 -1
View File
@@ -71,7 +71,33 @@ class DjiService {
/// HORIZONTAL → VERTICAL → SUCCESSFUL / FAILED as the pilot rotates the aircraft.
Future<void> startCompassCalibration() => _methods.invokeMethod<void>('startCompassCalibration');
Future<void> stopCompassCalibration() => _methods.invokeMethod<void>('stopCompassCalibration');
Future<void> startIMUCalibration() => _methods.invokeMethod<void>('startIMUCalibration');
// ── Calibration & maintenance (plan P6b, `CalibrationBridge.kt`) ────────────
// Progress arrives in the `calibration` telemetry group (`CalibrationState`).
/// Every IMU, or only [index]. There is no stop: it runs to the end.
Future<void> startIMUCalibration({int? index}) =>
_methods.invokeMethod<void>('startIMUCalibration', <String, dynamic>{if (index != null) 'index': index});
Future<void> startGravityCenterCalibration() => _methods.invokeMethod<void>('startGravityCenterCalibration');
Future<void> stopGravityCenterCalibration() => _methods.invokeMethod<void>('stopGravityCenterCalibration');
Future<void> startPropellerCalibration() => _methods.invokeMethod<void>('startPropellerCalibration');
Future<void> stopPropellerCalibration() => _methods.invokeMethod<void>('stopPropellerCalibration');
/// Manual-focus lens calibration (interchangeable-lens cameras).
Future<void> startLensCalibration() => _methods.invokeMethod<void>('startLensCalibration');
Future<void> stopLensCalibration() => _methods.invokeMethod<void>('stopLensCalibration');
/// Sensor dedusting: enter the mode, clean (lens off), leave it.
Future<void> initSensorCleaning() => _methods.invokeMethod<void>('initSensorCleaning');
Future<void> startSensorCleaning() => _methods.invokeMethod<void>('startSensorCleaning');
Future<void> exitSensorCleaning() => _methods.invokeMethod<void>('exitSensorCleaning');
/// Remote-controller calibration step: `MIDDLE` → `LIMITS` → `QUIT`.
Future<void> setRcCalibrationStep(String step) =>
_methods.invokeMethod<void>('setRcCalibrationStep', <String, dynamic>{'step': step});
/// Streams the sticks and dials into `calibration.rcHardware` while on.
Future<void> watchRcHardware(bool on) => _methods.invokeMethod<void>('watchRcHardware', <String, dynamic>{'on': on});
/// Starts the flight controller's built-in simulator at [latitude]/[longitude]
/// (developer only; propellers off). `telemetry.simulatorActive` reflects it.
+6 -1
View File
@@ -4,6 +4,7 @@ import 'fly/product_profile.dart';
import 'fly/state/aircraft_state.dart';
import 'fly/state/alert_state.dart';
import 'fly/state/battery_state.dart';
import 'fly/state/calibration_state.dart';
import 'fly/state/camera_state.dart';
import 'fly/state/gimbal_state.dart';
import 'fly/state/key_state.dart';
@@ -128,8 +129,11 @@ class FlightModel extends ChangeNotifier {
/// KeyManager values the settings sheet listens to (plan P6).
final KeyState keys = KeyState();
/// The calibration wizards' progress (plan P6b).
final CalibrationState calibration = CalibrationState();
late final List<ChangeNotifier> _subs = <ChangeNotifier>[
aircraft, battery, links, camera, gimbal, vision, alerts, mission, keys, //
aircraft, battery, links, camera, gimbal, vision, alerts, mission, keys, calibration, //
];
/// Wipes the aircraft-bound sub-models on disconnect.
@@ -142,6 +146,7 @@ class FlightModel extends ChangeNotifier {
vision.clear();
alerts.clear();
keys.clear();
calibration.clear();
});
// ── SDK / registration / connection ────────────────────────────────────────
+360
View File
@@ -0,0 +1,360 @@
import '../flight_model.dart';
import 'hud_features.dart';
import 'product_profile.dart';
import 'state/calibration_state.dart';
/// The calibration and maintenance wizards (plan P6b).
enum CalibrationKind {
compass,
imu,
rc,
gimbal,
/// Roll / pitch / yaw trim against a horizon line over the live video.
gimbalAdjust,
lens,
sensorCleaning,
gravityCenter,
propeller,
/// No SDK call: GO 4 also only says "use DJI Assistant 2".
visionSensors,
}
/// Titles, gating and availability per wizard.
abstract final class CalibrationKinds {
static String title(CalibrationKind k) => switch (k) {
CalibrationKind.compass => 'Compass calibration',
CalibrationKind.imu => 'IMU calibration',
CalibrationKind.rc => 'Remote controller calibration',
CalibrationKind.gimbal => 'Gimbal auto calibration',
CalibrationKind.gimbalAdjust => 'Adjust gimbal',
CalibrationKind.lens => 'Lens calibration',
CalibrationKind.sensorCleaning => 'Sensor cleaning',
CalibrationKind.gravityCenter => 'Center of gravity calibration',
CalibrationKind.propeller => 'Propeller calibration',
CalibrationKind.visionSensors => 'Vision sensor calibration',
};
static String icon(CalibrationKind k) => switch (k) {
CalibrationKind.compass => 'compass',
CalibrationKind.imu => 'gauge',
CalibrationKind.rc => 'rc',
CalibrationKind.gimbal || CalibrationKind.gimbalAdjust => 'gimbal',
CalibrationKind.lens => 'focus',
CalibrationKind.sensorCleaning => 'aperture',
CalibrationKind.gravityCenter || CalibrationKind.propeller => 'drone',
CalibrationKind.visionSensors => 'obstacle',
};
static HudFeature feature(CalibrationKind k) => switch (k) {
CalibrationKind.compass => HudFeatures.calibrationCompass,
CalibrationKind.imu => HudFeatures.calibrationImu,
CalibrationKind.rc => HudFeatures.calibrationRc,
CalibrationKind.gimbal => HudFeatures.gimbalCalibration,
CalibrationKind.gimbalAdjust => HudFeatures.gimbalAdjust,
CalibrationKind.lens => HudFeatures.calibrationLens,
CalibrationKind.sensorCleaning => HudFeatures.sensorCleaning,
CalibrationKind.gravityCenter => HudFeatures.calibrationGravityCenter,
CalibrationKind.propeller => HudFeatures.calibrationPropeller,
CalibrationKind.visionSensors => HudFeatures.visionSensorGuide,
};
/// The wizards the hub lists: the profile's feature, then whatever the
/// aircraft has since said it does not support. Unknown counts as offered.
static List<CalibrationKind> available(ProductProfile p, CalibrationState c) => <CalibrationKind>[
for (final CalibrationKind k in CalibrationKind.values)
if ((!p.connected || feature(k).enabledFor(p)) && _runtime(k, c) != false) k,
];
static bool? _runtime(CalibrationKind k, CalibrationState c) => switch (k) {
CalibrationKind.compass => c.compassCount == null ? null : c.compassCount! > 0,
CalibrationKind.gravityCenter => c.gravityCenterSupported,
CalibrationKind.propeller => c.propellerSupported,
CalibrationKind.lens => c.lensSupported,
CalibrationKind.sensorCleaning => c.sensorCleaningSupported,
CalibrationKind.rc => c.rcSupported,
_ => null,
};
}
enum RunPhase { idle, starting, running, succeeded, failed }
/// One wizard's progress, folded from the live model. A wizard calls [begin]
/// when its start command is sent and [observe] on every model change; the
/// result only counts once the aircraft was seen working since [begin], so a
/// stale SUCCESSFUL from an earlier run never ends a new one.
abstract class CalibrationRun {
RunPhase phase = RunPhase.idle;
String? failure;
bool _seenActive = false;
bool get active => phase == RunPhase.starting || phase == RunPhase.running;
bool get finished => phase == RunPhase.succeeded || phase == RunPhase.failed;
void begin() {
phase = RunPhase.starting;
failure = null;
_seenActive = false;
}
/// The start command itself was refused.
void startFailed(String reason) {
phase = RunPhase.failed;
failure = reason;
}
/// For calibrations the SDK reports nothing about: the start was accepted,
/// so it is running until the pilot says it ended ([complete]).
void accepted() {
if (phase != RunPhase.starting) return;
phase = RunPhase.running;
_seenActive = true;
}
void complete() {
if (active) phase = RunPhase.succeeded;
}
void reset() {
phase = RunPhase.idle;
failure = null;
_seenActive = false;
}
void observe(FlightModel m);
/// Common folding: [working] while the aircraft reports progress, then
/// [ok] / [failed] once it has worked.
void fold({required bool working, bool ok = false, bool failed = false, String? reason}) {
if (!active) return;
if (working) {
_seenActive = true;
phase = RunPhase.running;
return;
}
if (!_seenActive) return;
if (failed) {
phase = RunPhase.failed;
failure = reason;
} else if (ok) {
phase = RunPhase.succeeded;
}
}
}
// ── Compass ─────────────────────────────────────────────────────────────────
enum CompassStep { horizontal, vertical }
class CompassRun extends CalibrationRun {
CompassStep? step;
@override
void observe(FlightModel m) {
final String? s = m.aircraft.compassCalibrationState;
step = switch (s) {
'HORIZONTAL' => CompassStep.horizontal,
'VERTICAL' => CompassStep.vertical,
_ => step,
};
final bool working = s == 'HORIZONTAL' || s == 'VERTICAL' || (m.aircraft.compassCalibrating == true && s != 'SUCCESSFUL' && s != 'FAILED');
// After SUCCESSFUL the state drops back to NOT_CALIBRATING; an end without
// a verdict counts by the compass's own error flag.
final bool ended = !working && (s == 'SUCCESSFUL' || s == 'FAILED' || s == 'NOT_CALIBRATING');
final bool failed = s == 'FAILED' || (s == 'NOT_CALIBRATING' && m.aircraft.compassHasError == true);
fold(
working: working,
ok: ended && !failed,
failed: ended && failed,
reason: 'Move away from metal, cars and buildings, then try again.',
);
}
}
// ── IMU ─────────────────────────────────────────────────────────────────────
/// GO 4's order for the multi-side sequence.
abstract final class ImuOrientations {
static const List<String> order = <String>['BOTTOM_DOWN', 'TOP_DOWN', 'NOSE_DOWN', 'TAIL_DOWN', 'LEFT_DOWN', 'RIGHT_DOWN'];
static String label(String o) => switch (o) {
'BOTTOM_DOWN' => 'Flat, upright',
'TOP_DOWN' => 'Upside down',
'NOSE_DOWN' => 'Nose down',
'TAIL_DOWN' => 'Tail down',
'LEFT_DOWN' => 'Left side down',
'RIGHT_DOWN' => 'Right side down',
_ => o,
};
}
class ImuRun extends CalibrationRun {
/// 0..1 across the units that report progress.
double? progress;
/// The side to put down now, in a multi-side calibration.
String? orientation;
/// The multi-side sequence: every side and whether it is done.
List<(String, bool)> sides = const <(String, bool)>[];
@override
void observe(FlightModel m) {
final CalibrationState c = m.calibration;
final List<ImuUnit> units = <ImuUnit>[for (final ImuUnit u in c.imuUnits) if (u.connected != false) u];
final List<int> p = <int>[for (final ImuUnit u in units) if (u.progress != null) u.progress!];
progress = p.isEmpty ? null : p.reduce((int a, int b) => a + b) / p.length / 100;
final Set<String> done = c.imuCalibrated.toSet();
final Set<String> wanted = <String>{...c.imuToCalibrate, ...done};
sides = <(String, bool)>[
for (final String o in ImuOrientations.order)
if (wanted.contains(o)) (o, done.contains(o)),
];
orientation = c.imuMultiSide == true
? ImuOrientations.order.where((String o) => wanted.contains(o) && !done.contains(o)).firstOrNull
: null;
final bool working = units.any((ImuUnit u) => u.state == 'CALIBRATING') || c.imuOrientationState == 'CALIBRATING';
final bool failed = units.any((ImuUnit u) => u.state == 'FAILED');
final bool ok = units.isNotEmpty && units.every((ImuUnit u) => u.state == 'SUCCESSFUL');
fold(working: working && !failed, ok: ok, failed: failed, reason: 'Keep the aircraft still on a level surface and try again.');
}
}
// ── Remote controller ───────────────────────────────────────────────────────
enum RcStep { middle, limits }
/// The remote's calibration as GO 4 / the UX SDK run it: record the centre
/// (MIDDLE), sweep every stick and dial (LIMITS), then QUIT to save.
class RcRun extends CalibrationRun {
RcStep? step;
bool _quitting = false;
/// The pilot asked to finish; the next NORMAL / QUIT ends the run.
void finishing() => _quitting = true;
@override
void begin() {
super.begin();
step = null;
_quitting = false;
}
@override
void observe(FlightModel m) {
final String? s = m.calibration.rcState;
if (s == 'MIDDLE') step = RcStep.middle;
if (s == 'LIMITS') step = RcStep.limits;
fold(
working: (s == 'MIDDLE' || s == 'LIMITS') && !_quitting,
ok: _quitting,
failed: s == 'TIMEOUT' || s == 'DISCONNECT',
reason: s == 'TIMEOUT' ? 'The remote timed out. Start again.' : 'The remote disconnected.',
);
}
}
// ── Gimbal auto calibration ─────────────────────────────────────────────────
class GimbalRun extends CalibrationRun {
int? progress;
@override
void observe(FlightModel m) {
progress = m.gimbal.calibrationProgress;
fold(
working: m.gimbal.calibrating,
ok: m.gimbal.lastCalibrationOk == true,
failed: m.gimbal.lastCalibrationOk == false,
reason: 'Make sure nothing blocks the gimbal and the aircraft is level, then try again.',
);
}
}
// ── Lens ────────────────────────────────────────────────────────────────────
class LensRun extends CalibrationRun {
@override
void observe(FlightModel m) {
final String? r = m.calibration.lensResult;
fold(
working: m.calibration.lensState == 'CALIBRATING' && r != 'COMPLETED' && r != 'FAIL_NORMAL' && r != 'DATA_ERROR',
ok: r == 'COMPLETED',
failed: r == 'FAIL_NORMAL' || r == 'DATA_ERROR',
reason: r == 'DATA_ERROR' ? 'The lens returned bad data. Check it is seated correctly.' : 'Calibration failed. Try again.',
);
}
}
// ── Center of gravity ───────────────────────────────────────────────────────
class GravityCenterRun extends CalibrationRun {
@override
void observe(FlightModel m) {
final String? s = m.calibration.gravityCenterState;
fold(
working: s == 'CALCULATING',
ok: s == 'SUCCESSFUL',
failed: s == 'FAILED',
reason: m.calibration.gravityCenterError ?? 'Hover steadily in calm air and try again.',
);
}
}
// ── Sensor cleaning ─────────────────────────────────────────────────────────
/// What the pilot does at each sensor-cleaning state.
enum CleaningStep { off, entering, removeLens, ready, cleaning, finishing, remountLens }
abstract final class SensorCleaning {
static CleaningStep step(String? s) => switch (s) {
'INITIATING' => CleaningStep.entering,
'WAITING_FOR_LENS_REMOVAL' => CleaningStep.removeLens,
'READY' => CleaningStep.ready,
'CLEANING' => CleaningStep.cleaning,
'FINISHING' => CleaningStep.finishing,
'WAITING_FOR_LENS_REMOUNT' => CleaningStep.remountLens,
_ => CleaningStep.off,
};
static String instruction(CleaningStep s) => switch (s) {
CleaningStep.off => 'The camera vibrates its sensor to shake dust off. You will remove the lens and refit it afterwards.',
CleaningStep.entering => 'Preparing the camera…',
CleaningStep.removeLens => 'Remove the lens and hold the camera with the sensor facing down.',
CleaningStep.ready => 'Keep the sensor facing down and start cleaning.',
CleaningStep.cleaning => 'Cleaning… keep the sensor facing down.',
CleaningStep.finishing => 'Finishing…',
CleaningStep.remountLens => 'Refit the lens, then finish.',
};
}
/// Sensor cleaning runs while the camera is in its cleaning mode, and ends
/// well once the pilot has left the mode ([exiting]) and the camera is idle.
class SensorCleaningRun extends CalibrationRun {
CleaningStep step = CleaningStep.off;
bool _exiting = false;
void exiting() => _exiting = true;
@override
void begin() {
super.begin();
_exiting = false;
}
@override
void observe(FlightModel m) {
step = SensorCleaning.step(m.calibration.sensorCleaning);
fold(working: step != CleaningStep.off && !_exiting, ok: _exiting && step == CleaningStep.off);
}
}
/// Propeller calibration: the SDK has a start and a stop but reports no
/// progress, so the run lasts until the pilot ends it.
class PropellerRun extends CalibrationRun {
@override
void observe(FlightModel m) {}
}
+30
View File
@@ -112,6 +112,27 @@ abstract final class HudFeatures {
static final HudFeature linkWifi = HudFeature(
'settings.wifi', 'Wi-Fi transmission settings', 'Settings', (ProductProfile p) => p.link == LinkType.wifi);
// ── Calibration & maintenance wizards (plan P6b) ──────────────────────────
// Runtime support (`CalibrationState`) can still drop a wizard the profile allows.
static final HudFeature calibrationCompass = HudFeature(
'calibration.compass', 'Compass calibration', 'Calibration', (ProductProfile p) => p.isAircraft);
static final HudFeature calibrationImu = HudFeature(
'calibration.imu', 'IMU calibration', 'Calibration', (ProductProfile p) => p.isAircraft);
static final HudFeature calibrationRc = HudFeature(
'calibration.rc', 'Remote controller calibration', 'Calibration', (ProductProfile p) => p.remoteController != null);
static final HudFeature gimbalAdjust = HudFeature(
'calibration.gimbalAdjust', 'Adjust gimbal (horizon)', 'Calibration', (ProductProfile p) => p.hasGimbal);
static final HudFeature calibrationLens = HudFeature('calibration.lens', 'Lens calibration', 'Calibration',
(ProductProfile p) => p.camera?.interchangeableLensSupported == true);
static final HudFeature sensorCleaning = HudFeature('calibration.sensorCleaning', 'Sensor cleaning', 'Calibration',
(ProductProfile p) => p.camera?.interchangeableLensSupported == true);
static final HudFeature calibrationGravityCenter = HudFeature(
'calibration.gravityCenter', 'Center of gravity calibration', 'Calibration', (ProductProfile p) => p.isAircraft);
static final HudFeature calibrationPropeller = HudFeature('calibration.propeller', 'Propeller calibration', 'Calibration',
(ProductProfile p) => p.flightController?.propellerCalibrationSupported == true);
static final HudFeature visionSensorGuide = HudFeature(
'calibration.vision', 'Vision sensor calibration (guide)', 'Calibration', (ProductProfile p) => p.hasVps);
// ── Intelligent flight modes ──────────────────────────────────────────────
static final List<HudFeature> modes = <HudFeature>[
for (final IntelligentMode m in IntelligentMode.values)
@@ -155,6 +176,15 @@ abstract final class HudFeatures {
linkOcuSync,
linkLightbridge,
linkWifi,
calibrationCompass,
calibrationImu,
calibrationRc,
gimbalAdjust,
calibrationLens,
sensorCleaning,
calibrationGravityCenter,
calibrationPropeller,
visionSensorGuide,
...modes,
];
+66
View File
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import '../dji_service.dart';
import '../flight_model.dart';
import 'action_rail_state.dart';
import 'calibration.dart';
import 'flight_status.dart';
import 'hud_geometry.dart';
import 'state/aircraft_state.dart';
@@ -65,6 +66,16 @@ class SafetyGateRefused implements Exception {
String toString() => verdict.blockers.map((GateCheck c) => c.message).join(' ');
}
/// Thrown by [SafetyGate.runMaintenance] when a calibration may not start.
class MaintenanceRefused implements Exception {
const MaintenanceRefused(this.checks);
final List<GateCheck> checks;
@override
String toString() => checks.where((GateCheck c) => c.blocks).map((GateCheck c) => c.message).join(' ');
}
/// The one place every aircraft-moving command passes through (plan §1.5, P3).
///
/// [check] grades the preconditions — connected, flight state, GPS, battery,
@@ -196,6 +207,61 @@ class SafetyGate {
}
}
// ── Calibration & maintenance (plan P6b) ────────────────────────────────────
/// Below this charge an IMU calibration (5–10 minutes) is discouraged.
static const int imuMinBattery = 30;
/// Preconditions for starting [kind]. Everything except the center-of-gravity
/// calibration (done in a hover) and the gimbal trim needs the aircraft on
/// the ground with the motors off.
List<GateCheck> checkMaintenance(CalibrationKind kind) {
final List<GateCheck> checks = <GateCheck>[];
if (kind == CalibrationKind.visionSensors) return checks;
if (!model.connected) return checks..add(const GateCheck.block('No aircraft connected.'));
if (_a.updatedAt == null) return checks..add(const GateCheck.block('Waiting for the flight controller to report.'));
switch (kind) {
case CalibrationKind.gimbalAdjust:
case CalibrationKind.visionSensors:
break;
case CalibrationKind.gravityCenter:
if (_a.isFlying != true) checks.add(const GateCheck.block('Take off and hover first: the aircraft measures its balance in the air.'));
if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel) checks.add(const GateCheck.block('GPS too weak to hold a steady hover.'));
if (_a.windWarning != null && _a.windWarning != 'LEVEL_0') checks.add(const GateCheck.warn('Strong wind: the result may be off.'));
default:
if (_a.isFlying == true) {
checks.add(const GateCheck.block('Land first.'));
} else if (_a.motorsOn == true) {
checks.add(const GateCheck.block('Stop the motors first.'));
}
}
switch (kind) {
case CalibrationKind.imu:
final int? percent = model.battery.percent;
if (percent != null && percent < imuMinBattery) checks.add(GateCheck.warn('Battery at $percent%: calibration takes up to 10 minutes.'));
if (_a.imuPreheating == true) checks.add(const GateCheck.warn('The IMU is warming up; calibration waits for it.'));
case CalibrationKind.compass:
if (_a.compassCalibrating == true) checks.add(const GateCheck.warn('A compass calibration is already running; starting restarts it.'));
case CalibrationKind.gimbal:
if (model.gimbal.calibrating) checks.add(const GateCheck.block('The gimbal is already calibrating.'));
case CalibrationKind.propeller:
checks.add(const GateCheck.warn('The motors spin during calibration. Keep clear of the propellers.'));
case CalibrationKind.sensorCleaning:
case CalibrationKind.lens:
if (model.camera.isRecording) checks.add(const GateCheck.block('Stop recording first.'));
default:
break;
}
return checks;
}
/// Re-checks [kind] and runs [start] — the wizard's start command.
Future<void> runMaintenance(CalibrationKind kind, Future<void> Function() start) async {
final List<GateCheck> checks = checkMaintenance(kind);
if (checks.any((GateCheck c) => c.blocks)) throw MaintenanceRefused(checks);
await start();
}
/// True when the pilot stands far enough from the recorded home point that
/// RTH should ask whether to come back to them instead (GO 4's RTH target).
bool get offersRthTarget => (pilotToHome ?? 0) > rthTargetChoiceDistance;
@@ -0,0 +1,138 @@
import 'package:flutter/foundation.dart';
import 'parse.dart';
/// One IMU unit's calibration report.
@immutable
class ImuUnit {
const ImuUnit({required this.index, this.state, this.progress, this.gyroscope, this.accelerometer, this.connected});
factory ImuUnit.fromMap(Map<String, dynamic> m) => ImuUnit(
index: asInt(m['index']) ?? 0,
state: asString(m['state']),
progress: asInt(m['progress']),
gyroscope: asString(m['gyroscope']),
accelerometer: asString(m['accelerometer']),
connected: asBool(m['connected']),
);
final int index;
/// `NONE` / `CALIBRATING` / `SUCCESSFUL` / `FAILED` / `UNKNOWN`.
final String? state;
final int? progress; // 0..100
/// `SensorState` names: `NORMAL_BIAS`, `WARMING_UP`, `IN_MOTION`, `LARGE_BIAS` …
final String? gyroscope;
final String? accelerometer;
final bool? connected;
}
/// The RC wizard's live sticks (−660…660) and dials.
@immutable
class RcHardware {
const RcHardware({this.lx, this.ly, this.rx, this.ry, this.leftDial, this.rightDial});
factory RcHardware.fromMap(Map<String, dynamic> m) => RcHardware(
lx: asInt(m['lx']),
ly: asInt(m['ly']),
rx: asInt(m['rx']),
ry: asInt(m['ry']),
leftDial: asInt(m['leftDial']),
rightDial: asInt(m['rightDial']),
);
final int? lx, ly, rx, ry, leftDial, rightDial;
}
/// The calibration and maintenance wizards' state (plan P6b), from the
/// `calibration` group of the telemetry tick (`CalibrationBridge.kt`). The
/// compass reports through [AircraftState] and the gimbal through
/// [GimbalState]; everything else is here.
class CalibrationState extends ChangeNotifier {
// ── What the aircraft offers; null = not known yet ─────────────────────────
int? compassCount;
int? imuCount;
bool? gravityCenterSupported;
bool? propellerSupported;
bool? lensSupported;
bool? sensorCleaningSupported;
bool? rcSupported;
// ── IMU ────────────────────────────────────────────────────────────────────
List<ImuUnit> imuUnits = const <ImuUnit>[];
/// The aircraft wants to be turned onto several sides.
bool? imuMultiSide;
/// `CALIBRATING` / `COMPLETED` / `UNKNOWN` for the multi-side sequence.
String? imuOrientationState;
/// `CalibrationOrientation` names: `BOTTOM_DOWN`, `NOSE_DOWN` …
List<String> imuToCalibrate = const <String>[];
List<String> imuCalibrated = const <String>[];
// ── Gravity centre / lens / sensor cleaning ───────────────────────────────
/// `NONE` / `CALCULATING` / `SUCCESSFUL` / `FAILED`.
String? gravityCenterState;
String? gravityCenterError;
/// `NOT_CALIBRATE` / `CALIBRATING` / `CALIBRATED` / `WAITING_FOR_CALIBRATE`.
String? lensState;
/// `IDLE` / `COMPLETED` / `FAIL_NORMAL` / `DATA_ERROR`.
String? lensResult;
/// `SensorCleaningState` name: `IDLE`, `INITIATING`, `WAITING_FOR_LENS_REMOVAL` …
String? sensorCleaning;
// ── Remote controller ─────────────────────────────────────────────────────
/// `NORMAL` / `MIDDLE` / `LIMITS` / `QUIT` / `TIMEOUT` / `DISCONNECT`.
String? rcState;
int? rcSegments;
/// Per-axis calibration status (`A`…`H`) as the remote reports it.
Map<String, Object?> rcAxes = const <String, Object?>{};
/// Only while the RC wizard watches the hardware.
RcHardware? rcHardware;
void apply(Map<String, dynamic>? g) {
final Map<String, dynamic> support = asMap(g?['support']) ?? const <String, dynamic>{};
compassCount = asInt(support['compassCount']);
imuCount = asInt(support['imuCount']);
gravityCenterSupported = asBool(support['gravityCenter']);
propellerSupported = asBool(support['propeller']);
lensSupported = asBool(support['lens']);
sensorCleaningSupported = asBool(support['sensorCleaning']);
rcSupported = asBool(support['rc']);
final Map<String, dynamic>? imu = asMap(g?['imu']);
imuUnits = <ImuUnit>[for (final Map<String, dynamic> u in asMapList(imu?['units'])) ImuUnit.fromMap(u)];
imuMultiSide = asBool(imu?['multiSide']);
imuOrientationState = asString(imu?['orientationState']);
imuToCalibrate = _strings(imu?['toCalibrate']);
imuCalibrated = _strings(imu?['calibrated']);
final Map<String, dynamic>? gc = asMap(g?['gravityCenter']);
gravityCenterState = asString(gc?['state']);
gravityCenterError = asString(gc?['error']);
final Map<String, dynamic>? lens = asMap(g?['lens']);
lensState = asString(lens?['state']);
lensResult = asString(lens?['result']);
sensorCleaning = asString(g?['sensorCleaning']);
final Map<String, dynamic>? rc = asMap(g?['rc']);
rcState = asString(rc?['state']);
rcSegments = asInt(rc?['segments']);
rcAxes = asMap(rc?['axes']) ?? const <String, Object?>{};
final Map<String, dynamic>? hw = asMap(g?['rcHardware']);
rcHardware = hw == null ? null : RcHardware.fromMap(hw);
notifyListeners();
}
void clear() => apply(null);
static List<String> _strings(Object? v) => v is List ? <String>[for (final Object? e in v) if (e is String) e] : const <String>[];
}
+3 -2
View File
@@ -8,7 +8,7 @@ import 'state/parse.dart';
/// A `telemetry` event is the native [TelemetryHub]'s full snapshot: the flight
/// controller's flat keys plus one nested group per subsystem (`battery`,
/// `links`, `rc`, `storage`, `ssd`, `thermal`, `cameraSettings`, `gimbal`,
/// `gimbalSettings`, `vision`). It arrives at
/// `gimbalSettings`, `vision`, `calibration`). It arrives at
/// up to 10 Hz with the slow groups repeated unchanged between their 2 Hz
/// refreshes, so each group is compared with the last one seen and its
/// sub-model only notified when it actually moved. A group missing from the
@@ -20,7 +20,7 @@ class TelemetryReducer {
final Map<String, Object?> _lastGroups = <String, Object?>{};
static const List<String> _groups = <String>['battery', 'links', 'rc', 'storage', 'ssd', 'thermal', 'cameraSettings', 'gimbal', 'gimbalSettings', 'vision'];
static const List<String> _groups = <String>['battery', 'links', 'rc', 'storage', 'ssd', 'thermal', 'cameraSettings', 'gimbal', 'gimbalSettings', 'vision', 'calibration'];
/// Applies [event] if it is one of the telemetry types; returns whether it was.
bool apply(Map<String, dynamic> event) {
@@ -89,5 +89,6 @@ class TelemetryReducer {
}
if (changed.contains('gimbalSettings')) model.gimbal.applySettings(asMap(t['gimbalSettings']));
if (changed.contains('vision')) model.vision.apply(asMap(t['vision']));
if (changed.contains('calibration')) model.calibration.apply(asMap(t['calibration']));
}
}
@@ -4,11 +4,13 @@ import 'package:flutter/services.dart';
import '../../../dji_service.dart';
import '../../../flight_model.dart';
import '../../../fly/aircraft_checklist.dart';
import '../../../fly/calibration.dart';
import '../../../fly/state/parse.dart';
import '../../../theme.dart';
import '../../pv_icons.dart';
import '../dialogs/slide_to_confirm.dart';
import '../hud_kit.dart';
import 'settings/calibration/calibration_hub.dart';
/// Opens the Aircraft Status sheet over the right of the flight screen.
///
@@ -94,50 +96,38 @@ class _AircraftStatusSheetState extends State<AircraftStatusSheet> {
}
Future<void> _act(ChecklistAction action) async {
final ({SlideConfirmSpec? spec, String label, Future<void> Function() run}) a = switch (action) {
ChecklistAction.calibrateCompass => (
label: 'Compass calibration',
run: widget.dji.startCompassCalibration,
spec: const SlideConfirmSpec(
icon: 'compass',
title: 'Calibrate compass',
description: 'Stand away from metal, cars and power lines. Hold the aircraft level and turn it a full circle, '
'then hold it nose-down and turn again. The Compass row shows each step.',
sliderLabel: 'Slide to start',
),
),
ChecklistAction.stopCompassCalibration => (label: 'Stop calibration', run: widget.dji.stopCompassCalibration, spec: null),
ChecklistAction.calibrateImu => (
label: 'IMU calibration',
run: widget.dji.startIMUCalibration,
spec: const SlideConfirmSpec(
icon: 'gauge',
title: 'Calibrate IMU',
description: 'Put the aircraft on a flat, level surface with the motors off and do not touch it. '
'Calibration takes 5–10 minutes; some aircraft ask to be turned onto each side.',
sliderLabel: 'Slide to start',
),
),
ChecklistAction.formatSd => (
label: 'Format SD',
run: widget.dji.formatStorage,
spec: const SlideConfirmSpec(
switch (action) {
// Calibrations are guided wizards (plan P6b).
case ChecklistAction.calibrateCompass:
return _wizard(CalibrationKind.compass);
case ChecklistAction.calibrateImu:
return _wizard(CalibrationKind.imu);
case ChecklistAction.stopCompassCalibration:
return _send('Stop calibration', widget.dji.stopCompassCalibration);
case ChecklistAction.formatSd:
final SlideResult? ok = await showSlideToConfirm(
context,
const SlideConfirmSpec(
icon: 'sdcard',
title: 'Format SD card',
description: 'Every photo and video on the card is erased. This cannot be undone.',
sliderLabel: 'Slide to erase',
color: HudColors.danger,
),
),
};
if (a.spec != null) {
final SlideResult? ok = await showSlideToConfirm(context, a.spec!);
if (ok == null) return;
);
if (ok == null) return;
return _send('Format SD', widget.dji.formatStorage);
}
}
Future<void> _wizard(CalibrationKind kind) =>
openCalibration(context, kind, model: widget.model, dji: widget.dji, onError: widget.onError);
Future<void> _send(String label, Future<void> Function() run) async {
try {
await a.run();
await run();
} catch (e) {
widget.onError(a.label, e);
widget.onError(label, e);
}
}
+15 -15
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../../dji_service.dart';
import '../../../flight_model.dart';
import '../../../fly/calibration.dart';
import '../../../fly/gimbal_control.dart';
import '../../../fly/hud_features.dart';
import '../../../fly/product_profile.dart';
@@ -13,6 +14,7 @@ import '../dialogs/slide_to_confirm.dart';
import '../hud_kit.dart';
import 'camera_menu/camera_rows.dart';
import 'camera_write.dart';
import 'settings/calibration/calibration_hub.dart';
/// Opens the gimbal settings (plan P5) as a right-side glass sheet over the
/// live view, like the camera menu.
@@ -128,20 +130,8 @@ class GimbalSettingsList extends StatelessWidget {
}
}
Future<void> _calibrate(BuildContext context) async {
final SlideResult? ok = await showSlideToConfirm(
context,
const SlideConfirmSpec(
icon: 'gimbal',
title: 'Auto calibrate gimbal',
description: 'Put the aircraft on a flat, level surface with the motors off and the gimbal free to move. '
'Do not touch it until calibration finishes (about 30 seconds).',
sliderLabel: 'Slide to start',
),
);
if (ok == null) return;
await _run('Gimbal calibration', dji.startGimbalCalibration);
}
Future<void> _open(BuildContext context, CalibrationKind kind) =>
openCalibration(context, kind, model: model, dji: dji, onError: onError);
Future<void> _resetAdvanced(BuildContext context) async {
final SlideResult? ok = await showSlideToConfirm(
@@ -241,6 +231,16 @@ class GimbalSettingsList extends StatelessWidget {
canUp: true,
onStep: (int dir) => _run('Roll adjust', () => dji.fineTuneGimbal('roll', dir * GimbalActions.fineTuneStep)),
),
Requires(
HudFeatures.gimbalAdjust,
child: ButtonTile(
id: 'adjustGimbal',
label: 'Adjust gimbal',
hint: 'Roll, pitch and yaw against a horizon line on the video',
button: 'Adjust',
onTap: () => _open(context, CalibrationKind.gimbalAdjust),
),
),
]),
),
// ── Calibration ───────────────────────────────────────────────────
@@ -313,7 +313,7 @@ class GimbalSettingsList extends StatelessWidget {
label: 'Auto calibrate gimbal',
button: 'Calibrate',
hint: hint,
onTap: blocked == null ? () => _calibrate(context) : null,
onTap: blocked == null ? () => _open(context, CalibrationKind.gimbal) : null,
);
}
@@ -0,0 +1,294 @@
import 'package:flutter/material.dart';
import '../../../../../dji_service.dart';
import '../../../../../flight_model.dart';
import '../../../../../fly/calibration.dart';
import '../../../../../fly/gimbal_control.dart';
import '../../../../../fly/hud_features.dart';
import '../../../../../fly/safety_gate.dart';
import '../../../../../theme.dart';
import '../../../../pv_icons.dart';
import '../../../hud_kit.dart';
import '../../camera_menu/camera_rows.dart';
import 'illustrations.dart';
import 'wizards.dart';
/// Opens the [kind] wizard (plan P6b). Every wizard is a full-screen page that
/// leaves the live view, except the gimbal adjust step, which needs the video:
/// it closes any sheet or hub above the flight screen and lays a horizon line
/// over it instead.
Future<void> openCalibration(
BuildContext context,
CalibrationKind kind, {
required FlightModel model,
required DjiService dji,
required void Function(String label, Object error) onError,
}) async {
final NavigatorState nav = Navigator.of(context);
if (kind == CalibrationKind.gimbalAdjust) {
nav.popUntil((Route<dynamic> r) => r is! PopupRoute && r.settings.name != CalibrationHubPage.routeName);
await nav.push(PageRouteBuilder<void>(
opaque: false,
barrierColor: Colors.transparent,
settings: const RouteSettings(name: GimbalAdjustOverlay.routeName),
pageBuilder: (BuildContext context, _, __) => GimbalAdjustOverlay(model: model, dji: dji, onError: onError),
transitionsBuilder: (BuildContext context, Animation<double> a, _, Widget child) => FadeTransition(opacity: a, child: child),
));
return;
}
void adjust() {
openCalibration(nav.context, CalibrationKind.gimbalAdjust, model: model, dji: dji, onError: onError);
}
final Widget page = switch (kind) {
CalibrationKind.compass => CompassWizard(model: model, dji: dji, onError: onError),
CalibrationKind.imu => ImuWizard(model: model, dji: dji, onError: onError),
CalibrationKind.rc => RcWizard(model: model, dji: dji, onError: onError),
CalibrationKind.gimbal => GimbalCalibrationWizard(model: model, dji: dji, onError: onError, onAdjust: adjust),
CalibrationKind.lens => LensWizard(model: model, dji: dji, onError: onError),
CalibrationKind.sensorCleaning => SensorCleaningWizard(model: model, dji: dji, onError: onError),
CalibrationKind.gravityCenter => GravityCenterWizard(model: model, dji: dji, onError: onError),
CalibrationKind.propeller => PropellerWizard(model: model, dji: dji, onError: onError),
CalibrationKind.visionSensors => const VisionSensorGuide(),
CalibrationKind.gimbalAdjust => throw StateError('handled above'),
};
await nav.push(MaterialPageRoute<void>(builder: (_) => page));
}
/// Opens the list of every wizard this aircraft offers.
Future<void> showCalibrationHub(
BuildContext context, {
required FlightModel model,
required DjiService dji,
required void Function(String label, Object error) onError,
}) =>
Navigator.of(context).push(MaterialPageRoute<void>(
settings: const RouteSettings(name: CalibrationHubPage.routeName),
builder: (_) => CalibrationHubPage(model: model, dji: dji, onError: onError),
));
/// Settings → Safety → "Calibration & maintenance": every wizard the
/// connected aircraft offers, grouped like GO 4, with a one-line status each.
class CalibrationHubPage extends StatelessWidget {
const CalibrationHubPage({super.key, required this.model, required this.dji, required this.onError});
static const String routeName = 'calibrationHub';
final FlightModel model;
final DjiService dji;
final void Function(String label, Object error) onError;
static const List<(String, List<CalibrationKind>)> _groups = <(String, List<CalibrationKind>)>[
('AIRCRAFT', <CalibrationKind>[CalibrationKind.compass, CalibrationKind.imu, CalibrationKind.gravityCenter, CalibrationKind.propeller, CalibrationKind.visionSensors]),
('REMOTE CONTROLLER', <CalibrationKind>[CalibrationKind.rc]),
('GIMBAL & CAMERA', <CalibrationKind>[CalibrationKind.gimbal, CalibrationKind.gimbalAdjust, CalibrationKind.lens, CalibrationKind.sensorCleaning]),
];
String? _status(CalibrationKind k) => switch (k) {
CalibrationKind.compass => model.aircraft.compassHasError == true
? 'Error — calibrate'
: (model.aircraft.compassCalibrating == true ? 'Calibrating…' : null),
CalibrationKind.imu => model.aircraft.imuPreheating == true ? 'Warming up' : null,
CalibrationKind.gimbal => model.gimbal.calibrating ? 'Calibrating… ${model.gimbal.calibrationProgress ?? 0}%' : null,
CalibrationKind.gravityCenter => 'In a hover, after changing the payload',
CalibrationKind.visionSensors => 'Needs DJI Assistant 2 on a computer',
_ => null,
};
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF070D1A),
body: SafeArea(
child: ProductProfileScope(
profile: model.profile,
child: ListenableBuilder(
listenable: Listenable.merge(<Listenable>[model.calibration, model.aircraft, model.gimbal]),
builder: (BuildContext context, _) {
final Set<CalibrationKind> offered = CalibrationKinds.available(model.profile, model.calibration).toSet();
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
Container(
height: 44,
padding: const EdgeInsets.only(left: 4, right: 16),
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Glass.hairline))),
child: Row(children: <Widget>[
IconButton(
key: const ValueKey<String>('hub-back'),
onPressed: () => Navigator.of(context).pop(),
icon: const PVIcon('chevronLeft', size: 18, color: Glass.ink),
),
Expanded(
child: Text('Calibration & maintenance', style: HudText.sans.copyWith(fontSize: 15, fontWeight: FontWeight.w700)),
),
if (!model.connected) Text('NOT CONNECTED', style: HudText.label.copyWith(color: HudColors.caution)),
]),
),
Expanded(
child: ListView(padding: const EdgeInsets.only(bottom: 16), children: <Widget>[
for (final (String title, List<CalibrationKind> kinds) in _groups)
if (kinds.any(offered.contains)) ...<Widget>[
Padding(padding: const EdgeInsets.fromLTRB(16, 14, 16, 4), child: Text(title, style: HudText.label)),
for (final CalibrationKind k in kinds)
if (offered.contains(k))
_HubRow(
kind: k,
status: _status(k),
onTap: () => openCalibration(context, k, model: model, dji: dji, onError: onError),
),
],
]),
),
]);
},
),
),
),
);
}
}
class _HubRow extends StatelessWidget {
const _HubRow({required this.kind, required this.onTap, this.status});
final CalibrationKind kind;
final String? status;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
key: ValueKey<String>('hub-${kind.name}'),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11),
child: Row(children: <Widget>[
PVIcon(CalibrationKinds.icon(kind), size: 18, color: Glass.ink),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
Text(CalibrationKinds.title(kind), style: HudText.sans.copyWith(fontSize: 13)),
if (status != null)
Padding(
padding: const EdgeInsets.only(top: 3),
child: Text(status!, style: HudText.sans.copyWith(fontSize: 11, fontWeight: FontWeight.w500, color: HudText.dim)),
),
]),
),
const PVIcon('chevronRight', size: 16, color: HudText.dim),
]),
),
);
}
}
/// The gimbal adjust step: a horizon reference over the live video and
/// fine-tune steppers for roll (and pitch / yaw where the gimbal moves on
/// them), in a small glass panel so most of the picture stays visible.
class GimbalAdjustOverlay extends StatelessWidget {
const GimbalAdjustOverlay({super.key, required this.model, required this.dji, required this.onError});
static const String routeName = 'gimbalAdjust';
final FlightModel model;
final DjiService dji;
final void Function(String label, Object error) onError;
Future<void> _tune(String axis, int dir) async {
try {
await dji.fineTuneGimbal(axis, dir * GimbalActions.fineTuneStep);
} catch (e) {
onError('Adjust gimbal $axis', e);
}
}
static String _deg(double? v) => v == null ? '—' : '${v > 0 ? '+' : ''}${v.toStringAsFixed(1)}°';
@override
Widget build(BuildContext context) {
final bool pitch = model.profile.gimbal?.pitchAdjustable ?? true;
final bool yaw = model.profile.gimbal?.yawAdjustable ?? false;
return Material(
type: MaterialType.transparency,
child: Stack(fit: StackFit.expand, children: <Widget>[
const IgnorePointer(child: CustomPaint(painter: HorizonPainter())),
Positioned(
left: 16,
top: 12,
child: SafeArea(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(8)),
child: Text('Line the horizon in the video up with the dashed line', style: HudText.sans),
),
),
),
Positioned(
right: 12,
top: 12,
bottom: 12,
width: 300,
child: SafeArea(
left: false,
child: Container(
decoration: BoxDecoration(color: const Color(0xE60B1426), borderRadius: BorderRadius.circular(12)),
child: ListenableBuilder(
listenable: Listenable.merge(<Listenable>[model.gimbal, model.aircraft]),
builder: (BuildContext context, _) {
final List<GateCheck> checks = SafetyGate(model: model, dji: dji).checkMaintenance(CalibrationKind.gimbalAdjust);
final bool ok = checks.every((GateCheck c) => !c.blocks);
return ListView(padding: const EdgeInsets.symmetric(vertical: 6), children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 8, 2),
child: Row(children: <Widget>[
Expanded(child: Text('Adjust gimbal', style: HudText.sans.copyWith(fontSize: 14, fontWeight: FontWeight.w700))),
TextButton(
key: const ValueKey<String>('adjust-done'),
onPressed: () => Navigator.of(context).pop(),
child: Text('Done', style: HudText.sans.copyWith(color: Glass.accent, fontSize: 13)),
),
]),
),
for (final GateCheck c in checks)
Padding(
padding: const EdgeInsets.fromLTRB(16, 2, 16, 4),
child: Text(c.message, style: HudText.sans.copyWith(color: c.blocks ? HudColors.danger : HudColors.caution)),
),
StepperTile(
id: 'adjustRoll',
label: 'Roll',
hint: 'Tilt of the horizon',
shown: _deg(model.gimbal.rollFineTune),
canDown: ok,
canUp: ok,
onStep: (int dir) => _tune('roll', dir),
),
if (pitch)
StepperTile(
id: 'adjustPitch',
label: 'Pitch',
hint: 'Level point when centred',
shown: _deg(model.gimbal.pitchFineTune),
canDown: ok,
canUp: ok,
onStep: (int dir) => _tune('pitch', dir),
),
if (yaw)
StepperTile(
id: 'adjustYaw',
label: 'Yaw',
hint: 'Straight ahead when centred',
shown: _deg(model.gimbal.yawFineTune),
canDown: ok,
canUp: ok,
onStep: (int dir) => _tune('yaw', dir),
),
]);
},
),
),
),
),
]),
);
}
}
@@ -0,0 +1,291 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../../dji_service.dart';
import '../../../../../flight_model.dart';
import '../../../../../fly/calibration.dart';
import '../../../../../fly/safety_gate.dart';
import '../../../../../theme.dart';
import '../../../../pv_icons.dart';
import '../../../hud_kit.dart';
/// A wizard page: the shared inputs of every calibration flow.
abstract class CalibrationWizard extends StatefulWidget {
const CalibrationWizard({super.key, required this.model, required this.dji, required this.onError});
final FlightModel model;
final DjiService dji;
final void Function(String label, Object error) onError;
}
/// The plumbing every wizard shares: it folds the live model into [run],
/// sends its start command through the [SafetyGate], and on the way out
/// ([close], or the system back) [abort]s whatever is half done, so a
/// cancelled calibration leaves the aircraft in its normal state.
abstract class WizardState<W extends CalibrationWizard> extends State<W> {
CalibrationKind get kind;
CalibrationRun get run;
late final SafetyGate gate = SafetyGate(model: widget.model, dji: widget.dji);
FlightModel get model => widget.model;
DjiService get dji => widget.dji;
/// Leaving mid-run asks first unless this is false (IMU: nothing to stop).
bool get canAbort => true;
@override
void initState() {
super.initState();
model.addListener(_onModel);
}
@override
void dispose() {
model.removeListener(_onModel);
super.dispose();
}
void _onModel() {
if (!mounted) return;
setState(() => run.observe(model));
}
/// The gate's verdict right now, for the intro page.
List<GateCheck> get checks => gate.checkMaintenance(kind);
bool get blocked => checks.any((GateCheck c) => c.blocks);
/// Sends [send] through the gate; a refusal or SDK error fails the run.
Future<void> start(Future<void> Function() send) async {
HapticFeedback.selectionClick();
setState(run.begin);
try {
await gate.runMaintenance(kind, send);
if (mounted) setState(() => run.observe(model));
} catch (e) {
if (mounted) setState(() => run.startFailed(errorText(e)));
}
}
/// Runs a step command that is not the start; errors go to the snackbar.
Future<bool> send(String label, Future<void> Function() call) async {
try {
await call();
return true;
} catch (e) {
widget.onError(label, e);
return false;
}
}
/// Puts the aircraft back to normal. Called on the way out while [run] is active.
Future<void> abort() async {}
Future<void> close() async {
if (run.active && canAbort) {
final bool? stop = await showDialog<bool>(
context: context,
builder: (BuildContext context) => AlertDialog(
backgroundColor: const Color(0xFF0B1426),
title: Text('Stop ${CalibrationKinds.title(kind).toLowerCase()}?', style: HudText.sans.copyWith(fontSize: 15)),
content: Text('The aircraft goes back to its previous calibration.', style: HudText.sans.copyWith(color: HudText.dim)),
actions: <Widget>[
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Keep going')),
TextButton(
key: const ValueKey<String>('wizard-confirm-stop'),
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Stop', style: TextStyle(color: HudColors.danger)),
),
],
),
);
if (stop != true) return;
}
if (run.active) await abort();
if (mounted) Navigator.of(context).pop();
}
static String errorText(Object e) => switch (e) {
PlatformException(:final String? message, :final String code) => message ?? code,
_ => e.toString(),
};
}
/// One button along the bottom of a wizard.
class WizardAction {
const WizardAction(this.label, this.onTap, {this.primary = false, this.danger = false, required this.id});
final String id;
final String label;
final VoidCallback? onTap;
final bool primary;
final bool danger;
}
/// The full-screen frame of every wizard: the illustration on the left, the
/// step, instructions, gate checks, progress and buttons on the right (stacked
/// in portrait). Leaves the live view on purpose — GO 4 does the same.
class CalibrationScaffold extends StatelessWidget {
const CalibrationScaffold({
super.key,
required this.title,
required this.onClose,
required this.illustration,
required this.heading,
required this.body,
this.stepLabel,
this.checks = const <GateCheck>[],
this.detail,
this.progress,
this.actions = const <WizardAction>[],
this.tone,
});
final String title;
final VoidCallback onClose;
final Widget illustration;
final String? stepLabel;
final String heading;
final String body;
final List<GateCheck> checks;
final Widget? detail;
/// 0..1; null shows no bar, NaN an indeterminate one.
final double? progress;
final List<WizardAction> actions;
/// Heading colour: success / failure.
final Color? tone;
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (bool didPop, _) {
if (!didPop) onClose();
},
child: Scaffold(
backgroundColor: const Color(0xFF070D1A),
body: SafeArea(
child: LayoutBuilder(builder: (BuildContext context, BoxConstraints c) {
final bool wide = c.maxWidth > c.maxHeight;
final Widget art = Container(
margin: const EdgeInsets.all(12),
decoration: BoxDecoration(color: const Color(0xFF0B1426), borderRadius: BorderRadius.circular(14)),
child: Center(child: illustration),
);
final Widget text = _text(context);
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
_titleBar(),
Expanded(
child: wide
? Row(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
Expanded(flex: 5, child: art),
Expanded(flex: 6, child: text),
])
: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
SizedBox(height: c.maxHeight * 0.38, child: art),
Expanded(child: text),
]),
),
]);
}),
),
),
);
}
Widget _titleBar() => Container(
height: 44,
padding: const EdgeInsets.only(left: 16, right: 4),
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Glass.hairline))),
child: Row(children: <Widget>[
Expanded(child: Text(title, style: HudText.sans.copyWith(fontSize: 15, fontWeight: FontWeight.w700))),
IconButton(
key: const ValueKey<String>('wizard-close'),
onPressed: onClose,
icon: const PVIcon('close', size: 18, color: HudText.dim),
),
]),
);
Widget _text(BuildContext context) {
final double? p = progress;
return Padding(
padding: const EdgeInsets.fromLTRB(8, 12, 20, 12),
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
Expanded(
child: ListView(padding: EdgeInsets.zero, children: <Widget>[
if (stepLabel != null) Text(stepLabel!, key: const ValueKey<String>('wizard-step'), style: HudText.label),
if (stepLabel != null) const SizedBox(height: 6),
Text(
heading,
key: const ValueKey<String>('wizard-heading'),
style: HudText.sans.copyWith(fontSize: 18, fontWeight: FontWeight.w700, color: tone ?? Glass.ink, height: 1.2),
),
const SizedBox(height: 8),
Text(body, style: HudText.sans.copyWith(fontSize: 13, fontWeight: FontWeight.w500, color: HudText.dim, height: 1.4)),
for (final GateCheck c in <GateCheck>[...checks.where((GateCheck c) => c.blocks), ...checks.where((GateCheck c) => !c.blocks)])
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
PVIcon('alert', size: 14, color: c.blocks ? HudColors.danger : HudColors.caution),
const SizedBox(width: 6),
Expanded(
child: Text(c.message,
style: HudText.sans.copyWith(fontSize: 12, color: c.blocks ? HudColors.danger : HudColors.caution, height: 1.3)),
),
]),
),
if (detail != null) Padding(padding: const EdgeInsets.only(top: 12), child: detail),
]),
),
if (p != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
key: const ValueKey<String>('wizard-progress'),
value: p.isNaN ? null : p.clamp(0.0, 1.0),
minHeight: 6,
backgroundColor: Glass.hairline,
color: Glass.accent,
),
),
),
if (actions.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(mainAxisAlignment: MainAxisAlignment.end, children: <Widget>[
for (final WizardAction a in actions)
Padding(padding: const EdgeInsets.only(left: 10), child: _button(a)),
]),
),
]),
);
}
static Widget _button(WizardAction a) {
final Color color = a.danger ? HudColors.danger : Glass.accent;
final Text label = Text(a.label, style: const TextStyle(fontFamily: PV.fontSans, fontWeight: FontWeight.w700));
return a.primary
? FilledButton(
key: ValueKey<String>('wizard-${a.id}'),
onPressed: a.onTap,
style: FilledButton.styleFrom(
backgroundColor: color,
disabledBackgroundColor: Glass.hairline,
disabledForegroundColor: HudText.dim,
minimumSize: const Size(120, 42),
),
child: label,
)
: OutlinedButton(
key: ValueKey<String>('wizard-${a.id}'),
onPressed: a.onTap,
style: OutlinedButton.styleFrom(foregroundColor: Glass.ink, side: BorderSide(color: color), minimumSize: const Size(100, 42)),
child: label,
);
}
}
@@ -0,0 +1,416 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../../../../fly/calibration.dart';
import '../../../../../fly/state/calibration_state.dart';
import '../../../../../theme.dart';
import '../../../../pv_icons.dart';
import '../../../hud_kit.dart';
const Color _line = Glass.ink;
const Color _soft = Color(0x66EAF0FA);
/// A big icon in a ring — the illustration for wizards without a drawing,
/// with the ring filling as [progress] (0..1) grows.
class IconIllustration extends StatelessWidget {
const IconIllustration(this.icon, {super.key, this.progress, this.color = Glass.accent});
final String icon;
final double? progress;
final Color color;
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: 150,
child: Stack(alignment: Alignment.center, children: <Widget>[
SizedBox.expand(
child: CircularProgressIndicator(
value: progress ?? 0,
strokeWidth: 4,
backgroundColor: Glass.hairline,
color: color,
),
),
PVIcon(icon, size: 64, stroke: 1.4, color: Glass.ink),
]),
);
}
}
/// The compass dance: level spin, then nose-down spin, with a turning arrow.
class CompassIllustration extends StatefulWidget {
const CompassIllustration({super.key, required this.step, this.animate = true});
final CompassStep step;
final bool animate;
@override
State<CompassIllustration> createState() => _CompassIllustrationState();
}
class _CompassIllustrationState extends State<CompassIllustration> with SingleTickerProviderStateMixin {
late final AnimationController _spin = AnimationController(vsync: this, duration: const Duration(seconds: 4));
@override
void initState() {
super.initState();
if (widget.animate) _spin.repeat();
}
@override
void dispose() {
_spin.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
SizedBox(
width: 220,
height: 180,
child: AnimatedBuilder(
animation: _spin,
builder: (BuildContext context, _) => CustomPaint(painter: _CompassPainter(widget.step, _spin.value)),
),
),
const SizedBox(height: 8),
Text(widget.step == CompassStep.horizontal ? 'Level · turn 360°' : 'Nose down · turn 360°', style: HudText.label),
]);
}
}
class _CompassPainter extends CustomPainter {
_CompassPainter(this.step, this.t);
final CompassStep step;
final double t;
@override
void paint(Canvas canvas, Size size) {
final Offset c = size.center(Offset.zero);
final Paint ring = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2
..color = Glass.accent;
final Rect oval = Rect.fromCenter(center: c + const Offset(0, 40), width: 190, height: 50);
canvas.drawArc(oval, 0, math.pi * 1.7, false, ring);
// The arrowhead travels round the ellipse.
final double a = t * 2 * math.pi;
final Offset tip = Offset(oval.center.dx + oval.width / 2 * math.cos(a), oval.center.dy + oval.height / 2 * math.sin(a));
canvas.drawCircle(tip, 5, Paint()..color = Glass.accent);
canvas.save();
canvas.translate(c.dx, c.dy);
if (step == CompassStep.horizontal) {
// Seen from slightly above: squash vertically, spin about the vertical axis.
canvas.scale(1, 0.45);
canvas.rotate(a);
AircraftGlyph.top(canvas, 60);
} else {
// Nose down: the body stands on end, its width breathing as it spins.
canvas.scale(math.cos(a).abs().clamp(0.25, 1.0), 1);
canvas.rotate(math.pi / 2);
AircraftGlyph.side(canvas, 64);
}
canvas.restore();
}
@override
bool shouldRepaint(covariant _CompassPainter old) => old.t != t || old.step != step;
}
/// The aircraft on the side the IMU wants down, over a ground line.
class ImuIllustration extends StatelessWidget {
const ImuIllustration({super.key, this.orientation});
/// A `CalibrationOrientation` name; null draws it flat.
final String? orientation;
@override
Widget build(BuildContext context) {
return Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
SizedBox(width: 220, height: 170, child: CustomPaint(painter: _ImuPainter(orientation ?? 'BOTTOM_DOWN'))),
const SizedBox(height: 8),
Text(ImuOrientations.label(orientation ?? 'BOTTOM_DOWN').toUpperCase(), style: HudText.label),
]);
}
}
class _ImuPainter extends CustomPainter {
_ImuPainter(this.orientation);
final String orientation;
@override
void paint(Canvas canvas, Size size) {
final double ground = size.height - 18;
canvas.drawLine(Offset(20, ground), Offset(size.width - 20, ground), Paint()
..color = _soft
..strokeWidth = 2);
const double w = 70;
// How far the drawing reaches below its centre on each side, so it rests on the line.
final double reach = switch (orientation) {
'NOSE_DOWN' || 'TAIL_DOWN' => w * 0.66,
'LEFT_DOWN' || 'RIGHT_DOWN' => w * 0.64,
'TOP_DOWN' => w * 0.18,
_ => w * 0.28,
};
canvas.save();
canvas.translate(size.width / 2, ground - reach - 1);
final bool front = orientation == 'LEFT_DOWN' || orientation == 'RIGHT_DOWN';
canvas.rotate(switch (orientation) {
'TOP_DOWN' => math.pi,
'NOSE_DOWN' => math.pi / 2,
'TAIL_DOWN' => -math.pi / 2,
'LEFT_DOWN' => -math.pi / 2,
'RIGHT_DOWN' => math.pi / 2,
_ => 0,
});
front ? AircraftGlyph.front(canvas, w) : AircraftGlyph.side(canvas, w);
canvas.restore();
}
@override
bool shouldRepaint(covariant _ImuPainter old) => old.orientation != orientation;
}
/// Simple quadcopter line drawings, centred on the origin, [w] wide.
abstract final class AircraftGlyph {
static Paint get _stroke => Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.4
..strokeCap = StrokeCap.round
..color = _line;
/// From above, nose up: X arms, four rotors, a nose dot.
static void top(Canvas c, double w) {
final Paint p = _stroke;
final double r = w * 0.5;
for (final double a in <double>[math.pi / 4, 3 * math.pi / 4, 5 * math.pi / 4, 7 * math.pi / 4]) {
final Offset e = Offset(math.cos(a) * r, math.sin(a) * r);
c.drawLine(Offset.zero, e, p);
c.drawCircle(e, w * 0.2, p);
}
c.drawRRect(RRect.fromRectAndRadius(Rect.fromCenter(center: Offset.zero, width: w * 0.3, height: w * 0.45), const Radius.circular(6)),
Paint()..color = const Color(0xFF0B1426));
c.drawRRect(RRect.fromRectAndRadius(Rect.fromCenter(center: Offset.zero, width: w * 0.3, height: w * 0.45), const Radius.circular(6)), p);
c.drawCircle(Offset(0, -w * 0.16), 3.5, Paint()..color = HudColors.danger);
}
/// From the left side, nose to the right (+x): body, arms, rotors, legs.
static void side(Canvas c, double w) {
final Paint p = _stroke;
c.drawRRect(RRect.fromRectAndRadius(Rect.fromCenter(center: Offset.zero, width: w * 0.6, height: w * 0.22), const Radius.circular(5)), p);
for (final double x in <double>[-w * 0.5, w * 0.5]) {
c.drawLine(Offset(x * 0.55, -w * 0.06), Offset(x, -w * 0.14), p);
c.drawLine(Offset(x - w * 0.16, -w * 0.18), Offset(x + w * 0.16, -w * 0.18), p);
c.drawLine(Offset(x * 0.5, w * 0.11), Offset(x * 0.6, w * 0.28), p);
}
// Nose camera.
c.drawCircle(Offset(w * 0.33, w * 0.04), w * 0.05, Paint()..color = HudColors.danger);
}
/// From the front: body, two arms each side, rotors, legs.
static void front(Canvas c, double w) {
final Paint p = _stroke;
c.drawRRect(RRect.fromRectAndRadius(Rect.fromCenter(center: Offset.zero, width: w * 0.32, height: w * 0.24), const Radius.circular(5)), p);
for (final double s in <double>[-1, 1]) {
c.drawLine(Offset(s * w * 0.16, -w * 0.04), Offset(s * w * 0.5, -w * 0.12), p);
c.drawLine(Offset(s * w * 0.5 - w * 0.14, -w * 0.17), Offset(s * w * 0.5 + w * 0.14, -w * 0.17), p);
c.drawLine(Offset(s * w * 0.12, w * 0.12), Offset(s * w * 0.22, w * 0.3), p);
}
c.drawCircle(Offset(0, w * 0.02), w * 0.05, Paint()..color = HudColors.danger);
}
}
/// The remote's two sticks and two dials, live, with the area each stick has
/// swept so far — the RC wizard's "move to every limit" view.
class RcSticksView extends StatefulWidget {
const RcSticksView({super.key, required this.hardware, this.showCoverage = false});
final RcHardware? hardware;
final bool showCoverage;
/// Stick travel either side of centre, as the SDK reports it.
static const int travel = 660;
@override
State<RcSticksView> createState() => _RcSticksViewState();
}
class _RcSticksViewState extends State<RcSticksView> {
Rect _left = Rect.zero, _right = Rect.zero;
@override
void didUpdateWidget(RcSticksView old) {
super.didUpdateWidget(old);
if (!widget.showCoverage) {
_left = _right = Rect.zero;
return;
}
final RcHardware? h = widget.hardware;
if (h == null) return;
_left = _grow(_left, h.lx, h.ly);
_right = _grow(_right, h.rx, h.ry);
}
static Rect _grow(Rect r, int? x, int? y) {
if (x == null || y == null) return r;
final Offset p = Offset(x.toDouble(), -y.toDouble());
return r.expandToInclude(Rect.fromCenter(center: p, width: 0, height: 0));
}
@override
Widget build(BuildContext context) {
final RcHardware? h = widget.hardware;
return Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
_Stick(x: h?.lx, y: h?.ly, swept: widget.showCoverage ? _left : null, key: const ValueKey<String>('rc-left-stick')),
const SizedBox(width: 28),
_Stick(x: h?.rx, y: h?.ry, swept: widget.showCoverage ? _right : null, key: const ValueKey<String>('rc-right-stick')),
]),
const SizedBox(height: 14),
Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
_Dial(label: 'LEFT DIAL', value: h?.leftDial),
const SizedBox(width: 28),
_Dial(label: 'RIGHT DIAL', value: h?.rightDial),
]),
]);
}
}
class _Stick extends StatelessWidget {
const _Stick({super.key, this.x, this.y, this.swept});
final int? x, y;
final Rect? swept;
@override
Widget build(BuildContext context) =>
SizedBox.square(dimension: 104, child: CustomPaint(painter: _StickPainter(x, y, swept)));
}
class _StickPainter extends CustomPainter {
_StickPainter(this.x, this.y, this.swept);
final int? x, y;
final Rect? swept;
@override
void paint(Canvas canvas, Size size) {
final Rect box = Offset.zero & size;
final double half = size.width / 2;
double px(num v) => half + v / RcSticksView.travel * (half - 8);
canvas.drawRRect(RRect.fromRectAndRadius(box, const Radius.circular(10)), Paint()..color = const Color(0xFF111C33));
if (swept != null && swept != Rect.zero) {
canvas.drawRect(
Rect.fromLTRB(px(swept!.left), px(swept!.top), px(swept!.right), px(swept!.bottom)),
Paint()..color = HudColors.ready.withValues(alpha: 0.25),
);
}
final Paint cross = Paint()
..color = _soft
..strokeWidth = 1;
canvas.drawLine(Offset(half, 6), Offset(half, size.height - 6), cross);
canvas.drawLine(Offset(6, half), Offset(size.width - 6, half), cross);
if (x != null && y != null) {
canvas.drawCircle(Offset(px(x!), px(-y!)), 8, Paint()..color = Glass.accent);
}
}
@override
bool shouldRepaint(covariant _StickPainter old) => old.x != x || old.y != y || old.swept != swept;
}
class _Dial extends StatelessWidget {
const _Dial({required this.label, this.value});
final String label;
final int? value;
@override
Widget build(BuildContext context) {
final double t = ((value ?? 0) / RcSticksView.travel).clamp(-1.0, 1.0);
return SizedBox(
width: 104,
child: Column(children: <Widget>[
Text(label, style: HudText.label),
const SizedBox(height: 4),
SizedBox(
height: 8,
child: LayoutBuilder(builder: (BuildContext context, BoxConstraints c) {
final double mid = c.maxWidth / 2;
return Stack(children: <Widget>[
Container(decoration: BoxDecoration(color: const Color(0xFF111C33), borderRadius: BorderRadius.circular(4))),
Positioned(
left: t < 0 ? mid + t * mid : mid,
width: t.abs() * mid,
top: 0,
bottom: 0,
child: Container(color: Glass.accent),
),
]);
}),
),
]),
);
}
}
/// Reference lines drawn over the live video for the gimbal adjust step: a
/// dashed level line through the centre and faint thirds to judge the
/// horizon against.
class HorizonPainter extends CustomPainter {
const HorizonPainter();
@override
void paint(Canvas canvas, Size size) {
final double mid = size.height / 2;
final Paint ref = Paint()
..color = Glass.subject
..strokeWidth = 1.5;
for (double x = 0; x < size.width; x += 18) {
canvas.drawLine(Offset(x, mid), Offset(math.min(x + 10, size.width), mid), ref);
}
final Paint thirds = Paint()
..color = const Color(0x33FFFFFF)
..strokeWidth = 1;
for (final double f in <double>[1 / 3, 2 / 3]) {
canvas.drawLine(Offset(0, size.height * f), Offset(size.width, size.height * f), thirds);
}
}
@override
bool shouldRepaint(covariant HorizonPainter old) => false;
}
/// A row of sides for the multi-side IMU calibration, done ones ticked.
class SideChips extends StatelessWidget {
const SideChips({super.key, required this.sides, this.current});
final List<(String, bool)> sides;
final String? current;
@override
Widget build(BuildContext context) {
return Wrap(spacing: 6, runSpacing: 6, children: <Widget>[
for (final (String side, bool done) in sides)
Container(
key: ValueKey<String>('imu-side-$side'),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: side == current ? Glass.accent : (done ? HudColors.ready.withValues(alpha: 0.25) : const Color(0xFF111C33)),
),
child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
if (done) const PVIcon('check', size: 12, color: HudColors.ready),
if (done) const SizedBox(width: 4),
Text(ImuOrientations.label(side), style: HudText.sans.copyWith(fontSize: 11, fontFamily: PV.fontSans)),
]),
),
]);
}
}
@@ -0,0 +1,608 @@
import 'package:flutter/material.dart';
import '../../../../../fly/calibration.dart';
import '../../../hud_kit.dart';
import 'calibration_scaffold.dart';
import 'illustrations.dart';
/// The pieces every wizard's pages share: the intro with the gate's checks,
/// the "starting" page, and the success / failure ends.
mixin _Pages<W extends CalibrationWizard> on WizardState<W> {
String get title => CalibrationKinds.title(kind);
Widget intro({
required Widget illustration,
required String heading,
required String body,
required Future<void> Function() onStart,
String startLabel = 'Start',
Widget? detail,
}) {
final bool b = blocked;
return CalibrationScaffold(
title: title,
onClose: close,
illustration: illustration,
heading: heading,
body: body,
checks: checks,
detail: detail,
actions: <WizardAction>[
WizardAction(startLabel, b ? null : onStart, primary: true, id: 'start'),
],
);
}
Widget starting(Widget illustration) => CalibrationScaffold(
title: title,
onClose: close,
illustration: illustration,
heading: 'Starting…',
body: 'Waiting for the aircraft to begin.',
progress: double.nan,
);
Widget succeeded({required Widget illustration, required String heading, required String body, List<WizardAction> more = const <WizardAction>[]}) =>
CalibrationScaffold(
title: title,
onClose: close,
illustration: illustration,
heading: heading,
body: body,
tone: HudColors.ready,
actions: <WizardAction>[...more, WizardAction('Done', close, primary: true, id: 'done')],
);
Widget failed({required Widget illustration, required VoidCallback onRetry}) => CalibrationScaffold(
title: title,
onClose: close,
illustration: illustration,
heading: 'Calibration failed',
body: run.failure ?? 'Try again.',
tone: HudColors.danger,
checks: checks,
actions: <WizardAction>[
WizardAction('Close', close, id: 'close'),
WizardAction('Try again', blocked ? null : onRetry, primary: true, id: 'retry'),
],
);
/// Ends a run from its Stop button: undo, then back to the intro.
Future<void> stopHere() async {
await abort();
if (mounted) setState(run.reset);
}
WizardAction get stopAction => WizardAction('Stop', stopHere, danger: true, id: 'stop');
}
// ── Compass ─────────────────────────────────────────────────────────────────
class CompassWizard extends CalibrationWizard {
const CompassWizard({super.key, required super.model, required super.dji, required super.onError});
@override
State<CompassWizard> createState() => _CompassWizardState();
}
class _CompassWizardState extends WizardState<CompassWizard> with _Pages<CompassWizard> {
@override
CalibrationKind get kind => CalibrationKind.compass;
@override
final CompassRun run = CompassRun();
@override
Future<void> abort() => send('Stop compass calibration', dji.stopCompassCalibration);
Future<void> _start() => start(dji.startCompassCalibration);
@override
Widget build(BuildContext context) {
final CompassStep step = run.step ?? CompassStep.horizontal;
final int count = model.calibration.compassCount ?? model.profile.flightController?.compassCount ?? 1;
return switch (run.phase) {
RunPhase.idle => intro(
illustration: const CompassIllustration(step: CompassStep.horizontal),
heading: 'Calibrate the compass',
body: 'Do it outdoors, away from metal, cars, reinforced concrete and power lines, and without a phone or keys '
'in your hand. Take the aircraft off any metal surface. You will turn it a full circle level, '
'then a full circle nose down.${count > 1 ? ' Its $count compasses calibrate together.' : ''}',
onStart: _start,
),
RunPhase.starting => starting(const CompassIllustration(step: CompassStep.horizontal, animate: false)),
RunPhase.running => CalibrationScaffold(
title: title,
onClose: close,
illustration: CompassIllustration(key: ValueKey<CompassStep>(step), step: step),
stepLabel: step == CompassStep.horizontal ? 'STEP 1 OF 2' : 'STEP 2 OF 2',
heading: step == CompassStep.horizontal ? 'Hold it level and turn a full circle' : 'Hold it nose down and turn a full circle',
body: step == CompassStep.horizontal
? 'Turn slowly on the spot about 360° until the next step appears.'
: 'Point the nose at the ground and turn about 360° again.',
progress: step == CompassStep.horizontal ? 0.25 : 0.75,
actions: <WizardAction>[stopAction],
),
RunPhase.succeeded => succeeded(
illustration: const IconIllustration('compass', progress: 1, color: HudColors.ready),
heading: 'Compass calibrated',
body: 'Recalibrate after flying somewhere far away or if the app warns about compass interference.',
),
RunPhase.failed => failed(illustration: const IconIllustration('compass', color: HudColors.danger), onRetry: _start),
};
}
}
// ── IMU ─────────────────────────────────────────────────────────────────────
class ImuWizard extends CalibrationWizard {
const ImuWizard({super.key, required super.model, required super.dji, required super.onError});
@override
State<ImuWizard> createState() => _ImuWizardState();
}
class _ImuWizardState extends WizardState<ImuWizard> with _Pages<ImuWizard> {
@override
CalibrationKind get kind => CalibrationKind.imu;
@override
final ImuRun run = ImuRun();
/// The SDK has no stop: leaving the page lets it finish on its own.
@override
bool get canAbort => false;
Future<void> _start() => start(dji.startIMUCalibration);
@override
Widget build(BuildContext context) {
final int count = model.calibration.imuCount ?? model.profile.flightController?.imuCount ?? 1;
final double? p = run.progress;
return switch (run.phase) {
RunPhase.idle => intro(
illustration: const ImuIllustration(),
heading: 'Calibrate the IMU',
body: 'Take the propellers off and put the aircraft on a flat, level, steady surface with the motors off and '
'the gimbal clamp removed. Do not touch it while it calibrates — about 5 to 10 minutes. Some aircraft ask '
'to be turned onto each side; this screen shows which.${count > 1 ? ' All $count IMUs calibrate together.' : ''}',
onStart: _start,
),
RunPhase.starting => starting(const ImuIllustration()),
RunPhase.running => CalibrationScaffold(
title: title,
onClose: close,
illustration: ImuIllustration(key: ValueKey<String?>(run.orientation), orientation: run.orientation),
stepLabel: run.orientation == null
? null
: 'SIDE ${run.sides.where(((String, bool) s) => s.$2).length + 1} OF ${run.sides.length}',
heading: run.orientation == null
? 'Calibrating — do not move the aircraft'
: 'Put it ${ImuOrientations.label(run.orientation!).toLowerCase()}',
body: run.orientation == null
? 'Keep it still until calibration finishes. Leaving this screen does not stop it.'
: 'Hold it steady on that side until the side is ticked, then turn it as shown.',
detail: run.sides.isEmpty ? null : SideChips(sides: run.sides, current: run.orientation),
progress: p ?? double.nan,
),
RunPhase.succeeded => succeeded(
illustration: const IconIllustration('gauge', progress: 1, color: HudColors.ready),
heading: 'IMU calibrated',
body: 'Restart the aircraft before flying.',
),
RunPhase.failed => failed(illustration: const IconIllustration('gauge', color: HudColors.danger), onRetry: _start),
};
}
}
// ── Remote controller ───────────────────────────────────────────────────────
class RcWizard extends CalibrationWizard {
const RcWizard({super.key, required super.model, required super.dji, required super.onError});
@override
State<RcWizard> createState() => _RcWizardState();
}
class _RcWizardState extends WizardState<RcWizard> with _Pages<RcWizard> {
@override
CalibrationKind get kind => CalibrationKind.rc;
@override
final RcRun run = RcRun();
@override
void initState() {
super.initState();
dji.watchRcHardware(true).catchError((Object _) {});
}
@override
void dispose() {
dji.watchRcHardware(false).catchError((Object _) {});
super.dispose();
}
/// QUIT is the only way out the remote knows.
@override
Future<void> abort() => send('Leave RC calibration', () => dji.setRcCalibrationStep('QUIT'));
Future<void> _start() => start(() => dji.setRcCalibrationStep('MIDDLE'));
Future<void> _finish() async {
if (!await send('Save RC calibration', () => dji.setRcCalibrationStep('QUIT'))) return;
if (!mounted) return;
setState(() {
run.finishing();
run.observe(model);
});
}
Widget? _axes() {
final Map<String, Object?> axes = model.calibration.rcAxes;
if (axes.isEmpty) return null;
final int? segments = model.calibration.rcSegments;
return Text(
[for (final MapEntry<String, Object?> e in axes.entries) '${e.key} ${e.value}${segments == null ? '' : '/$segments'}'].join(' '),
key: const ValueKey<String>('rc-axes'),
style: HudText.mono,
);
}
@override
Widget build(BuildContext context) {
final bool limits = run.step == RcStep.limits;
final Widget sticks = RcSticksView(hardware: model.calibration.rcHardware, showCoverage: run.active && limits);
return switch (run.phase) {
RunPhase.idle => intro(
illustration: sticks,
heading: 'Calibrate the remote controller',
body: 'Keep the aircraft on the ground with the motors off. Let go of both sticks and centre both dials, then start. '
'Calibrate when the aircraft drifts with the sticks centred or the sticks feel off.',
onStart: _start,
),
RunPhase.starting => starting(sticks),
RunPhase.running when !limits => CalibrationScaffold(
title: title,
onClose: close,
illustration: sticks,
stepLabel: 'STEP 1 OF 2',
heading: 'Leave the sticks and dials centred',
body: 'The remote is recording their centre. Do not touch them, then tap Next.',
progress: 0.3,
actions: <WizardAction>[
stopAction,
WizardAction('Next', () => send('RC calibration', () => dji.setRcCalibrationStep('LIMITS')), primary: true, id: 'next'),
],
),
RunPhase.running => CalibrationScaffold(
title: title,
onClose: close,
illustration: sticks,
stepLabel: 'STEP 2 OF 2',
heading: 'Move every stick and dial to its limits',
body: 'Push both sticks around their full range a few times and turn both dials all the way each way. '
'Tap Finish when the green areas fill both boxes.',
detail: _axes(),
progress: 0.7,
actions: <WizardAction>[
stopAction,
WizardAction('Finish', _finish, primary: true, id: 'finish'),
],
),
RunPhase.succeeded => succeeded(illustration: sticks, heading: 'Remote controller calibrated', body: 'The new centre and limits are saved.'),
RunPhase.failed => failed(illustration: sticks, onRetry: _start),
};
}
}
// ── Gimbal auto calibration ─────────────────────────────────────────────────
class GimbalCalibrationWizard extends CalibrationWizard {
const GimbalCalibrationWizard({super.key, required super.model, required super.dji, required super.onError, this.onAdjust});
/// Offered after a calibration: opens the horizon adjust step.
final VoidCallback? onAdjust;
@override
State<GimbalCalibrationWizard> createState() => _GimbalCalibrationWizardState();
}
class _GimbalCalibrationWizardState extends WizardState<GimbalCalibrationWizard> with _Pages<GimbalCalibrationWizard> {
@override
CalibrationKind get kind => CalibrationKind.gimbal;
@override
final GimbalRun run = GimbalRun();
/// The SDK has no stop for gimbal auto calibration.
@override
bool get canAbort => false;
Future<void> _start() => start(dji.startGimbalCalibration);
@override
Widget build(BuildContext context) {
final double? p = run.progress == null ? null : run.progress! / 100;
return switch (run.phase) {
RunPhase.idle => intro(
illustration: const IconIllustration('gimbal'),
heading: 'Auto calibrate the gimbal',
body: 'Put the aircraft on a flat, level surface with the motors off, the gimbal clamp removed and nothing touching '
'the camera. Do not touch it until calibration finishes — about 30 seconds.',
onStart: _start,
),
RunPhase.starting => starting(const IconIllustration('gimbal')),
RunPhase.running => CalibrationScaffold(
title: title,
onClose: close,
illustration: IconIllustration('gimbal', progress: p),
heading: 'Calibrating — do not touch the aircraft',
body: 'The gimbal moves through its range on its own.',
progress: p ?? double.nan,
),
RunPhase.succeeded => succeeded(
illustration: const IconIllustration('gimbal', progress: 1, color: HudColors.ready),
heading: 'Gimbal calibrated',
body: 'If the horizon still looks tilted, adjust it against the live video.',
more: <WizardAction>[
if (widget.onAdjust != null) WizardAction('Adjust horizon', widget.onAdjust, id: 'adjust'),
],
),
RunPhase.failed => failed(illustration: const IconIllustration('gimbal', color: HudColors.danger), onRetry: _start),
};
}
}
// ── Lens ────────────────────────────────────────────────────────────────────
class LensWizard extends CalibrationWizard {
const LensWizard({super.key, required super.model, required super.dji, required super.onError});
@override
State<LensWizard> createState() => _LensWizardState();
}
class _LensWizardState extends WizardState<LensWizard> with _Pages<LensWizard> {
@override
CalibrationKind get kind => CalibrationKind.lens;
@override
final LensRun run = LensRun();
@override
Future<void> abort() => send('Stop lens calibration', dji.stopLensCalibration);
Future<void> _start() => start(dji.startLensCalibration);
@override
Widget build(BuildContext context) {
return switch (run.phase) {
RunPhase.idle => intro(
illustration: const IconIllustration('focus'),
heading: 'Calibrate the lens',
body: 'Do this after fitting a lens, or when manual focus does not match the distance scale. '
'Point the camera at a distant, detailed scene in good light and keep the aircraft still.',
onStart: _start,
),
RunPhase.starting => starting(const IconIllustration('focus')),
RunPhase.running => CalibrationScaffold(
title: title,
onClose: close,
illustration: const IconIllustration('focus'),
heading: 'Calibrating the lens…',
body: 'The lens runs through its focus range. Keep the camera still.',
progress: double.nan,
actions: <WizardAction>[stopAction],
),
RunPhase.succeeded => succeeded(
illustration: const IconIllustration('focus', progress: 1, color: HudColors.ready),
heading: 'Lens calibrated',
body: 'Manual focus now follows the lens\'s distance scale.',
),
RunPhase.failed => failed(illustration: const IconIllustration('focus', color: HudColors.danger), onRetry: _start),
};
}
}
// ── Sensor cleaning ─────────────────────────────────────────────────────────
class SensorCleaningWizard extends CalibrationWizard {
const SensorCleaningWizard({super.key, required super.model, required super.dji, required super.onError});
@override
State<SensorCleaningWizard> createState() => _SensorCleaningWizardState();
}
class _SensorCleaningWizardState extends WizardState<SensorCleaningWizard> with _Pages<SensorCleaningWizard> {
@override
CalibrationKind get kind => CalibrationKind.sensorCleaning;
@override
final SensorCleaningRun run = SensorCleaningRun();
@override
Future<void> abort() => send('Leave sensor cleaning', dji.exitSensorCleaning);
Future<void> _start() => start(dji.initSensorCleaning);
Future<void> _finish() async {
setState(run.exiting);
await send('Leave sensor cleaning', dji.exitSensorCleaning);
if (mounted) setState(() => run.observe(model));
}
@override
Widget build(BuildContext context) {
final CleaningStep step = run.step;
const Widget art = IconIllustration('aperture');
return switch (run.phase) {
RunPhase.idle => intro(
illustration: art,
heading: 'Clean the image sensor',
body: '${SensorCleaning.instruction(CleaningStep.off)} Do it indoors, out of the wind, with the aircraft on a table.',
onStart: _start,
),
RunPhase.starting => starting(art),
RunPhase.running => CalibrationScaffold(
title: title,
onClose: close,
illustration: art,
stepLabel: switch (step) {
CleaningStep.removeLens || CleaningStep.entering => 'STEP 1 OF 3',
CleaningStep.ready || CleaningStep.cleaning || CleaningStep.finishing => 'STEP 2 OF 3',
CleaningStep.remountLens || CleaningStep.off => 'STEP 3 OF 3',
},
heading: switch (step) {
CleaningStep.removeLens => 'Remove the lens',
CleaningStep.ready => 'Ready to clean',
CleaningStep.cleaning => 'Cleaning',
CleaningStep.remountLens => 'Refit the lens',
_ => 'Please wait',
},
body: SensorCleaning.instruction(step),
progress: step == CleaningStep.cleaning || step == CleaningStep.entering || step == CleaningStep.finishing ? double.nan : null,
actions: <WizardAction>[
stopAction,
if (step == CleaningStep.ready) WizardAction('Clean', () => send('Sensor cleaning', dji.startSensorCleaning), primary: true, id: 'clean'),
if (step == CleaningStep.remountLens) WizardAction('Finish', _finish, primary: true, id: 'finish'),
],
),
RunPhase.succeeded => succeeded(
illustration: const IconIllustration('aperture', progress: 1, color: HudColors.ready),
heading: 'Sensor cleaned',
body: 'If spots remain on a plain sky, have the sensor cleaned by DJI support.',
),
RunPhase.failed => failed(illustration: const IconIllustration('aperture', color: HudColors.danger), onRetry: _start),
};
}
}
// ── Center of gravity ───────────────────────────────────────────────────────
class GravityCenterWizard extends CalibrationWizard {
const GravityCenterWizard({super.key, required super.model, required super.dji, required super.onError});
@override
State<GravityCenterWizard> createState() => _GravityCenterWizardState();
}
class _GravityCenterWizardState extends WizardState<GravityCenterWizard> with _Pages<GravityCenterWizard> {
@override
CalibrationKind get kind => CalibrationKind.gravityCenter;
@override
final GravityCenterRun run = GravityCenterRun();
@override
Future<void> abort() => send('Stop gravity calibration', dji.stopGravityCenterCalibration);
Future<void> _start() => start(dji.startGravityCenterCalibration);
@override
Widget build(BuildContext context) {
const Widget art = IconIllustration('drone');
return switch (run.phase) {
RunPhase.idle => intro(
illustration: art,
heading: 'Calibrate the center of gravity',
body: 'After fitting, moving or changing a payload. Take off, hover at least 2 m above open ground in calm air, '
'let go of the sticks, then start. The aircraft measures its balance for about a minute.',
onStart: _start,
),
RunPhase.starting => starting(art),
RunPhase.running => CalibrationScaffold(
title: title,
onClose: close,
illustration: art,
heading: 'Measuring — keep the sticks centred',
body: 'The aircraft holds its hover while it measures. Take over at once if it drifts towards anything.',
progress: double.nan,
actions: <WizardAction>[stopAction],
),
RunPhase.succeeded => succeeded(
illustration: const IconIllustration('drone', progress: 1, color: HudColors.ready),
heading: 'Center of gravity calibrated',
body: 'Land when ready. Recalibrate whenever the payload changes.',
),
RunPhase.failed => failed(illustration: const IconIllustration('drone', color: HudColors.danger), onRetry: _start),
};
}
}
// ── Propellers ──────────────────────────────────────────────────────────────
class PropellerWizard extends CalibrationWizard {
const PropellerWizard({super.key, required super.model, required super.dji, required super.onError});
@override
State<PropellerWizard> createState() => _PropellerWizardState();
}
class _PropellerWizardState extends WizardState<PropellerWizard> with _Pages<PropellerWizard> {
@override
CalibrationKind get kind => CalibrationKind.propeller;
@override
final PropellerRun run = PropellerRun();
@override
Future<void> abort() => send('Stop propeller calibration', dji.stopPropellerCalibration);
Future<void> _start() async {
await start(dji.startPropellerCalibration);
if (mounted) setState(run.accepted);
}
Future<void> _done() async {
await send('Stop propeller calibration', dji.stopPropellerCalibration);
if (mounted) setState(run.complete);
}
@override
Widget build(BuildContext context) {
const Widget art = IconIllustration('drone');
return switch (run.phase) {
RunPhase.idle => intro(
illustration: art,
heading: 'Calibrate the propellers',
body: 'Put the aircraft on open, level ground with the propellers fitted. The motors spin during calibration: '
'step back and keep everyone clear.',
onStart: _start,
),
RunPhase.starting => starting(art),
RunPhase.running => CalibrationScaffold(
title: title,
onClose: close,
illustration: art,
heading: 'Calibrating — keep clear',
body: 'The aircraft does not report progress to the app. When the motors have stopped, tap Done.',
progress: double.nan,
actions: <WizardAction>[
stopAction,
WizardAction('Done', _done, primary: true, id: 'finish'),
],
),
RunPhase.succeeded => succeeded(
illustration: const IconIllustration('drone', progress: 1, color: HudColors.ready),
heading: 'Propeller calibration ended',
body: 'If the aircraft reported an error on its status lights, run it again.',
),
RunPhase.failed => failed(illustration: const IconIllustration('drone', color: HudColors.danger), onRetry: _start),
};
}
}
// ── Vision sensors ──────────────────────────────────────────────────────────
/// No SDK call exists; GO 4 also only points to DJI Assistant 2.
class VisionSensorGuide extends StatelessWidget {
const VisionSensorGuide({super.key});
@override
Widget build(BuildContext context) {
void close() => Navigator.of(context).pop();
return CalibrationScaffold(
title: CalibrationKinds.title(CalibrationKind.visionSensors),
onClose: close,
illustration: const IconIllustration('obstacle'),
heading: 'Calibrate on a computer with DJI Assistant 2',
body: 'The vision sensors cannot be calibrated from a mobile app. Connect the aircraft to a computer with '
'DJI Assistant 2 (the Consumer or Enterprise series that matches the aircraft), open Calibration and '
'follow the steps on screen. Do it when the app reports a vision sensor error or after a hard knock.',
actions: <WizardAction>[WizardAction('Close', close, primary: true, id: 'done')],
);
}
}
@@ -17,6 +17,7 @@ import '../../dialogs/slide_to_confirm.dart';
import '../../hud_kit.dart';
import '../camera_menu/camera_rows.dart';
import '../gimbal_sheet.dart';
import 'calibration/calibration_hub.dart';
import 'setting_tiles.dart';
/// Opens the aircraft settings (plan P6, GO 4's "•••") as a right-side glass
@@ -379,11 +380,18 @@ class _FlySettingsSheetState extends State<FlySettingsSheet> {
}}',
valueColor: compassError == true ? HudColors.danger : null,
),
ButtonTile(
id: 'openCalibration',
label: 'Calibration & maintenance',
hint: 'Compass, IMU, remote, gimbal and more',
button: 'Open',
onTap: () => showCalibrationHub(context, model: _m, dji: widget.dji, onError: widget.onError),
),
if (widget.onOpenStatus != null)
ButtonTile(
id: 'openStatus',
label: 'Aircraft status & calibration',
hint: 'Compass and IMU calibration',
label: 'Aircraft status',
hint: 'Every subsystem at a glance',
button: 'Open',
onTap: widget.onOpenStatus,
),
+499
View File
@@ -0,0 +1,499 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pilotvault_fly/flight_model.dart';
import 'package:pilotvault_fly/fly/calibration.dart';
import 'package:pilotvault_fly/fly/product_profile.dart';
import 'package:pilotvault_fly/fly/safety_gate.dart';
import 'package:pilotvault_fly/fly/telemetry_reducer.dart';
import 'package:pilotvault_fly/ui/fly/panels/aircraft_status_sheet.dart';
import 'package:pilotvault_fly/ui/fly/panels/settings/calibration/calibration_hub.dart';
import 'package:pilotvault_fly/ui/fly/panels/settings/calibration/wizards.dart';
import 'fake_dji.dart';
import 'profile_fixtures.dart';
import 'telemetry_fixtures.dart';
/// A connected aircraft on the ground, fed through the real reducer; [tick]
/// sends the next telemetry snapshot with [patch] on top.
class Rig {
Rig({Map<String, dynamic>? profile, bool flying = false}) {
model
..connected = true
..profile = ProductProfile.fromBridge(profile ?? phantom4);
tick(flying ? const <String, Object?>{} : const <String, Object?>{'isFlying': false, 'areMotorsOn': false});
}
final FlightModel model = FlightModel();
late final TelemetryReducer reducer = TelemetryReducer(model);
final Map<String, Object?> _state = <String, Object?>{};
void tick(Map<String, Object?> patch) {
_state.addAll(patch);
reducer.apply(<String, dynamic>{...telemetryTick(), ..._state});
}
void calibration(Map<String, Object?> group) => tick(<String, Object?>{'calibration': group});
}
Map<String, dynamic> withLens(Map<String, dynamic> p) => <String, dynamic>{
...p,
'cameras': <dynamic>[
for (final Object? c in p['cameras'] as List<dynamic>) <String, dynamic>{...(c as Map<String, dynamic>), 'interchangeableLensSupported': true},
],
};
void main() {
group('State', () {
test('the calibration group lands in CalibrationState and clears when absent', () {
final Rig r = Rig();
r.calibration(<String, Object?>{
'support': <String, Object?>{'compassCount': 2, 'imuCount': 2, 'gravityCenter': false, 'rc': true},
'imu': <String, Object?>{
'units': <Object?>[
<String, Object?>{'index': 0, 'state': 'CALIBRATING', 'progress': 40, 'connected': true},
],
'multiSide': true,
'toCalibrate': <Object?>['NOSE_DOWN'],
'calibrated': <Object?>['BOTTOM_DOWN'],
},
'rc': <String, Object?>{'state': 'MIDDLE', 'segments': 4, 'axes': <String, Object?>{'A': 2}},
'rcHardware': <String, Object?>{'lx': 100, 'ly': -660, 'rx': 0, 'ry': 0, 'leftDial': 10, 'rightDial': -5},
});
final c = r.model.calibration;
expect(c.compassCount, 2);
expect(c.gravityCenterSupported, isFalse);
expect(c.imuUnits.single.progress, 40);
expect(c.imuToCalibrate, <String>['NOSE_DOWN']);
expect(c.rcState, 'MIDDLE');
expect(c.rcAxes['A'], 2);
expect(c.rcHardware!.ly, -660);
r.tick(<String, Object?>{'calibration': null});
expect(c.compassCount, isNull);
expect(c.imuUnits, isEmpty);
expect(c.rcHardware, isNull);
});
});
group('Availability', () {
test('profile features, then runtime support, decide the hub', () {
final Rig r = Rig();
List<CalibrationKind> kinds() => CalibrationKinds.available(r.model.profile, r.model.calibration);
expect(kinds(), containsAll(<CalibrationKind>[
CalibrationKind.compass,
CalibrationKind.imu,
CalibrationKind.rc,
CalibrationKind.gimbal,
CalibrationKind.gimbalAdjust,
CalibrationKind.visionSensors,
]));
expect(kinds(), isNot(contains(CalibrationKind.lens)), reason: 'fixed lens');
expect(kinds(), isNot(contains(CalibrationKind.sensorCleaning)));
expect(kinds(), contains(CalibrationKind.gravityCenter), reason: 'unknown until the aircraft answers');
r.calibration(<String, Object?>{
'support': <String, Object?>{'gravityCenter': false, 'rc': false, 'compassCount': 1},
});
expect(kinds(), isNot(contains(CalibrationKind.gravityCenter)));
expect(kinds(), isNot(contains(CalibrationKind.rc)));
expect(kinds(), contains(CalibrationKind.compass));
});
test('interchangeable-lens cameras get lens calibration and sensor cleaning; Spark has no RC wizard', () {
final Rig x5s = Rig(profile: withLens(inspire2X5s));
final List<CalibrationKind> k = CalibrationKinds.available(x5s.model.profile, x5s.model.calibration);
expect(k, containsAll(<CalibrationKind>[CalibrationKind.lens, CalibrationKind.sensorCleaning]));
final Rig spark = Rig(profile: sparkWifi);
expect(CalibrationKinds.available(spark.model.profile, spark.model.calibration), isNot(contains(CalibrationKind.rc)));
});
});
group('SafetyGate', () {
test('ground work is refused in the air and with the motors running', () {
final Rig r = Rig(flying: true);
final SafetyGate gate = SafetyGate(model: r.model, dji: FakeDjiService());
for (final CalibrationKind k in <CalibrationKind>[
CalibrationKind.compass,
CalibrationKind.imu,
CalibrationKind.rc,
CalibrationKind.gimbal,
CalibrationKind.lens,
CalibrationKind.sensorCleaning,
CalibrationKind.propeller,
]) {
expect(gate.checkMaintenance(k).where((GateCheck c) => c.blocks).map((GateCheck c) => c.message), contains('Land first.'), reason: k.name);
}
r.tick(<String, Object?>{'isFlying': false, 'areMotorsOn': true});
expect(gate.checkMaintenance(CalibrationKind.compass).single.message, 'Stop the motors first.');
r.tick(<String, Object?>{'areMotorsOn': false});
expect(gate.checkMaintenance(CalibrationKind.compass), isEmpty);
});
test('center of gravity is the exception: it needs a hover', () {
final Rig ground = Rig();
final SafetyGate g1 = SafetyGate(model: ground.model, dji: FakeDjiService());
expect(g1.checkMaintenance(CalibrationKind.gravityCenter).any((GateCheck c) => c.blocks), isTrue);
final Rig air = Rig(flying: true);
final SafetyGate g2 = SafetyGate(model: air.model, dji: FakeDjiService());
expect(g2.checkMaintenance(CalibrationKind.gravityCenter).any((GateCheck c) => c.blocks), isFalse);
expect(g2.checkMaintenance(CalibrationKind.gimbalAdjust), isEmpty, reason: 'trim is fine in flight');
});
test('runMaintenance re-checks and refuses without sending', () async {
final Rig r = Rig(flying: true);
final FakeDjiService dji = FakeDjiService();
final SafetyGate gate = SafetyGate(model: r.model, dji: dji);
await expectLater(gate.runMaintenance(CalibrationKind.imu, dji.startIMUCalibration), throwsA(isA<MaintenanceRefused>()));
expect(dji.calls, isEmpty);
});
test('a low battery and a cold IMU only warn', () {
final Rig r = Rig();
r.tick(<String, Object?>{
'isIMUPreheating': true,
'battery': <String, Object?>{'percent': 20},
});
final List<GateCheck> checks = SafetyGate(model: r.model, dji: FakeDjiService()).checkMaintenance(CalibrationKind.imu);
expect(checks, isNotEmpty);
expect(checks.any((GateCheck c) => c.blocks), isFalse);
});
});
group('Runs', () {
test('compass: a stale result never ends a new run; steps, then success', () {
final Rig r = Rig();
r.tick(<String, Object?>{'compassCalibrationState': 'SUCCESSFUL'});
final CompassRun run = CompassRun()..begin();
run.observe(r.model);
expect(run.phase, RunPhase.starting, reason: 'SUCCESSFUL from before the start');
r.tick(<String, Object?>{'compassCalibrating': true, 'compassCalibrationState': 'HORIZONTAL'});
run.observe(r.model);
expect((run.phase, run.step), (RunPhase.running, CompassStep.horizontal));
r.tick(<String, Object?>{'compassCalibrationState': 'VERTICAL'});
run.observe(r.model);
expect(run.step, CompassStep.vertical);
r.tick(<String, Object?>{'compassCalibrating': false, 'compassCalibrationState': 'SUCCESSFUL'});
run.observe(r.model);
expect(run.phase, RunPhase.succeeded);
});
test('compass: an end without a verdict counts by the error flag', () {
final Rig r = Rig();
final CompassRun run = CompassRun()..begin();
r.tick(<String, Object?>{'compassCalibrating': true, 'compassCalibrationState': 'VERTICAL'});
run.observe(r.model);
r.tick(<String, Object?>{'compassCalibrating': false, 'compassCalibrationState': 'NOT_CALIBRATING', 'compassHasError': true});
run.observe(r.model);
expect(run.phase, RunPhase.failed);
expect(run.failure, isNotNull);
});
test('IMU: multi-side sequence walks the sides in order and averages progress', () {
final Rig r = Rig();
final ImuRun run = ImuRun()..begin();
Map<String, Object?> unit(int i, String state, int progress) =>
<String, Object?>{'index': i, 'state': state, 'progress': progress, 'connected': true};
r.calibration(<String, Object?>{
'imu': <String, Object?>{
'units': <Object?>[unit(0, 'CALIBRATING', 20), unit(1, 'CALIBRATING', 40)],
'multiSide': true,
'orientationState': 'CALIBRATING',
'toCalibrate': <Object?>['NOSE_DOWN', 'LEFT_DOWN', 'BOTTOM_DOWN'],
'calibrated': <Object?>['BOTTOM_DOWN'],
},
});
run.observe(r.model);
expect(run.phase, RunPhase.running);
expect(run.progress, closeTo(0.3, 1e-9));
expect(run.orientation, 'NOSE_DOWN');
expect(run.sides, <(String, bool)>[('BOTTOM_DOWN', true), ('NOSE_DOWN', false), ('LEFT_DOWN', false)]);
r.calibration(<String, Object?>{
'imu': <String, Object?>{
'units': <Object?>[unit(0, 'SUCCESSFUL', 100), unit(1, 'SUCCESSFUL', 100)],
'multiSide': true,
'orientationState': 'COMPLETED',
},
});
run.observe(r.model);
expect(run.phase, RunPhase.succeeded);
});
test('IMU: one failed unit fails the run', () {
final Rig r = Rig();
final ImuRun run = ImuRun()..begin();
r.calibration(<String, Object?>{
'imu': <String, Object?>{
'units': <Object?>[<String, Object?>{'index': 0, 'state': 'CALIBRATING', 'progress': 10}],
},
});
run.observe(r.model);
r.calibration(<String, Object?>{
'imu': <String, Object?>{
'units': <Object?>[<String, Object?>{'index': 0, 'state': 'FAILED'}],
},
});
run.observe(r.model);
expect(run.phase, RunPhase.failed);
});
test('RC: MIDDLE → LIMITS → finish; a timeout fails', () {
final Rig r = Rig();
final RcRun run = RcRun()..begin();
r.calibration(<String, Object?>{'rc': <String, Object?>{'state': 'MIDDLE'}});
run.observe(r.model);
expect((run.phase, run.step), (RunPhase.running, RcStep.middle));
r.calibration(<String, Object?>{'rc': <String, Object?>{'state': 'LIMITS'}});
run.observe(r.model);
expect(run.step, RcStep.limits);
run
..finishing()
..observe(r.model);
expect(run.phase, RunPhase.succeeded);
run.begin();
r.calibration(<String, Object?>{'rc': <String, Object?>{'state': 'MIDDLE'}});
run.observe(r.model);
r.calibration(<String, Object?>{'rc': <String, Object?>{'state': 'TIMEOUT'}});
run.observe(r.model);
expect(run.phase, RunPhase.failed);
});
test('sensor cleaning ends well only after leaving the mode', () {
final Rig r = Rig();
final SensorCleaningRun run = SensorCleaningRun()..begin();
for (final String s in <String>['INITIATING', 'WAITING_FOR_LENS_REMOVAL', 'READY', 'CLEANING', 'WAITING_FOR_LENS_REMOUNT']) {
r.calibration(<String, Object?>{'sensorCleaning': s});
run.observe(r.model);
expect(run.phase, RunPhase.running, reason: s);
}
expect(run.step, CleaningStep.remountLens);
run.exiting();
run.observe(r.model);
expect(run.phase, RunPhase.running, reason: 'still in the mode');
r.calibration(<String, Object?>{'sensorCleaning': 'IDLE'});
run.observe(r.model);
expect(run.phase, RunPhase.succeeded);
});
test('lens and center of gravity read their verdicts', () {
final Rig r = Rig();
final LensRun lens = LensRun()..begin();
r.calibration(<String, Object?>{'lens': <String, Object?>{'state': 'CALIBRATING', 'result': 'IDLE'}});
lens.observe(r.model);
r.calibration(<String, Object?>{'lens': <String, Object?>{'state': 'CALIBRATED', 'result': 'DATA_ERROR'}});
lens.observe(r.model);
expect(lens.phase, RunPhase.failed);
final GravityCenterRun gc = GravityCenterRun()..begin();
r.calibration(<String, Object?>{'gravityCenter': <String, Object?>{'state': 'CALCULATING'}});
gc.observe(r.model);
r.calibration(<String, Object?>{'gravityCenter': <String, Object?>{'state': 'SUCCESSFUL'}});
gc.observe(r.model);
expect(gc.phase, RunPhase.succeeded);
});
});
group('Wizards', () {
// The compass illustration spins forever, so pumpAndSettle never would.
Future<void> settle(WidgetTester tester) async {
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
}
Future<FakeDjiService> open(WidgetTester tester, Rig r, CalibrationKind kind) async {
tester.view.physicalSize = const Size(844, 390);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final FakeDjiService dji = FakeDjiService();
await tester.pumpWidget(MaterialApp(
home: Builder(
builder: (BuildContext context) => Scaffold(
body: Center(
child: TextButton(
onPressed: () => openCalibration(context, kind, model: r.model, dji: dji, onError: (_, __) {}),
child: const Text('open'),
),
),
),
),
));
await tester.tap(find.text('open'));
await settle(tester);
return dji;
}
String heading(WidgetTester tester) => tester.widget<Text>(find.byKey(const ValueKey<String>('wizard-heading'))).data!;
testWidgets('compass: start, the two steps follow telemetry, done', (WidgetTester tester) async {
final Rig r = Rig();
final FakeDjiService dji = await open(tester, r, CalibrationKind.compass);
expect(heading(tester), 'Calibrate the compass');
await tester.tap(find.byKey(const ValueKey<String>('wizard-start')));
await tester.pump();
expect(dji.calls, <String>['startCompassCalibration']);
expect(heading(tester), 'Starting…');
r.tick(<String, Object?>{'compassCalibrating': true, 'compassCalibrationState': 'HORIZONTAL'});
await tester.pump();
expect(find.text('STEP 1 OF 2'), findsOneWidget);
r.tick(<String, Object?>{'compassCalibrationState': 'VERTICAL'});
await tester.pump();
expect(find.text('STEP 2 OF 2'), findsOneWidget);
r.tick(<String, Object?>{'compassCalibrating': false, 'compassCalibrationState': 'SUCCESSFUL'});
await tester.pump();
expect(heading(tester), 'Compass calibrated');
await tester.tap(find.byKey(const ValueKey<String>('wizard-done')));
await settle(tester);
expect(find.byType(CompassWizard), findsNothing);
});
testWidgets('compass: closing mid-run asks, then stops the calibration', (WidgetTester tester) async {
final Rig r = Rig();
final FakeDjiService dji = await open(tester, r, CalibrationKind.compass);
await tester.tap(find.byKey(const ValueKey<String>('wizard-start')));
await tester.pump();
r.tick(<String, Object?>{'compassCalibrating': true, 'compassCalibrationState': 'HORIZONTAL'});
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('wizard-close')));
await settle(tester);
await tester.tap(find.byKey(const ValueKey<String>('wizard-confirm-stop')));
await settle(tester);
expect(dji.calls, contains('stopCompassCalibration'));
expect(find.byType(CompassWizard), findsNothing);
});
testWidgets('a blocked start says why and sends nothing', (WidgetTester tester) async {
final Rig r = Rig(flying: true);
final FakeDjiService dji = await open(tester, r, CalibrationKind.imu);
expect(find.text('Land first.'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey<String>('wizard-start')));
await tester.pump();
expect(dji.calls, isEmpty);
});
testWidgets('IMU: shows the side to put down; leaving does not try to stop it', (WidgetTester tester) async {
final Rig r = Rig();
final FakeDjiService dji = await open(tester, r, CalibrationKind.imu);
await tester.tap(find.byKey(const ValueKey<String>('wizard-start')));
await tester.pump();
r.calibration(<String, Object?>{
'imu': <String, Object?>{
'units': <Object?>[<String, Object?>{'index': 0, 'state': 'CALIBRATING', 'progress': 50}],
'multiSide': true,
'toCalibrate': <Object?>['NOSE_DOWN', 'TAIL_DOWN'],
'calibrated': <Object?>['BOTTOM_DOWN'],
},
});
await tester.pump();
expect(heading(tester), 'Put it nose down');
expect(find.text('SIDE 2 OF 3'), findsOneWidget);
expect(find.byKey(const ValueKey<String>('imu-side-TAIL_DOWN')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey<String>('wizard-close')));
await tester.pumpAndSettle();
expect(find.byType(ImuWizard), findsNothing, reason: 'no stop to confirm');
expect(dji.calls, <String>['startIMUCalibration']);
});
testWidgets('RC: watches the sticks, steps MIDDLE → LIMITS → QUIT, unwatches on the way out', (WidgetTester tester) async {
final Rig r = Rig();
final FakeDjiService dji = await open(tester, r, CalibrationKind.rc);
expect(dji.calls, <String>['watchRcHardware(true)']);
await tester.tap(find.byKey(const ValueKey<String>('wizard-start')));
await tester.pump();
expect(dji.calls.last, 'setRcCalibrationStep(MIDDLE)');
r.calibration(<String, Object?>{'rc': <String, Object?>{'state': 'MIDDLE'}});
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('wizard-next')));
await tester.pump();
expect(dji.calls.last, 'setRcCalibrationStep(LIMITS)');
r.calibration(<String, Object?>{
'rc': <String, Object?>{'state': 'LIMITS'},
'rcHardware': <String, Object?>{'lx': 660, 'ly': 660, 'rx': -660, 'ry': -660},
});
await tester.pump();
expect(heading(tester), 'Move every stick and dial to its limits');
await tester.tap(find.byKey(const ValueKey<String>('wizard-finish')));
await tester.pump();
expect(dji.calls.last, 'setRcCalibrationStep(QUIT)');
expect(heading(tester), 'Remote controller calibrated');
await tester.tap(find.byKey(const ValueKey<String>('wizard-done')));
await tester.pumpAndSettle();
expect(dji.calls.last, 'watchRcHardware(false)');
});
testWidgets('sensor cleaning: the buttons follow the camera, Stop leaves the mode', (WidgetTester tester) async {
final Rig r = Rig(profile: withLens(inspire2X5s));
final FakeDjiService dji = await open(tester, r, CalibrationKind.sensorCleaning);
await tester.tap(find.byKey(const ValueKey<String>('wizard-start')));
await tester.pump();
r.calibration(<String, Object?>{'sensorCleaning': 'READY'});
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('wizard-clean')));
await tester.pump();
expect(dji.calls, <String>['initSensorCleaning', 'startSensorCleaning']);
await tester.tap(find.byKey(const ValueKey<String>('wizard-stop')));
await tester.pump();
expect(dji.calls.last, 'exitSensorCleaning');
expect(heading(tester), 'Clean the image sensor', reason: 'back at the start');
});
testWidgets('the hub lists the offered wizards and opens one', (WidgetTester tester) async {
tester.view.physicalSize = const Size(844, 390);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final Rig r = Rig();
r.calibration(<String, Object?>{
'support': <String, Object?>{'gravityCenter': false},
});
await tester.pumpWidget(MaterialApp(home: CalibrationHubPage(model: r.model, dji: FakeDjiService(), onError: (_, __) {})));
expect(find.byKey(const ValueKey<String>('hub-compass')), findsOneWidget);
expect(find.byKey(const ValueKey<String>('hub-gravityCenter')), findsNothing);
expect(find.byKey(const ValueKey<String>('hub-lens')), findsNothing);
await tester.tap(find.byKey(const ValueKey<String>('hub-imu')));
await tester.pumpAndSettle();
expect(find.byType(ImuWizard), findsOneWidget);
});
testWidgets('Aircraft Status: Calibrate opens the compass wizard instead of starting at once', (WidgetTester tester) async {
tester.view.physicalSize = const Size(844, 390);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final Rig r = Rig();
final FakeDjiService dji = FakeDjiService();
await tester.pumpWidget(MaterialApp(
home: Builder(
builder: (BuildContext context) => TextButton(
onPressed: () => showAircraftStatusSheet(context, model: r.model, dji: dji, onError: (_, __) {}),
child: const Text('status'),
),
),
));
await tester.tap(find.text('status'));
await settle(tester);
final Finder calibrate = find.byKey(const ValueKey<String>('status-action-compass'));
await tester.scrollUntilVisible(calibrate, 60, scrollable: find.byType(Scrollable).last);
await tester.tap(calibrate);
await settle(tester);
expect(find.byType(CompassWizard), findsOneWidget);
expect(dji.calls, isNot(contains('startCompassCalibration')));
});
testWidgets('gimbal adjust lays the horizon over the screen and nudges each axis', (WidgetTester tester) async {
final Rig r = Rig(profile: inspire2X5s);
final FakeDjiService dji = await open(tester, r, CalibrationKind.gimbalAdjust);
expect(find.byType(GimbalAdjustOverlay), findsOneWidget);
expect(find.text('open'), findsOneWidget, reason: 'the page underneath stays visible');
await tester.tap(find.byKey(const ValueKey<String>('step-adjustRoll-plus')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('step-adjustPitch-minus')));
await tester.pump();
expect(dji.calls, <String>['fineTuneGimbal(roll, 0.1)', 'fineTuneGimbal(pitch, -0.1)']);
await tester.tap(find.byKey(const ValueKey<String>('adjust-done')));
await tester.pumpAndSettle();
expect(find.byType(GimbalAdjustOverlay), findsNothing);
});
});
}
+28
View File
@@ -37,6 +37,34 @@ class FakeDjiService extends DjiService {
Future<void> stopActiveTrack() => _record('stopActiveTrack');
@override
Future<void> startCompassCalibration() => _record('startCompassCalibration');
// ── Calibration (plan P6b) ─────────────────────────────────────────────────
@override
Future<void> stopCompassCalibration() => _record('stopCompassCalibration');
@override
Future<void> startIMUCalibration({int? index}) => _record('startIMUCalibration');
@override
Future<void> startGravityCenterCalibration() => _record('startGravityCenterCalibration');
@override
Future<void> stopGravityCenterCalibration() => _record('stopGravityCenterCalibration');
@override
Future<void> startPropellerCalibration() => _record('startPropellerCalibration');
@override
Future<void> stopPropellerCalibration() => _record('stopPropellerCalibration');
@override
Future<void> startLensCalibration() => _record('startLensCalibration');
@override
Future<void> stopLensCalibration() => _record('stopLensCalibration');
@override
Future<void> initSensorCleaning() => _record('initSensorCleaning');
@override
Future<void> startSensorCleaning() => _record('startSensorCleaning');
@override
Future<void> exitSensorCleaning() => _record('exitSensorCleaning');
@override
Future<void> setRcCalibrationStep(String step) => _record('setRcCalibrationStep($step)');
@override
Future<void> watchRcHardware(bool on) async => calls.add('watchRcHardware($on)');
@override
Future<void> formatStorage({String location = 'SDCARD'}) => _record('formatStorage');
@override
+7 -8
View File
@@ -6,10 +6,10 @@ import 'package:pilotvault_fly/fly/gimbal_control.dart';
import 'package:pilotvault_fly/fly/hud_features.dart';
import 'package:pilotvault_fly/fly/product_profile.dart';
import 'package:pilotvault_fly/fly/telemetry_reducer.dart';
import 'package:pilotvault_fly/ui/fly/dialogs/slide_to_confirm.dart';
import 'package:pilotvault_fly/ui/fly/fly_screen.dart';
import 'package:pilotvault_fly/ui/fly/panels/camera_write.dart';
import 'package:pilotvault_fly/ui/fly/panels/gimbal_sheet.dart';
import 'package:pilotvault_fly/ui/fly/panels/settings/calibration/wizards.dart';
import 'fake_dji.dart';
import 'profile_fixtures.dart';
@@ -250,7 +250,7 @@ void main() {
expect(dji.calls, containsAllInOrder(<String>['fineTuneGimbal(roll, 0.1)', 'fineTuneGimbal(roll, -0.1)']));
});
testWidgets('calibration is blocked in flight and slide-confirmed on the ground', (WidgetTester tester) async {
testWidgets('calibration is blocked in flight and runs through its wizard on the ground', (WidgetTester tester) async {
final FlightModel m = gimbalModel(phantom4);
final FakeDjiService dji = await pump(tester, m);
await openSheet(tester);
@@ -260,8 +260,8 @@ void main() {
await tester.pump();
expect(find.text('Land and stop the motors first'), findsOneWidget);
await tester.tap(calibrate);
await tester.pump();
expect(find.byType(SlideConfirmDialog), findsNothing);
await tester.pumpAndSettle();
expect(find.byType(GimbalCalibrationWizard), findsNothing);
m.aircraft
..isFlying = false
@@ -269,12 +269,11 @@ void main() {
..notifyListeners();
await tester.pump();
await tester.tap(calibrate);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
expect(find.byType(GimbalCalibrationWizard), findsOneWidget);
expect(dji.calls, isNot(contains('startGimbalCalibration')));
await tester.drag(find.byKey(SlideToConfirmTrack.thumbKey), const Offset(600, 0));
await tester.tap(find.byKey(const ValueKey<String>('wizard-start')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(dji.calls, contains('startGimbalCalibration'));
});