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>
1127 lines
57 KiB
Dart
1127 lines
57 KiB
Dart
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()
|
||
}
|
||
}
|
||
}
|
||
}
|