Files
PilotVault/Fly App/lib/fly/calibration.dart
T
tajniak81andClaude Opus 5.5 a892bfb209 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>
2026-10-19 21:41:19 +02:00

361 lines
14 KiB
Dart

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) {}
}