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>
361 lines
14 KiB
Dart
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) {}
|
|
}
|