Vision radar: graded obstacle sectors around the centre of the video, the nearest distance per side, the obstacle overhead, and BLOCKED on a side whose sensors cannot see once the motors run. On aircraft with 360° sensing, sides without their own readings are filled from the perception ring. The same sectors sit on the attitude ball's rim, turned by the heading. Settings > Vision gains Display radar chart and the braking / sensing distances per direction. On-screen sticks: the rail's STICKS button hands the aircraft to two stick pads after a slide-to-confirm. The 25 Hz stream runs natively and sends zeros once Dart's last command is 300 ms old, so a stalled UI leaves the aircraft hovering. The sticks close when the remote takes over, on RTH or on landing. They are simulator-only on aircraft with a remote unless Settings > General > Developer allows real flights; Spark and Mavic Air on Wi-Fi may fly them for real. Which MSDK axis is forward in velocity + body mode is coded from DJI's documentation and must be checked in the simulator first. AirSense: a banner for manned aircraft at warning level 2 and up, with distance, direction and ICAO code; the planes on the map; the full list in a sheet and Settings > Safety; and the aircraft's own avoidance action with its countdown. Fly zones: DJI GEO zones around the aircraft (or the phone) on the map with their height limits, a banner for FlySafe notices and in/near-zone states, take-off refused in restricted and locked authorization zones, self-unlock of authorization zones, the aircraft's licences (enable, sync from the DJI account, remove all; ground only), and the GEO database version with an update button. Tested with unit and widget tests and screenshot renders only; nothing has run on an aircraft or the simulator yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
801 lines
38 KiB
Dart
801 lines
38 KiB
Dart
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/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:
|
||
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.'));
|
||
|
||
for (final MissionIssue i in spec.validate(limits)) {
|
||
checks.add(i.blocks ? GateCheck.block(i.message) : GateCheck.warn(i.message));
|
||
}
|
||
|
||
final String mode = _a.flightModeEnum ?? '';
|
||
if (_a.goingHome == true || mode == 'GO_HOME') checks.add(const GateCheck.block('Cancel Return to Home first.'));
|
||
if (mode == 'AUTO_LANDING' || mode == 'CONFIRM_LANDING') checks.add(const GateCheck.block('The aircraft is landing.'));
|
||
if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel) {
|
||
checks.add(const GateCheck.block('GPS too weak for a waypoint mission.'));
|
||
}
|
||
if (_a.homeLocationSet != true) checks.add(const GateCheck.block('No home point recorded yet.'));
|
||
|
||
final int? percent = model.battery.percent;
|
||
final int critical = model.battery.seriousLowWarningThreshold ?? 15;
|
||
final int low = model.battery.lowWarningThreshold ?? 30;
|
||
if (_a.seriousLowBattery == true || (percent != null && percent <= critical)) {
|
||
checks.add(GateCheck.block('Battery critically low${percent == null ? '' : ' ($percent%)'}.'));
|
||
} else if (_a.lowBattery == true || (percent != null && percent <= low)) {
|
||
checks.add(GateCheck.warn('Battery low${percent == null ? '' : ' ($percent%)'}: the route may not finish.'));
|
||
}
|
||
final int? left = _a.goHome?.remainingFlightTime;
|
||
final int needed = spec.estimatedSeconds + (_a.goHome?.timeNeededToGoHome ?? 0);
|
||
if (left != null && left > 0 && spec.points.length >= 2 && needed > left) {
|
||
checks.add(GateCheck.warn('The route takes about ${formatDuration(spec.estimatedSeconds)}; '
|
||
'the battery has about ${formatDuration(left)} of flight left.'));
|
||
}
|
||
|
||
if (_a.isFlying != true) {
|
||
if (_a.imuPreheating == true) checks.add(const GateCheck.block('IMU is warming up. Wait until it is ready.'));
|
||
if (_a.compassCalibrating == true) checks.add(const GateCheck.block('Compass calibration is in progress.'));
|
||
for (final DiagnosticAlert d in model.alerts.all.where((DiagnosticAlert d) => d.severity == AlertSeverity.critical).take(2)) {
|
||
checks.add(GateCheck.block(d.reason));
|
||
}
|
||
checks.add(const GateCheck.warn('The aircraft takes off and flies to waypoint 1 on its own.'));
|
||
}
|
||
if (_a.compassHasError == true) checks.add(const GateCheck.block('Compass error. Calibrate the compass or move away from metal.'));
|
||
|
||
final double? lat = _a.latitude, lon = _a.longitude;
|
||
if (spec.points.isNotEmpty && lat != null && lon != null && !(lat == 0 && lon == 0)) {
|
||
final LatLng first = spec.points.first.at;
|
||
final double d = HudGeometry.distance(lat, lon, first.latitude, first.longitude);
|
||
if (d > farFirstWaypoint) checks.add(GateCheck.warn('Waypoint 1 is ${(d / 1000).toStringAsFixed(1)} km from the aircraft.'));
|
||
}
|
||
if (!model.profile.hasObstacleSensing && spec.points.any((WaypointSpec p) => p.altitude < lowRthHeight)) {
|
||
checks.add(const GateCheck.warn('Parts of the route are low and this aircraft cannot see obstacles.'));
|
||
}
|
||
final bool shoots = spec.points.any((WaypointSpec p) => p.action != WaypointCameraAction.none);
|
||
if (shoots && model.camera.storage != null && !model.camera.storage!.usable) {
|
||
checks.add(GateCheck.warn("${model.camera.storage!.status}: the route's photos and video will not be saved."));
|
||
}
|
||
return checks;
|
||
}
|
||
|
||
/// Re-checks [spec], writes [rthHeight] when it changed, uploads the
|
||
/// mission and starts it. [onUploaded] runs between the two.
|
||
Future<void> startWaypointMission(
|
||
WaypointMissionSpec spec, {
|
||
MissionLimits limits = const MissionLimits(),
|
||
int? rthHeight,
|
||
void Function()? onUploaded,
|
||
}) async {
|
||
final List<GateCheck> checks = checkWaypointStart(spec, limits);
|
||
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
|
||
if (rthHeight != null) {
|
||
final int h = rthHeight.clamp(minRthHeight, maxRthHeight);
|
||
if (h != _a.goHomeHeight?.round()) await dji.setGoHomeHeight(h);
|
||
}
|
||
await dji.uploadWaypointMission(spec.toBridge(gimbalMin: limits.gimbalMin, gimbalMax: limits.gimbalMax));
|
||
onUploaded?.call();
|
||
await dji.startWaypointMission();
|
||
}
|
||
|
||
/// Holds the aircraft where it is; the mission can resume from there.
|
||
Future<void> pauseWaypointMission() async {
|
||
if (!model.mission.waypoint.executing) {
|
||
throw const MissionRefused(<GateCheck>[GateCheck.block('No waypoint mission is flying.')]);
|
||
}
|
||
await dji.pauseWaypointMission();
|
||
}
|
||
|
||
Future<void> resumeWaypointMission() async {
|
||
final List<GateCheck> checks = <GateCheck>[
|
||
if (!model.mission.waypoint.paused) const GateCheck.block('The mission is not paused.'),
|
||
if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel) const GateCheck.block('GPS too weak to carry on with the route.'),
|
||
if (_a.goingHome == true) const GateCheck.block('The aircraft is returning to home.'),
|
||
];
|
||
if (checks.isNotEmpty) throw MissionRefused(checks);
|
||
await dji.resumeWaypointMission();
|
||
}
|
||
|
||
// ── 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 (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));
|
||
}
|
||
|
||
// ── 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.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.none:
|
||
case ActiveMode.other:
|
||
break; // refused by check()
|
||
}
|
||
}
|
||
}
|
||
}
|