Files
PilotVault/Fly App/lib/fly/safety_gate.dart
T
tajniak81andClaude Opus 5.5 43a66df8df Give the Fly App GO 4-style intelligent modes and a Waypoints editor
P7a of the GO 4-parity plan. The rail's GRID button becomes MODES, which
opens a picker of the product's intelligent flight modes with first-use
terms; Waypoints is the first one built.

Waypoints opens on the big map: tap to add waypoints or POIs, long-press
to drag, "+" to insert on a leg, and a point editor for altitude, speed,
heading (route / custom / POI), gimbal (set or aim at POI), camera action
and hover, and turn radius, with a swipe-to-adjust readout. Route settings,
a local mission library and undo sit in a tool column. Start runs the
SafetyGate's pre-start check in the slide-to-confirm dialog, then uploads
and starts; a run panel shows upload and flight progress with Pause,
Resume and Stop.

MissionBridge now listens to the waypoint operator and builds the full V1
mission (per-waypoint headings, turn direction, gimbal pitch, actions).
Not yet run on a simulator or aircraft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:22:53 +02:00

479 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart' show LatLng;
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 'modes/waypoint_mission.dart';
import 'state/aircraft_state.dart';
import 'state/alert_state.dart';
import 'state/mission_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(' ');
}
/// Thrown by the mission methods of [SafetyGate] when a mission may not start or go on.
class MissionRefused implements Exception {
const MissionRefused(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();
}
// ── Intelligent flight modes (plan P7) ──────────────────────────────────────
/// Beyond this the pre-start check points out the trip to waypoint 1.
static const double farFirstWaypoint = 500;
/// The aircraft's own limits for [WaypointMissionSpec.validate], read from
/// the flight controller (they are not in the telemetry tick).
Future<MissionLimits> missionLimits() async {
Future<Object?> read(String key) async {
if (!model.connected) return null;
try {
return await dji.getKey(DjiKey.flightController(key));
} catch (_) {
return null;
}
}
final List<Object?> v = await Future.wait(<Future<Object?>>[
read('MAX_FLIGHT_HEIGHT'),
read('MAX_FLIGHT_RADIUS'),
read('MAX_FLIGHT_RADIUS_ENABLED'),
]);
final double? lat = _a.homeLatitude, lon = _a.homeLongitude;
final bool radiusOn = v[2] == true;
return MissionLimits(
maxAltitude: (v[0] as num?)?.toDouble(),
maxRadius: radiusOn ? (v[1] as num?)?.toDouble() : null,
home: lat == null || lon == null || _a.homeLocationSet == false ? null : LatLng(lat, lon),
gimbalMin: model.profile.gimbal?.pitch?.min ?? -90,
gimbalMax: model.profile.gimbal?.pitch?.max ?? 30,
);
}
/// Preconditions for flying [spec]: the aircraft's state, the operator's,
/// and the route's own [WaypointMissionSpec.validate] issues.
List<GateCheck> checkWaypointStart(WaypointMissionSpec spec, [MissionLimits limits = const MissionLimits()]) {
final List<GateCheck> checks = <GateCheck>[];
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.'));
final WaypointProgress op = model.mission.waypoint;
if (op.unsupported) {
checks.add(const GateCheck.block('This aircraft cannot fly waypoint missions.'));
} else if (op.active) {
checks.add(const GateCheck.block('A waypoint mission is already running.'));
} else if (op.uploading) {
checks.add(const GateCheck.block('A mission is still uploading.'));
} else if (op.state == 'DISCONNECTED' || op.state == 'RECOVERING') {
checks.add(const GateCheck.block('The mission system is not ready yet. Wait a moment.'));
}
if (model.mission.tracking) checks.add(const GateCheck.block('Stop ActiveTrack first.'));
for (final MissionIssue i in spec.validate(limits)) {
checks.add(i.blocks ? GateCheck.block(i.message) : GateCheck.warn(i.message));
}
final String mode = _a.flightModeEnum ?? '';
if (_a.goingHome == true || mode == 'GO_HOME') checks.add(const GateCheck.block('Cancel Return to Home first.'));
if (mode == 'AUTO_LANDING' || mode == 'CONFIRM_LANDING') checks.add(const GateCheck.block('The aircraft is landing.'));
if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel) {
checks.add(const GateCheck.block('GPS too weak for a waypoint mission.'));
}
if (_a.homeLocationSet != true) checks.add(const GateCheck.block('No home point recorded yet.'));
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%)'}.'));
} else if (_a.lowBattery == true || (percent != null && percent <= low)) {
checks.add(GateCheck.warn('Battery low${percent == null ? '' : ' ($percent%)'}: the route may not finish.'));
}
final int? left = _a.goHome?.remainingFlightTime;
final int needed = spec.estimatedSeconds + (_a.goHome?.timeNeededToGoHome ?? 0);
if (left != null && left > 0 && spec.points.length >= 2 && needed > left) {
checks.add(GateCheck.warn('The route takes about ${formatDuration(spec.estimatedSeconds)}; '
'the battery has about ${formatDuration(left)} of flight left.'));
}
if (_a.isFlying != true) {
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.'));
for (final DiagnosticAlert d in model.alerts.all.where((DiagnosticAlert d) => d.severity == AlertSeverity.critical).take(2)) {
checks.add(GateCheck.block(d.reason));
}
checks.add(const GateCheck.warn('The aircraft takes off and flies to waypoint 1 on its own.'));
}
if (_a.compassHasError == true) checks.add(const GateCheck.block('Compass error. Calibrate the compass or move away from metal.'));
final double? lat = _a.latitude, lon = _a.longitude;
if (spec.points.isNotEmpty && lat != null && lon != null && !(lat == 0 && lon == 0)) {
final LatLng first = spec.points.first.at;
final double d = HudGeometry.distance(lat, lon, first.latitude, first.longitude);
if (d > farFirstWaypoint) checks.add(GateCheck.warn('Waypoint 1 is ${(d / 1000).toStringAsFixed(1)} km from the aircraft.'));
}
if (!model.profile.hasObstacleSensing && spec.points.any((WaypointSpec p) => p.altitude < lowRthHeight)) {
checks.add(const GateCheck.warn('Parts of the route are low and this aircraft cannot see obstacles.'));
}
final bool shoots = spec.points.any((WaypointSpec p) => p.action != WaypointCameraAction.none);
if (shoots && model.camera.storage != null && !model.camera.storage!.usable) {
checks.add(GateCheck.warn("${model.camera.storage!.status}: the route's photos and video will not be saved."));
}
return checks;
}
/// Re-checks [spec], writes [rthHeight] when it changed, uploads the
/// mission and starts it. [onUploaded] runs between the two.
Future<void> startWaypointMission(
WaypointMissionSpec spec, {
MissionLimits limits = const MissionLimits(),
int? rthHeight,
void Function()? onUploaded,
}) async {
final List<GateCheck> checks = checkWaypointStart(spec, limits);
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
if (rthHeight != null) {
final int h = rthHeight.clamp(minRthHeight, maxRthHeight);
if (h != _a.goHomeHeight?.round()) await dji.setGoHomeHeight(h);
}
await dji.uploadWaypointMission(spec.toBridge(gimbalMin: limits.gimbalMin, gimbalMax: limits.gimbalMax));
onUploaded?.call();
await dji.startWaypointMission();
}
/// Holds the aircraft where it is; the mission can resume from there.
Future<void> pauseWaypointMission() async {
if (!model.mission.waypoint.executing) {
throw const MissionRefused(<GateCheck>[GateCheck.block('No waypoint mission is flying.')]);
}
await dji.pauseWaypointMission();
}
Future<void> resumeWaypointMission() async {
final List<GateCheck> checks = <GateCheck>[
if (!model.mission.waypoint.paused) const GateCheck.block('The mission is not paused.'),
if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel) const GateCheck.block('GPS too weak to carry on with the route.'),
if (_a.goingHome == true) const GateCheck.block('The aircraft is returning to home.'),
];
if (checks.isNotEmpty) throw MissionRefused(checks);
await dji.resumeWaypointMission();
}
/// 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()
}
}
}
}