Files
PilotVault/Fly App/lib/fly/safety_gate.dart
T
tajniak81andClaude Opus 5.5 6ebc567e97 Put every flight command behind a SafetyGate
Take-off, land, RTH, the landing cancel/confirm and Stop now all go
through one SafetyGate (lib/fly/safety_gate.dart). The gate sorts its
preconditions into blockers and warnings: connection, flight state,
battery against the thresholds, IMU warm-up, compass, critical
diagnostics, GPS, home point, SD card and the RTH battery budget. It
checks again right before sending.

The left rail is now a state machine (lib/fly/action_rail_state.dart):
Take-off becomes Land, then Cancel or Confirm landing. RTH becomes
Cancel RTH. A Stop button shows while an intelligent mode is flying.
Moving the aircraft needs a slide-to-confirm dialog, which can offer
Precision Takeoff or set the RTH altitude. A blocked command shows why
and has no slider. When the pilot is more than 30 m from home, RTH asks
whether to return to them or to the original home point.

The HUD gains an error stack. Its cards can be dismissed, it fits the
space it has, and a new warning triggers haptic feedback. It also gets a
smart-RTH countdown banner with Cancel / Go home now, a forced-landing
banner, and an Aircraft Status sheet. The sheet opens from the status
chip and appears before each connection's first take-off. It offers
Calibrate compass/IMU and Format SD.

On the bridge side this adds precisionTakeOff, setHomeLocation, compass
and IMU calibration start/stop, formatStorage, and compass health in the
telemetry tick.

The classic flight screen stays available until the simulator run passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 19:24:17 +02:00

258 lines
11 KiB
Dart

import 'package:flutter/foundation.dart';
import '../dji_service.dart';
import '../flight_model.dart';
import 'action_rail_state.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(' ');
}
/// 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.'));
}
}
/// 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()
}
}
}
}