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>
324 lines
14 KiB
Dart
324 lines
14 KiB
Dart
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';
|
||
import 'state/alert_state.dart';
|
||
|
||
export 'action_rail_state.dart' show FlightCommand;
|
||
|
||
/// How hard a precondition stops a command.
|
||
enum CheckLevel {
|
||
/// Shown on the confirm dialog; the pilot may still slide.
|
||
warn,
|
||
|
||
/// The command is refused; the dialog says why and has no slider.
|
||
block,
|
||
}
|
||
|
||
@immutable
|
||
class GateCheck {
|
||
const GateCheck(this.level, this.message);
|
||
|
||
const GateCheck.warn(this.message) : level = CheckLevel.warn;
|
||
const GateCheck.block(this.message) : level = CheckLevel.block;
|
||
|
||
final CheckLevel level;
|
||
final String message;
|
||
|
||
bool get blocks => level == CheckLevel.block;
|
||
|
||
@override
|
||
bool operator ==(Object other) => other is GateCheck && other.level == level && other.message == message;
|
||
|
||
@override
|
||
int get hashCode => Object.hash(level, message);
|
||
|
||
@override
|
||
String toString() => '${level.name}: $message';
|
||
}
|
||
|
||
/// What [SafetyGate.check] found for one command.
|
||
@immutable
|
||
class GateVerdict {
|
||
const GateVerdict(this.command, this.checks);
|
||
|
||
final FlightCommand command;
|
||
final List<GateCheck> checks;
|
||
|
||
bool get allowed => !checks.any((GateCheck c) => c.blocks);
|
||
List<GateCheck> get blockers => <GateCheck>[for (final GateCheck c in checks) if (c.blocks) c];
|
||
List<GateCheck> get warnings => <GateCheck>[for (final GateCheck c in checks) if (!c.blocks) c];
|
||
}
|
||
|
||
/// Thrown by [SafetyGate.run] when a command's preconditions fail at the
|
||
/// moment it would be sent (the state may have moved on since the dialog).
|
||
class SafetyGateRefused implements Exception {
|
||
const SafetyGateRefused(this.verdict);
|
||
|
||
final GateVerdict verdict;
|
||
|
||
@override
|
||
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,
|
||
/// live faults — into blockers and warnings for the slide-to-confirm dialog;
|
||
/// [run] re-checks and then sends the command. The UI never calls the
|
||
/// `DjiService` flight commands directly, so this class is what the safety
|
||
/// tests pin down.
|
||
class SafetyGate {
|
||
SafetyGate({required this.model, required this.dji});
|
||
|
||
final FlightModel model;
|
||
final DjiService dji;
|
||
|
||
/// RTH altitude limits the flight controller accepts (MSDK `setGoHomeHeightInMeters`).
|
||
static const int minRthHeight = 20;
|
||
static const int maxRthHeight = 500;
|
||
|
||
/// Below this RTH altitude the dialog warns about obstacles on the way home.
|
||
static const int lowRthHeight = 30;
|
||
|
||
/// How far the pilot must be from the home point before RTH asks where to go.
|
||
static const double rthTargetChoiceDistance = 30;
|
||
|
||
AircraftState get _a => model.aircraft;
|
||
|
||
GateVerdict check(FlightCommand command) {
|
||
final List<GateCheck> checks = <GateCheck>[];
|
||
if (!model.connected) {
|
||
checks.add(const GateCheck.block('No aircraft connected.'));
|
||
return GateVerdict(command, checks);
|
||
}
|
||
if (_a.updatedAt == null) {
|
||
checks.add(const GateCheck.block('Waiting for the flight controller to report.'));
|
||
return GateVerdict(command, checks);
|
||
}
|
||
switch (command) {
|
||
case FlightCommand.takeOff:
|
||
_takeOff(checks);
|
||
case FlightCommand.land:
|
||
if (_a.isFlying != true) checks.add(const GateCheck.block('The aircraft is not flying.'));
|
||
if (_a.goingHome == true) checks.add(const GateCheck.warn('This ends Return to Home and lands where the aircraft is now.'));
|
||
case FlightCommand.cancelLanding:
|
||
if (_a.flightModeEnum != 'AUTO_LANDING') checks.add(const GateCheck.block('The aircraft is not landing.'));
|
||
case FlightCommand.confirmLanding:
|
||
if (_a.landingConfirmationNeeded != true && _a.flightModeEnum != 'CONFIRM_LANDING') {
|
||
checks.add(const GateCheck.block('The aircraft is not waiting for a landing confirmation.'));
|
||
}
|
||
case FlightCommand.goHome:
|
||
_goHome(checks);
|
||
case FlightCommand.cancelGoHome:
|
||
if (_a.goingHome != true && _a.flightModeEnum != 'GO_HOME') {
|
||
checks.add(const GateCheck.block('The aircraft is not returning to home.'));
|
||
}
|
||
case FlightCommand.stopMode:
|
||
switch (RailState.activeMode(_a, model.mission)) {
|
||
case ActiveMode.none:
|
||
checks.add(const GateCheck.block('No intelligent flight mode is running.'));
|
||
case ActiveMode.other:
|
||
checks.add(const GateCheck.block('This mode cannot be stopped from the app yet. Press the pause button on the remote.'));
|
||
case ActiveMode.waypoint:
|
||
case ActiveMode.activeTrack:
|
||
break;
|
||
}
|
||
}
|
||
return GateVerdict(command, checks);
|
||
}
|
||
|
||
void _takeOff(List<GateCheck> checks) {
|
||
if (_a.isFlying == true) {
|
||
checks.add(const GateCheck.block('The aircraft is already flying.'));
|
||
return;
|
||
}
|
||
final int? percent = model.battery.percent;
|
||
final int critical = model.battery.seriousLowWarningThreshold ?? 15;
|
||
final int low = model.battery.lowWarningThreshold ?? 30;
|
||
if (_a.seriousLowBattery == true || (percent != null && percent <= critical)) {
|
||
checks.add(GateCheck.block('Battery critically low${percent == null ? '' : ' ($percent%)'}. Replace or charge it.'));
|
||
} else if (_a.lowBattery == true || (percent != null && percent <= low)) {
|
||
checks.add(GateCheck.warn('Battery low${percent == null ? '' : ' ($percent%)'}: a short flight only.'));
|
||
}
|
||
if (_a.imuPreheating == true) checks.add(const GateCheck.block('IMU is warming up. Wait until it is ready.'));
|
||
if (_a.compassCalibrating == true) checks.add(const GateCheck.block('Compass calibration is in progress.'));
|
||
if (_a.compassHasError == true) checks.add(const GateCheck.block('Compass error. Calibrate the compass or move away from metal.'));
|
||
// Faults the aircraft itself reports: critical ones refuse, the worst warning is shown.
|
||
final Iterable<DiagnosticAlert> faults = model.alerts.all.where((DiagnosticAlert d) => d.severity == AlertSeverity.critical);
|
||
for (final DiagnosticAlert d in faults.take(2)) {
|
||
checks.add(GateCheck.block(d.reason));
|
||
}
|
||
final bool gps = (_a.gpsSignalLevel ?? 0) >= FlightStatus.minGpsLevel;
|
||
if (!gps) {
|
||
checks.add(GateCheck.warn(_a.visionPositioningBeingUsed == true
|
||
? 'Weak GPS: the aircraft holds position by vision only, low over textured ground.'
|
||
: 'Weak GPS: the aircraft will fly in ATTI mode and drift with the wind.'));
|
||
}
|
||
if (_a.homeLocationSet != true) {
|
||
checks.add(const GateCheck.warn('No home point recorded yet: Return to Home will not be available.'));
|
||
}
|
||
for (final DiagnosticAlert d in model.alerts.all.where((DiagnosticAlert d) => d.severity == AlertSeverity.warning).take(1)) {
|
||
checks.add(GateCheck.warn(d.reason));
|
||
}
|
||
if (model.profile.hasCamera && model.camera.storage != null && !model.camera.storage!.usable) {
|
||
checks.add(GateCheck.warn('${model.camera.storage!.status}: photos and video will not be saved.'));
|
||
}
|
||
}
|
||
|
||
void _goHome(List<GateCheck> checks) {
|
||
if (_a.isFlying != true) {
|
||
checks.add(const GateCheck.block('The aircraft is not flying.'));
|
||
return;
|
||
}
|
||
if (_a.goingHome == true) {
|
||
checks.add(const GateCheck.block('The aircraft is already returning to home.'));
|
||
return;
|
||
}
|
||
if (_a.homeLocationSet != true) {
|
||
checks.add(const GateCheck.block('No home point recorded. Fly back manually.'));
|
||
}
|
||
if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel) {
|
||
checks.add(const GateCheck.block('GPS too weak for Return to Home. Fly back manually.'));
|
||
}
|
||
final int? percent = model.battery.percent;
|
||
final int? needed = _a.goHome?.batteryNeededToGoHome;
|
||
if (percent != null && needed != null && needed > 0 && percent < needed) {
|
||
checks.add(GateCheck.warn('Battery ($percent%) is below what the trip home needs ($needed%). It may land on the way.'));
|
||
}
|
||
final double? h = _a.goHomeHeight;
|
||
if (h != null && h < lowRthHeight && !model.profile.hasObstacleSensing) {
|
||
checks.add(GateCheck.warn('RTH altitude is ${h.round()} m and this aircraft cannot see obstacles.'));
|
||
}
|
||
}
|
||
|
||
// ── 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;
|
||
|
||
/// Metres between the pilot (the phone's fix) and the recorded home point.
|
||
double? get pilotToHome {
|
||
final double? pLat = model.phoneLatitude, pLon = model.phoneLongitude;
|
||
final double? hLat = _a.homeLatitude, hLon = _a.homeLongitude;
|
||
if (pLat == null || pLon == null || hLat == null || hLon == null) return null;
|
||
return HudGeometry.distance(pLat, pLon, hLat, hLon);
|
||
}
|
||
|
||
/// Answers the flight controller's low-battery smart-RTH countdown: true
|
||
/// flies home now, false cancels it. Ignored once the countdown has ended.
|
||
Future<void> answerSmartRth({required bool goHome}) async {
|
||
if (_a.goHome?.smartRTHCountingDown != true) return;
|
||
await dji.confirmSmartRTH(goHome);
|
||
}
|
||
|
||
/// Re-checks [command] and sends it. For take-off, [precision] asks for a
|
||
/// precision take-off; for RTH, [rthHeight] is written first when it differs
|
||
/// from the current setting and [homeToPilot] moves the home point to the
|
||
/// phone's position.
|
||
Future<void> run(FlightCommand command, {bool precision = false, int? rthHeight, bool homeToPilot = false}) async {
|
||
final GateVerdict v = check(command);
|
||
if (!v.allowed) throw SafetyGateRefused(v);
|
||
switch (command) {
|
||
case FlightCommand.takeOff:
|
||
await (precision ? dji.precisionTakeOff() : dji.takeOff());
|
||
case FlightCommand.land:
|
||
await dji.land();
|
||
case FlightCommand.cancelLanding:
|
||
await dji.cancelLanding();
|
||
case FlightCommand.confirmLanding:
|
||
await dji.confirmLanding();
|
||
case FlightCommand.goHome:
|
||
if (homeToPilot && model.phoneLatitude != null && model.phoneLongitude != null) {
|
||
await dji.setHomeLocation(model.phoneLatitude!, model.phoneLongitude!);
|
||
}
|
||
if (rthHeight != null) {
|
||
final int h = rthHeight.clamp(minRthHeight, maxRthHeight);
|
||
if (h != _a.goHomeHeight?.round()) await dji.setGoHomeHeight(h);
|
||
}
|
||
await dji.startGoHome();
|
||
case FlightCommand.cancelGoHome:
|
||
await dji.cancelGoHome();
|
||
case FlightCommand.stopMode:
|
||
switch (RailState.activeMode(_a, model.mission)) {
|
||
case ActiveMode.waypoint:
|
||
await dji.stopWaypointMission();
|
||
case ActiveMode.activeTrack:
|
||
await dji.stopActiveTrack();
|
||
case ActiveMode.none:
|
||
case ActiveMode.other:
|
||
break; // refused by check()
|
||
}
|
||
}
|
||
}
|
||
}
|