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 checks; bool get allowed => !checks.any((GateCheck c) => c.blocks); List get blockers => [for (final GateCheck c in checks) if (c.blocks) c]; List get warnings => [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 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 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 checks = []; 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 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 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 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 checkLicenceChange() => [ 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 _licences(Future Function() change) async { final List 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 setLicenceEnabled(FlyZoneLicence licence, bool enabled) async { if (enabled && licence.expired) throw const MissionRefused([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 syncLicences() => _licences(dji.syncFlyZoneLicences); /// Removes every licence from the aircraft. Future deleteAllLicences() => _licences(dji.deleteAllFlyZoneLicences); /// Downloads the basic GEO database; the database state refreshes when it ends. Future 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 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 unlockFlyZone(FlyZone zone) async { if (!zone.unlockable) throw const MissionRefused([GateCheck.block('Only authorization zones can be self-unlocked.')]); if (!model.connected) throw const MissionRefused([GateCheck.block('Connect the aircraft to unlock a zone for it.')]); await dji.unlockAuthorizationFlyZone(zone.id); try { model.flyZones.applyUnlocked({...model.flyZones.unlocked, ...await dji.getUnlockedFlyZones()}); } catch (_) { model.flyZones.applyUnlocked({...model.flyZones.unlocked, zone.id}); } } void _goHome(List 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 checkMaintenance(CalibrationKind kind) { final List checks = []; 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 runMaintenance(CalibrationKind kind, Future Function() start) async { final List 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() async { Future read(String key) async { if (!model.connected) return null; try { return await dji.getKey(DjiKey.flightController(key)); } catch (_) { return null; } } final List v = await Future.wait(>[ 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 checkWaypointStart(WaypointMissionSpec spec, [MissionLimits limits = const MissionLimits()]) { final List 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.')); 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 startWaypointMission( WaypointMissionSpec spec, { MissionLimits limits = const MissionLimits(), int? rthHeight, void Function()? onUploaded, }) async { final List 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 pauseWaypointMission() async { if (!model.mission.waypoint.executing) { throw const MissionRefused([GateCheck.block('No waypoint mission is flying.')]); } await dji.pauseWaypointMission(); } Future resumeWaypointMission() async { final List checks = [ 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 tapFlyDirections() { final VisionSensors v = model.profile.vision; final bool rear = v == VisionSensors.frontBack || v == VisionSensors.omni; return [ 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 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 checkActiveTrack(TrackMode mode) { final List 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.')); 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 startActiveTrack(Rect rect, TrackMode mode) async { final List 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 startActiveTrackSubject(int index, TrackMode mode) async { final List 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 setAutoSensing(bool on) async { if (!model.connected) throw const MissionRefused([GateCheck.block('No aircraft connected.')]); if (!model.mission.activeTrack.autoSensingSupported) { throw const MissionRefused([GateCheck.block('This aircraft cannot detect subjects itself.')]); } if (model.mission.activeTrack.active) throw const MissionRefused([GateCheck.block('Stop tracking first.')]); await dji.setActiveTrackAutoSensing(on); } /// Trace's "fly backwards ahead of a subject walking towards the aircraft". Future setTrackRetreat(bool on) async { if (!model.connected) throw const MissionRefused([GateCheck.block('No aircraft connected.')]); if (on && !model.profile.hasObstacleSensing) { throw const MissionRefused([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 resetTapFlyHeading() async { if (!model.mission.tapFly.active) throw const MissionRefused([GateCheck.block('TapFly is not flying.')]); await dji.resetTapFlyHeading(); } /// Answers the operator's "is this the subject?". Future confirmActiveTrack({required bool yes}) async { if (!model.mission.activeTrack.waitingForConfirmation && !model.mission.activeTrack.cannotConfirm) return; await (yes ? dji.acceptActiveTrackConfirmation() : dji.rejectActiveTrackConfirmation()); } Future switchActiveTrackMode(TrackMode mode) async { if (!model.mission.activeTrack.active) { throw const MissionRefused([GateCheck.block('Not tracking a subject.')]); } await dji.switchActiveTrackMode(mode.sdk); } /// Preconditions for flying towards a tapped point in [direction]. List checkTapFly(TapFlyDirection direction) { final List 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.')); 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 startTapFly(Offset target, TapFlyDirection direction, {double speed = 5}) async { final List 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 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 checkVirtualSticks() { final List 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.')); 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 enableVirtualSticks() async { final List 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 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 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 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 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() } } } }