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 available(ProductProfile p, CalibrationState c) => [ 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 order = ['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 units = [for (final ImuUnit u in c.imuUnits) if (u.connected != false) u]; final List p = [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 done = c.imuCalibrated.toSet(); final Set wanted = {...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) {} }