Files
PilotVault/Fly App/lib/fly/safety_gate.dart
T
tajniak81andClaude Opus 5.5 636da1ac2c Give the Fly App every remaining GO 4 flight mode and the missing settings
Intelligent modes: Point of Interest (orbit on the map, live radius and
speed), Follow Me (follows the phone), QuickShots on a boxed subject,
Course Lock / Home Lock, Tripod and Cinematic, a Free hyperlapse, and
Waypoints 2.0 for V2-only aircraft. ActiveTrack gains Spotlight Pro and
Circle. Every mode has a first-use tutorial and stops from the rail.
Waypoints can resume an interrupted mission, change cruise speed in
flight and check terrain clearance (Open-Meteo, on tap).

Settings: units, C1/C2 actions, dual-operator master/slave, HDMI/SDI
output, a link spectrum chart, Wi-Fi name and password, aircraft name,
video cache, battery warning history and pairing, non-smart cell
voltages. Calibration: compass interference meter, per-IMU calibration,
lens infinity focus and a first-manual-focus prompt. Also an RTK chip,
gimbal movement presets and motor tuning, Precision Takeoff gated on the
flight controller, and the precise GEO database's server version.

Built and unit/widget-tested only; nothing has flown yet.

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

1127 lines
57 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 'dart:async';
import 'dart:ui' show Offset, Rect;
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 'fly_prefs.dart';
import 'hud_geometry.dart';
import 'modes/orbit_modes.dart';
import 'modes/vision_modes.dart';
import 'modes/waypoint_mission.dart';
import 'product_profile.dart';
import 'state/aircraft_state.dart';
import 'state/alert_state.dart';
import 'state/fly_zone_state.dart';
import 'state/mission_state.dart';
import 'virtual_sticks.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:
case ActiveMode.tapFly:
case ActiveMode.hotpoint:
case ActiveMode.followMe:
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.'));
}
_flyZone(checks);
}
// ── Fly zones (plan P8) ─────────────────────────────────────────────────────
/// DJI's GEO system refuses take-off in restricted and locked authorization
/// zones on its own; saying so here keeps the slider from promising otherwise.
void _flyZone(List<GateCheck> checks) {
final FlyZoneState z = model.flyZones;
switch (z.state) {
case 'IN_RESTRICTED_ZONE':
case 'SUSPECTED_IN_RESTRICTED_ZONE':
checks.add(const GateCheck.block('The aircraft is in a restricted zone: take-off is not allowed here.'));
case 'IN_AUTHORIZED_ZONE':
case 'SUSPECTED_IN_AUTHORIZED_ZONE':
checks.add(const GateCheck.block('Authorization zone: unlock it first (Settings → Safety → Fly zones).'));
case 'IN_ENHANCE_WARNING_ZONE':
checks.add(const GateCheck.warn('Enhanced warning zone: check local rules before flying.'));
case 'IN_WARNING_ZONE':
checks.add(const GateCheck.warn('Warning zone: check local rules before flying.'));
case 'IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION':
case 'SUSPECTED_IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION':
checks.add(const GateCheck.warn('Height-limited zone: the aircraft will not climb above the zone\'s limit.'));
}
}
/// Switching or removing licences changes what the flight controller lets the
/// aircraft do, so it happens on the ground only.
List<GateCheck> checkLicenceChange() => <GateCheck>[
if (!model.connected) const GateCheck.block('Connect the aircraft to change its licences.'),
if (model.connected && _a.isFlying == true) const GateCheck.block('Land before changing licences.'),
];
Future<void> _licences(Future<void> Function() change) async {
final List<GateCheck> checks = checkLicenceChange();
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
await change();
try {
model.flyZones.applyLicences(await dji.getFlyZoneLicences());
} catch (_) {
// The list refreshes on the next connection.
}
}
/// Enables [licence] (the aircraft keeps one enabled at a time) or disables it.
Future<void> setLicenceEnabled(FlyZoneLicence licence, bool enabled) async {
if (enabled && licence.expired) throw const MissionRefused(<GateCheck>[GateCheck.block('This licence has expired.')]);
await _licences(() => dji.setFlyZoneLicenceEnabled(licence.id, enabled));
}
/// Fetches the DJI account's licences and writes them to the aircraft.
Future<void> syncLicences() => _licences(dji.syncFlyZoneLicences);
/// Removes every licence from the aircraft.
Future<void> deleteAllLicences() => _licences(dji.deleteAllFlyZoneLicences);
/// Downloads the basic GEO database; the database state refreshes when it ends.
Future<void> upgradeFlyZoneDatabase() async {
model.flyZones.applyUpgradeProgress(0);
try {
await dji.startFlyZoneDatabaseUpgrade();
} finally {
model.flyZones.applyUpgradeProgress(null);
try {
model.flyZones.applyDatabase(await dji.getFlyZoneDatabase());
} catch (_) {
// Keeps the last known state.
}
}
}
Future<void> cancelFlyZoneDatabaseUpgrade() async {
await dji.stopFlyZoneDatabaseUpgrade();
model.flyZones.applyUpgradeProgress(null);
}
/// Self-unlocks an authorization zone (DJI account required). Unlocking is
/// the pilot declaring they may fly there, so the UI confirms it first.
Future<void> unlockFlyZone(FlyZone zone) async {
if (!zone.unlockable) throw const MissionRefused(<GateCheck>[GateCheck.block('Only authorization zones can be self-unlocked.')]);
if (!model.connected) throw const MissionRefused(<GateCheck>[GateCheck.block('Connect the aircraft to unlock a zone for it.')]);
await dji.unlockAuthorizationFlyZone(zone.id);
try {
model.flyZones.applyUnlocked(<int>{...model.flyZones.unlocked, ...await dji.getUnlockedFlyZones()});
} catch (_) {
model.flyZones.applyUnlocked(<int>{...model.flyZones.unlocked, zone.id});
}
}
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.'));
if (model.mission.hotpoint.active) checks.add(const GateCheck.block('Stop Point of Interest first.'));
if (model.mission.followMe.active) checks.add(const GateCheck.block('Stop Follow Me 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."));
}
if (usesWaypointsV2 && (shoots || spec.points.any((WaypointSpec p) => p.hoverSeconds > 0 || p.gimbalPitch != null))) {
checks.add(const GateCheck.warn('Waypoints 2.0: the route flies, but its camera actions, hovers and gimbal angles are not sent yet.'));
}
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), v2: usesWaypointsV2);
onUploaded?.call();
await dji.startWaypointMission();
}
/// The connected aircraft flies Waypoints 2.0 only (Matrice 300): the same
/// route goes to the V2 operator.
bool get usesWaypointsV2 =>
model.profile.supportsMode(IntelligentMode.waypointV2) && !model.profile.supportsMode(IntelligentMode.waypoint);
/// The cruise speed of the mission in flight, from the run panel.
Future<void> setWaypointCruiseSpeed(double speed) async {
if (!model.mission.waypoint.active) {
throw const MissionRefused(<GateCheck>[GateCheck.block('No waypoint mission is flying.')]);
}
await dji.setWaypointAutoFlightSpeed(speed.clamp(WaypointMissionSpec.minSpeed, WaypointMissionSpec.maxSpeed));
}
/// Where the last mission was cut short, if it belongs to [spec]: the
/// waypoint (0-based) to resume from, else null.
Future<int?> interruptionFor(WaypointMissionSpec spec) async {
if (!model.connected || spec.points.length < 2) return null;
try {
final Map<String, dynamic>? i = await dji.getWaypointInterruption();
if (i == null) return null;
final int? index = (i['index'] as num?)?.toInt();
final double? lat = (i['lat'] as num?)?.toDouble(), lon = (i['lon'] as num?)?.toDouble();
if (index == null || lat == null || lon == null || index <= 0) return null;
// A resume needs two waypoints left to fly.
if (index > spec.points.length - 2) return null;
return spec.matchesInterruption(index, LatLng(lat, lon)) ? index : null;
} catch (_) {
return null;
}
}
/// 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();
}
// ── Vision modes: ActiveTrack and TapFly (plan P7b) ─────────────────────────
/// Below this height (m above take-off) the vision modes refuse to start:
/// the aircraft needs room to climb over and see the subject.
static const double visionMinAltitude = 2;
/// TapFly speed range the operator accepts, m/s.
static const double tapFlyMinSpeed = 1;
static const double tapFlyMaxSpeed = 10;
/// TapFly directions this product can fly: Backward needs rear sensors, Free
/// the newer flight controllers that ship with them.
List<TapFlyDirection> tapFlyDirections() {
final VisionSensors v = model.profile.vision;
final bool rear = v == VisionSensors.frontBack || v == VisionSensors.omni;
return <TapFlyDirection>[
TapFlyDirection.forward,
if (rear || !model.profile.connected) TapFlyDirection.backward,
if (rear || !model.profile.connected) TapFlyDirection.free,
];
}
/// What both vision modes need: flying in P mode with room, nothing else in charge.
void _visionCommon(List<GateCheck> checks) {
if (_a.isFlying != true) {
checks.add(const GateCheck.block('Take off first.'));
return;
}
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 (model.mission.waypoint.active) checks.add(const GateCheck.block('Stop the waypoint mission first.'));
if (model.mission.hotpoint.active) checks.add(const GateCheck.block('Stop Point of Interest first.'));
if (model.mission.followMe.active) checks.add(const GateCheck.block('Stop Follow Me first.'));
if (mode == 'ATTI' || mode == 'GPS_SPORT' || mode == 'MANUAL') {
checks.add(const GateCheck.block('Switch the remote to P mode first.'));
}
final double? h = _a.altitude;
if (h != null && h < visionMinAltitude) checks.add(const GateCheck.block('Climb above 2 m first.'));
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%)'}.'));
}
if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel) {
checks.add(const GateCheck.warn('Weak GPS: the aircraft may drift while it follows.'));
}
}
/// Preconditions for following a subject in [mode].
List<GateCheck> checkActiveTrack(TrackMode mode) {
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.'));
if (model.mission.activeTrack.unsupported) return checks..add(const GateCheck.block('This aircraft cannot run ActiveTrack.'));
_visionCommon(checks);
if (model.mission.tapFly.active) checks.add(const GateCheck.block('Stop TapFly first.'));
if (!model.profile.hasObstacleSensing && mode != TrackMode.spotlight) {
checks.add(const GateCheck.warn('This aircraft cannot see obstacles: keep the path behind the subject clear.'));
} else if (mode == TrackMode.profile) {
checks.add(const GateCheck.warn('Profile flies sideways, where the aircraft has no obstacle sensors.'));
}
return checks;
}
/// Re-checks and starts following the subject in [rect] (0..1 of the live view).
Future<void> startActiveTrack(Rect rect, TrackMode mode) async {
final List<GateCheck> checks = checkActiveTrack(mode);
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
final Rect r = Rect.fromLTRB(rect.left.clamp(0, 1), rect.top.clamp(0, 1), rect.right.clamp(0, 1), rect.bottom.clamp(0, 1));
await dji.startActiveTrack(r.left, r.top, r.width, r.height, mode: mode.sdk);
}
/// Re-checks and follows the auto-sensed subject [index].
Future<void> startActiveTrackSubject(int index, TrackMode mode) async {
final List<GateCheck> checks = checkActiveTrack(mode);
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
await dji.startActiveTrackSubject(index, mode: mode.sdk);
}
/// Turns the aircraft's own subject detection on or off (not while following).
Future<void> setAutoSensing(bool on) async {
if (!model.connected) throw const MissionRefused(<GateCheck>[GateCheck.block('No aircraft connected.')]);
if (!model.mission.activeTrack.autoSensingSupported) {
throw const MissionRefused(<GateCheck>[GateCheck.block('This aircraft cannot detect subjects itself.')]);
}
if (model.mission.activeTrack.active) throw const MissionRefused(<GateCheck>[GateCheck.block('Stop tracking first.')]);
await dji.setActiveTrackAutoSensing(on);
}
/// Trace's "fly backwards ahead of a subject walking towards the aircraft".
Future<void> setTrackRetreat(bool on) async {
if (!model.connected) throw const MissionRefused(<GateCheck>[GateCheck.block('No aircraft connected.')]);
if (on && !model.profile.hasObstacleSensing) {
throw const MissionRefused(<GateCheck>[GateCheck.block('Flying backwards needs obstacle sensing.')]);
}
await dji.setActiveTrackRetreat(on);
}
/// In TapFly Free, turns the aircraft back to face where it is flying.
Future<void> resetTapFlyHeading() async {
if (!model.mission.tapFly.active) throw const MissionRefused(<GateCheck>[GateCheck.block('TapFly is not flying.')]);
await dji.resetTapFlyHeading();
}
/// Answers the operator's "is this the subject?".
Future<void> confirmActiveTrack({required bool yes}) async {
if (!model.mission.activeTrack.waitingForConfirmation && !model.mission.activeTrack.cannotConfirm) return;
await (yes ? dji.acceptActiveTrackConfirmation() : dji.rejectActiveTrackConfirmation());
}
Future<void> switchActiveTrackMode(TrackMode mode) async {
if (!model.mission.activeTrack.active) {
throw const MissionRefused(<GateCheck>[GateCheck.block('Not tracking a subject.')]);
}
await dji.switchActiveTrackMode(mode.sdk);
}
/// Preconditions for flying towards a tapped point in [direction].
List<GateCheck> checkTapFly(TapFlyDirection direction) {
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.'));
if (model.mission.tapFly.unsupported) return checks..add(const GateCheck.block('This aircraft cannot run TapFly.'));
_visionCommon(checks);
if (model.mission.activeTrack.active) checks.add(const GateCheck.block('Stop ActiveTrack first.'));
if (!tapFlyDirections().contains(direction)) checks.add(GateCheck.block('${direction.label} is not available on this aircraft.'));
if (direction == TapFlyDirection.free) {
checks.add(const GateCheck.warn('Free does not avoid obstacles sideways: watch the video.'));
} else if (!model.profile.hasObstacleSensing) {
checks.add(const GateCheck.warn('This aircraft cannot see obstacles: it flies straight into whatever is in the way.'));
}
return checks;
}
/// Re-checks and flies towards [target] (0..1 of the live view).
Future<void> startTapFly(Offset target, TapFlyDirection direction, {double speed = 5}) async {
final List<GateCheck> checks = checkTapFly(direction);
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
await dji.startTapFly(
target.dx.clamp(0.0, 1.0),
target.dy.clamp(0.0, 1.0),
mode: direction.sdk,
speed: speed.clamp(tapFlyMinSpeed, tapFlyMaxSpeed),
avoidance: direction != TapFlyDirection.free,
);
}
/// Changes the TapFly speed in flight.
Future<void> setTapFlySpeed(double speed) async {
if (!model.mission.tapFly.active) return;
await dji.setTapFlySpeed(speed.clamp(tapFlyMinSpeed, tapFlyMaxSpeed));
}
/// ActiveTrack Circle's orbit speed, m/s (negative anticlockwise).
Future<void> setTrackCircleSpeed(double speed) async {
if (!model.connected) throw const MissionRefused(<GateCheck>[GateCheck.block('No aircraft connected.')]);
await dji.setActiveTrackCircularSpeed(speed.clamp(-10.0, 10.0));
}
// ── QuickShots (plan P7) ────────────────────────────────────────────────────
/// Preconditions for flying [shot] around a boxed subject: ActiveTrack's,
/// plus room for the aircraft to climb and fly away.
List<GateCheck> checkQuickShot(QuickShot shot) {
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.'));
if (model.mission.activeTrack.unsupported) return checks..add(const GateCheck.block('This aircraft cannot fly QuickShots.'));
_visionCommon(checks);
if (model.mission.tapFly.active) checks.add(const GateCheck.block('Stop TapFly first.'));
if (model.mission.activeTrack.active) checks.add(const GateCheck.block('Stop ActiveTrack first.'));
if (model.camera.storage != null && !model.camera.storage!.usable) {
checks.add(GateCheck.block('${model.camera.storage!.status}: the QuickShot video would not be saved.'));
}
final int? left = _a.goHome?.remainingFlightTime;
if (left != null && left > 0 && left < 120) checks.add(const GateCheck.warn('Under 2 minutes of flight left: the shot may be cut short.'));
switch (shot) {
case QuickShot.rocket:
case QuickShot.dronie:
case QuickShot.asteroid:
case QuickShot.helix:
checks.add(const GateCheck.warn('The aircraft climbs up to 120 m and flies away from the subject: keep the space above and behind clear.'));
case QuickShot.circle:
case QuickShot.boomerang:
checks.add(const GateCheck.warn('The aircraft flies around the subject: keep the circle clear.'));
case QuickShot.dollyZoom:
checks.add(const GateCheck.warn('The aircraft backs away from the subject: keep the space behind it clear.'));
}
if (!model.profile.hasObstacleSensing) {
checks.add(const GateCheck.warn('This aircraft cannot see obstacles on the way.'));
}
return checks;
}
/// Re-checks and flies [shot] around the subject in [rect] (0..1 of the live view).
Future<void> startQuickShot(Rect rect, QuickShot shot) async {
final List<GateCheck> checks = checkQuickShot(shot);
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
final Rect r = Rect.fromLTRB(rect.left.clamp(0, 1), rect.top.clamp(0, 1), rect.right.clamp(0, 1), rect.bottom.clamp(0, 1));
await dji.startQuickShot(shot.sdk, x: r.left, y: r.top, w: r.width, h: r.height);
}
// ── Point of Interest (plan P7) ─────────────────────────────────────────────
/// What a mission-type mode needs before it may fly the aircraft on its own:
/// connected, airborne in P mode with GPS, nothing else in charge, battery.
void _autoCommon(List<GateCheck> checks, String name) {
final String mode = _a.flightModeEnum ?? '';
if (_a.isFlying != true) checks.add(GateCheck.block('Take off before starting $name.'));
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 (mode == 'ATTI' || mode == 'GPS_SPORT' || mode == 'MANUAL') checks.add(const GateCheck.block('Switch the remote to P mode first.'));
final ActiveMode running = RailState.activeMode(_a, model.mission);
if (running != ActiveMode.none) checks.add(const GateCheck.block('Stop the intelligent flight mode that is running first.'));
if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel) checks.add(GateCheck.block('GPS too weak for $name.'));
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%)'}.'));
}
if (_a.compassHasError == true) checks.add(const GateCheck.block('Compass error. Calibrate the compass or move away from metal.'));
}
/// Preconditions for circling [spec]; [maxAngular] is the aircraft's limit at its radius.
List<GateCheck> checkPoi(PoiSpec spec, {double? maxAngular}) {
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.'));
if (model.mission.hotpoint.unsupported) return checks..add(const GateCheck.block('This aircraft cannot fly Point of Interest.'));
_autoCommon(checks, 'Point of Interest');
final double? lat = _a.latitude, lon = _a.longitude;
for (final (bool blocks, String m) in spec.issues(maxAngular: maxAngular, aircraft: lat == null || lon == null ? null : LatLng(lat, lon))) {
checks.add(blocks ? GateCheck.block(m) : GateCheck.warn(m));
}
// POI flies sideways round the circle, where most aircraft have no sensors.
checks.add(const GateCheck.warn('The aircraft does not brake for obstacles on the circle: check it is clear.'));
final int? left = _a.goHome?.remainingFlightTime;
if (left != null && left > 0 && left < spec.lapSeconds + (_a.goHome?.timeNeededToGoHome ?? 0)) {
checks.add(GateCheck.warn('One lap takes about ${formatDuration(spec.lapSeconds)}: more than the battery has to spare.'));
}
return checks;
}
Future<void> startPoi(PoiSpec spec, {double? maxAngular}) async {
final List<GateCheck> checks = checkPoi(spec, maxAngular: maxAngular);
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
final Map<String, Object?> m = spec.toBridge(maxAngular: maxAngular);
await dji.startHotpoint(
lat: m['lat']! as double,
lon: m['lon']! as double,
altitude: (m['altitude']! as num).toDouble(),
radius: (m['radius']! as num).toDouble(),
angularVelocity: (m['angularVelocity']! as num).toDouble(),
clockwise: m['clockwise']! as bool,
startPoint: m['startPoint']! as String,
heading: m['heading']! as String,
);
}
Future<void> pausePoi() async {
if (!model.mission.hotpoint.active || model.mission.hotpoint.paused) {
throw const MissionRefused(<GateCheck>[GateCheck.block('Point of Interest is not circling.')]);
}
await dji.pauseHotpoint();
}
Future<void> resumePoi() async {
final List<GateCheck> checks = <GateCheck>[
if (!model.mission.hotpoint.paused) const GateCheck.block('Point of Interest is not paused.'),
if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel) const GateCheck.block('GPS too weak to carry on.'),
];
if (checks.isNotEmpty) throw MissionRefused(checks);
await dji.resumeHotpoint();
}
/// Changes the orbit in flight; [speed] m/s becomes °/s on the current radius.
Future<void> adjustPoi({double? radius, double? speed, required bool clockwise, required double currentRadius}) async {
if (!model.mission.hotpoint.active) throw const MissionRefused(<GateCheck>[GateCheck.block('Point of Interest is not running.')]);
if (radius != null) await dji.setHotpointRadius(radius.clamp(PoiSpec.minRadius, PoiSpec.maxRadius));
if (speed != null) {
final double r = radius ?? currentRadius;
final double w = PoiSpec.angularFor(speed.clamp(PoiSpec.minSpeed, PoiSpec.maxSpeed), r);
await dji.setHotpointAngularVelocity(clockwise ? w : -w);
}
}
// ── Follow Me (plan P7) ─────────────────────────────────────────────────────
/// Preconditions for following this phone. [phoneAccuracy] is its fix, m.
List<GateCheck> checkFollowMe(FollowMeSpec spec, {double? phoneAccuracy}) {
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.'));
if (model.mission.followMe.unsupported) return checks..add(const GateCheck.block('This aircraft cannot run Follow Me.'));
_autoCommon(checks, 'Follow Me');
final double? pLat = model.phoneLatitude, pLon = model.phoneLongitude;
if (pLat == null || pLon == null) {
checks.add(const GateCheck.block('This phone has no location fix: Follow Me follows the phone.'));
} else {
if (phoneAccuracy != null && phoneAccuracy > FollowMeSpec.maxAccuracy) {
checks.add(GateCheck.warn('The phone\'s fix is only good to ${phoneAccuracy.round()} m: the aircraft will wander.'));
}
final double? lat = _a.latitude, lon = _a.longitude;
if (lat != null && lon != null) {
final double d = HudGeometry.distance(lat, lon, pLat, pLon);
if (d > 200) checks.add(GateCheck.warn('The aircraft is ${d.round()} m from you: it flies to you first.'));
}
}
if (spec.altitude < FollowMeSpec.minAltitude) checks.add(const GateCheck.block('Follow at 10 m or higher.'));
if (spec.altitude < 20) checks.add(const GateCheck.warn('Low: trees and lamp posts along your way are not avoided.'));
checks.add(const GateCheck.warn('The aircraft does not avoid obstacles while it follows you.'));
return checks;
}
Future<void> startFollowMe(FollowMeSpec spec) async {
final List<GateCheck> checks = checkFollowMe(spec);
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
await dji.startFollowMe(lat: model.phoneLatitude!, lon: model.phoneLongitude!, altitude: spec.altitude, heading: spec.heading.sdk);
}
/// Sends the phone's fix while following; ignored once it is not.
Future<void> updateFollowMe() async {
final double? lat = model.phoneLatitude, lon = model.phoneLongitude;
if (!model.mission.followMe.active || lat == null || lon == null) return;
await dji.updateFollowMeTarget(lat, lon);
}
// ── Orientation, Tripod, Cinematic (plan P7) ────────────────────────────────
/// Preconditions for switching Intelligent Orientation Control to [mode].
List<GateCheck> checkOrientation(OrientationMode mode) {
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.'));
if (mode == OrientationMode.normal) return checks;
if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel) checks.add(GateCheck.block('GPS too weak for ${mode.label}.'));
if (mode == OrientationMode.homeLock) {
if (_a.homeLocationSet != true) checks.add(const GateCheck.block('No home point recorded yet.'));
final double? d = _a.homeDistance;
if (d != null && d < homeLockMinDistance) checks.add(const GateCheck.block('Fly 10 m from home first: Home Lock needs a direction to pull back along.'));
}
final String fm = _a.flightModeEnum ?? '';
if (fm == 'GPS_SPORT' || fm == 'ATTI') checks.add(const GateCheck.block('Switch the remote to P mode first.'));
checks.add(GateCheck.warn(mode == OrientationMode.courseLock
? 'Forward on the stick is now the locked course, whichever way the aircraft faces.'
: 'Pulling back now always flies towards home, whichever way the aircraft faces.'));
return checks;
}
Future<void> setOrientation(OrientationMode mode) async {
final List<GateCheck> checks = checkOrientation(mode);
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
await dji.setFlightOrientationMode(mode.sdk);
}
/// Course Lock takes the current heading as "forward".
Future<void> lockCourse() async {
if (_a.orientationMode != 'COURSE_LOCK') throw const MissionRefused(<GateCheck>[GateCheck.block('Course Lock is not on.')]);
await dji.lockCourseUsingCurrentHeading();
}
/// Tripod (slow, fine control) or Cinematic (smooth braking) on or off.
/// Turning one on is a speed change the pilot feels at once, so it warns.
List<GateCheck> checkSlowMode({required bool cinematic, required bool on}) => <GateCheck>[
if (!model.connected) const GateCheck.block('No aircraft connected.'),
if (model.connected && on && RailState.activeMode(_a, model.mission) != ActiveMode.none)
const GateCheck.block('Stop the intelligent flight mode that is running first.'),
if (model.connected && on)
GateCheck.warn(cinematic
? 'The aircraft brakes and turns slowly: give it room to stop.'
: 'Speed drops to about 1 m/s and the sticks become very gentle.'),
];
Future<void> setSlowMode({required bool cinematic, required bool on}) async {
final List<GateCheck> checks = checkSlowMode(cinematic: cinematic, on: on);
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
await dji.setKey(DjiKey.flightController(cinematic ? 'CINEMATIC_MODE_ENABLED' : 'TRIPOD_MODE_ENABLED'), on);
}
// ── Hyperlapse (plan P7) ────────────────────────────────────────────────────
/// Preconditions for a Free hyperlapse: a camera that shoots intervals, a
/// card with room, and nothing else shooting.
List<GateCheck> checkHyperlapse(HyperlapseSpec spec) {
final List<GateCheck> checks = <GateCheck>[];
if (!model.connected) return checks..add(const GateCheck.block('No aircraft connected.'));
if (!model.profile.hasCamera) return checks..add(const GateCheck.block('This aircraft has no camera.'));
if (model.camera.isRecording) checks.add(const GateCheck.block('Stop recording first.'));
if (model.camera.shootingContinuous) checks.add(const GateCheck.block('The camera is already shooting a sequence.'));
final int? shots = model.camera.storage?.availableCaptureCount;
if (model.camera.storage != null && !model.camera.storage!.usable) {
checks.add(GateCheck.block('${model.camera.storage!.status}: the frames would not be saved.'));
} else if (shots != null && shots < spec.frames) {
checks.add(GateCheck.block('The card has room for $shots photos; this hyperlapse needs ${spec.frames}.'));
}
final int? left = _a.goHome?.remainingFlightTime;
if (left != null && left > 0 && left < spec.shootingSeconds + (_a.goHome?.timeNeededToGoHome ?? 0)) {
checks.add(GateCheck.warn('Shooting takes ${formatDuration(spec.shootingSeconds)}: more than the battery has to spare.'));
}
if (_a.isFlying != true) checks.add(const GateCheck.warn('The aircraft is on the ground: take off, then fly slowly and steadily.'));
return checks;
}
/// Ends an open-ended (255) interval sequence once it has its frames.
Timer? _hyperlapseStop;
/// Starts the interval sequence; the camera's own interval mode runs it.
Future<void> startHyperlapse(HyperlapseSpec spec) async {
final List<GateCheck> checks = checkHyperlapse(spec);
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
await dji.setCameraMode('photo');
await dji.setShootPhotoMode('INTERVAL');
await dji.setPhotoTimeInterval(count: spec.cameraCount, seconds: spec.intervalSeconds);
await dji.startShootPhoto();
_hyperlapseStop?.cancel();
if (spec.cameraCount == 255) {
_hyperlapseStop = Timer(Duration(seconds: spec.shootingSeconds), () => dji.stopShootPhoto().catchError((Object _) {}));
}
}
Future<void> stopHyperlapse() {
_hyperlapseStop?.cancel();
_hyperlapseStop = null;
return dji.stopShootPhoto();
}
// ── Virtual sticks (plan P8) ────────────────────────────────────────────────
/// Preconditions for handing the aircraft to the on-screen sticks.
///
/// Simulator first: on an aircraft that has a remote, the sticks are refused
/// outside the simulator unless the developer switch allows them. Aircraft
/// whose primary control *is* the phone (Spark / Mavic Air on Wi-Fi) may fly
/// them for real, with a warning.
List<GateCheck> checkVirtualSticks() {
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 ProductProfile p = model.profile;
if (p.connected && !p.supportsVirtualStick) return checks..add(const GateCheck.block('This aircraft cannot be flown with on-screen sticks.'));
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.'));
switch (RailState.activeMode(_a, model.mission)) {
case ActiveMode.waypoint:
checks.add(const GateCheck.block('Stop the waypoint mission first.'));
case ActiveMode.activeTrack:
checks.add(const GateCheck.block('Stop ActiveTrack first.'));
case ActiveMode.tapFly:
checks.add(const GateCheck.block('Stop TapFly first.'));
case ActiveMode.hotpoint:
checks.add(const GateCheck.block('Stop Point of Interest first.'));
case ActiveMode.followMe:
checks.add(const GateCheck.block('Stop Follow Me first.'));
case ActiveMode.other:
checks.add(const GateCheck.block('Stop the intelligent flight mode first.'));
case ActiveMode.none:
break;
}
final int? percent = model.battery.percent;
final int critical = model.battery.seriousLowWarningThreshold ?? 15;
if (_a.seriousLowBattery == true || (percent != null && percent <= critical)) {
checks.add(GateCheck.block('Battery critically low${percent == null ? '' : ' ($percent%)'}.'));
}
final bool simulating = _a.simulatorActive == true;
if (!simulating) {
if (!p.virtualStickPrimary && !FlyPrefs.sticksInRealFlight.value) {
checks.add(const GateCheck.block(
'On-screen sticks are for the simulator on this aircraft: start it in Settings → General → Developer, '
'or allow real flights there.'));
} else {
checks.add(const GateCheck.warn('This is a real flight: the aircraft moves as you push the sticks.'));
}
}
if (_a.isFlying != true) {
checks.add(const GateCheck.warn('The sticks do not start the motors: take off with TAKE OFF, then fly.'));
}
if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel && !simulating) {
checks.add(const GateCheck.warn('Weak GPS: the aircraft drifts when the sticks are centred.'));
}
if (p.hasRcLink) {
checks.add(const GateCheck.warn("The remote's sticks are ignored meanwhile: flip its flight-mode switch to take back control."));
}
return checks;
}
/// Re-checks and puts the flight controller in virtual-stick mode.
Future<void> enableVirtualSticks() async {
final List<GateCheck> checks = checkVirtualSticks();
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
await dji.setVirtualStickEnabled(true);
}
/// Hands control back to the remote. Always allowed.
Future<void> disableVirtualSticks() => dji.setVirtualStickEnabled(false);
/// One stick packet. Not re-checked per packet (25 Hz); the controller closes
/// the sticks when RTH, landing or the remote takes over.
Future<void> sendSticks(StickCommand c) => dji.sendVirtualStick(forward: c.forward, right: c.right, yaw: c.yaw, up: c.up);
/// 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.tapFly:
await dji.stopTapFly();
case ActiveMode.hotpoint:
await dji.stopHotpoint();
case ActiveMode.followMe:
await dji.stopFollowMe();
case ActiveMode.none:
case ActiveMode.other:
break; // refused by check()
}
}
}
}