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:
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))
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ────────────────────────────────────────
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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>[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user