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

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

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

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

284 lines
10 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart' show LatLng;
import '../hud_geometry.dart';
/// The remaining Intelligent Flight Modes (plan P7) as pure data: Point of
/// Interest, Follow Me, QuickShots, Hyperlapse and the orientation /
/// Tripod / Cinematic switches. The SafetyGate checks them and the bridge
/// flies them; nothing here talks to the aircraft.
// ── Point of Interest ─────────────────────────────────────────────────────────
/// Which way the aircraft faces while circling (`HotpointHeading`).
enum PoiHeading {
toCenter('Towards POI', 'TOWARDS_HOT_POINT'),
away('Away from POI', 'AWAY_FROM_HOT_POINT'),
forward('Along the circle', 'ALONG_CIRCLE_LOOKING_FORWARDS'),
backward('Along, looking back', 'ALONG_CIRCLE_LOOKING_BACKWARDS'),
remote('Remote controls it', 'CONTROLLED_BY_REMOTE_CONTROLLER');
const PoiHeading(this.label, this.sdk);
final String label;
final String sdk;
}
/// Where on the circle the aircraft joins it (`HotpointStartPoint`).
enum PoiStart {
nearest('Nearest', 'NEAREST'),
north('North', 'NORTH'),
east('East', 'EAST'),
south('South', 'SOUTH'),
west('West', 'WEST');
const PoiStart(this.label, this.sdk);
final String label;
final String sdk;
}
/// A Point of Interest orbit as the pilot sets it up (GO 4's POI panel).
@immutable
class PoiSpec {
const PoiSpec({
this.center,
this.radius = 30,
this.altitude = 30,
this.speed = 3,
this.clockwise = true,
this.heading = PoiHeading.toCenter,
this.start = PoiStart.nearest,
});
// `HotpointMission` limits (MSDK 4.18 constants).
static const double minRadius = 5;
static const double maxRadius = 500;
static const double minAltitude = 5;
static const double maxAltitude = 500;
/// The operator's ceiling for the orbit, whatever the radius allows.
static const double maxSpeed = 10;
static const double minSpeed = 0.5;
/// Below this the pre-start check warns about trees and buildings.
static const double lowAltitude = 20;
final LatLng? center;
final double radius;
final double altitude;
/// Tangential speed, m/s; the bridge wants °/s ([angularVelocity]).
final double speed;
final bool clockwise;
final PoiHeading heading;
final PoiStart start;
static const Object _keep = Object();
PoiSpec copyWith({
Object? center = _keep,
double? radius,
double? altitude,
double? speed,
bool? clockwise,
PoiHeading? heading,
PoiStart? start,
}) =>
PoiSpec(
center: identical(center, _keep) ? this.center : center as LatLng?,
radius: radius ?? this.radius,
altitude: altitude ?? this.altitude,
speed: speed ?? this.speed,
clockwise: clockwise ?? this.clockwise,
heading: heading ?? this.heading,
start: start ?? this.start,
);
/// Degrees per second for a tangential [speed] on a circle of [radius].
static double angularFor(double speed, double radius) => speed / radius * 180 / math.pi;
/// The tangential speed of [deg] °/s on [radius].
static double speedFor(double deg, double radius) => deg * math.pi / 180 * radius;
double get angularVelocity => angularFor(speed, radius);
/// Seconds per lap.
int get lapSeconds => (2 * math.pi * radius / speed.clamp(minSpeed, maxSpeed)).round();
/// What would make the operator refuse the orbit (blocking) or what the
/// pilot should know (not).
List<(bool, String)> issues({double? maxAngular, LatLng? aircraft}) => <(bool, String)>[
if (center == null) (true, 'Set the point of interest: tap the map, or use the aircraft\'s position.'),
if (radius < minRadius || radius > maxRadius) (true, 'The radius must be between 5 and 500 m.'),
if (altitude < minAltitude || altitude > maxAltitude) (true, 'The altitude must be between 5 and 500 m.'),
if (maxAngular != null && maxAngular > 0 && angularVelocity > maxAngular)
(false, 'At ${radius.round()} m the aircraft circles at most ${speedFor(maxAngular, radius).toStringAsFixed(1)} m/s; it flies that speed.'),
if (altitude < lowAltitude) (false, 'The orbit is only ${altitude.round()} m up: check for trees and buildings on the circle.'),
if (center != null && aircraft != null && HudGeometry.distance(aircraft.latitude, aircraft.longitude, center!.latitude, center!.longitude) > 1000)
(false, 'The point of interest is more than 1 km from the aircraft.'),
];
/// `startHotpoint`'s arguments; [maxAngular] caps the speed the operator would refuse.
Map<String, Object?> toBridge({double? maxAngular}) {
double w = angularVelocity;
if (maxAngular != null && maxAngular > 0) w = math.min(w, maxAngular);
return <String, Object?>{
'lat': center!.latitude,
'lon': center!.longitude,
'altitude': altitude.clamp(minAltitude, maxAltitude),
'radius': radius.clamp(minRadius, maxRadius),
'angularVelocity': w,
'clockwise': clockwise,
'startPoint': start.sdk,
'heading': heading.sdk,
};
}
}
// ── Follow Me ─────────────────────────────────────────────────────────────────
enum FollowMeHeading {
toPilot('Face me', 'TOWARD_FOLLOW_POSITION'),
remote('Remote controls it', 'CONTROLLED_BY_REMOTE_CONTROLLER');
const FollowMeHeading(this.label, this.sdk);
final String label;
final String sdk;
}
@immutable
class FollowMeSpec {
const FollowMeSpec({this.altitude = 15, this.heading = FollowMeHeading.toPilot});
static const double minAltitude = 10;
static const double maxAltitude = 120;
/// How often the phone's fix is sent while following.
static const Duration updateEvery = Duration(milliseconds: 500);
/// A phone fix worse than this (m) is not worth following.
static const double maxAccuracy = 15;
final double altitude;
final FollowMeHeading heading;
FollowMeSpec copyWith({double? altitude, FollowMeHeading? heading}) =>
FollowMeSpec(altitude: altitude ?? this.altitude, heading: heading ?? this.heading);
}
// ── QuickShots ────────────────────────────────────────────────────────────────
/// GO 4's QuickShots (`QuickShotMode` names); which ones an aircraft flies is
/// its firmware's call — an unsupported one is refused by the operator.
enum QuickShot {
dronie('Dronie', 'DRONIE', 'Flies up and back, camera on the subject'),
rocket('Rocket', 'ROCKET', 'Climbs straight up, camera looking down'),
circle('Circle', 'CIRCLE', 'Circles the subject at its height'),
helix('Helix', 'HELIX', 'Spirals up and out around the subject'),
boomerang('Boomerang', 'BOOMERANG', 'Swings out and back in an oval'),
asteroid('Asteroid', 'ASTEROID', 'Flies up and back, then a spherical pano'),
dollyZoom('Dolly zoom', 'DOLLY_ZOOM', 'Backs away while zooming in (zoom cameras)');
const QuickShot(this.label, this.sdk, this.blurb);
final String label;
final String sdk;
final String blurb;
/// The shots GO 4 offers on this product: Boomerang and Asteroid came with
/// the omni-sensing aircraft, Dolly zoom needs a zoom lens.
static List<QuickShot> offered({required bool omni, required bool zoom}) => <QuickShot>[
dronie,
rocket,
circle,
helix,
if (omni) boomerang,
if (omni) asteroid,
if (zoom) dollyZoom,
];
}
// ── Hyperlapse ────────────────────────────────────────────────────────────────
/// A Free hyperlapse: the camera shoots an interval sequence while the pilot
/// flies, for a video of [videoSeconds] at [fps] (GO 4's Hyperlapse → Free).
/// MSDK V4 has no hyperlapse operator, so the flight is the pilot's and the
/// frames are assembled afterwards.
@immutable
class HyperlapseSpec {
const HyperlapseSpec({this.intervalSeconds = 2, this.videoSeconds = 10, this.fps = 25});
static const List<int> intervals = <int>[2, 3, 5, 10];
static const List<int> durations = <int>[5, 10, 15, 20, 30];
final int intervalSeconds;
final int videoSeconds;
final int fps;
int get frames => (videoSeconds * fps).clamp(2, 10000);
/// The camera's interval count: at most 254 frames, else 255 = "until
/// stopped", and the gate stops it after [shootingSeconds].
int get cameraCount => frames > 254 ? 255 : frames;
/// How long the shooting takes, s.
int get shootingSeconds => frames * intervalSeconds;
HyperlapseSpec copyWith({int? intervalSeconds, int? videoSeconds}) =>
HyperlapseSpec(intervalSeconds: intervalSeconds ?? this.intervalSeconds, videoSeconds: videoSeconds ?? this.videoSeconds, fps: fps);
}
// ── Orientation / Tripod / Cinematic ──────────────────────────────────────────
/// Intelligent Orientation Control (`FlightOrientationMode`).
enum OrientationMode {
normal('Normal', 'AIRCRAFT_HEADING'),
courseLock('Course Lock', 'COURSE_LOCK'),
homeLock('Home Lock', 'HOME_LOCK');
const OrientationMode(this.label, this.sdk);
final String label;
final String sdk;
static OrientationMode fromSdk(String? s) => switch (s) {
'COURSE_LOCK' => courseLock,
'HOME_LOCK' => homeLock,
_ => normal,
};
}
/// Home Lock refuses within this distance of home: "pull back" has no direction.
const double homeLockMinDistance = 10;
/// What the pilot has set up in the POI / Follow Me / Hyperlapse panels,
/// shared by the panel, the map layer and the flight screen.
class ModeSetup extends ChangeNotifier {
PoiSpec poi = const PoiSpec();
FollowMeSpec followMe = const FollowMeSpec();
HyperlapseSpec hyperlapse = const HyperlapseSpec();
/// The aircraft's fastest orbit at [poi]'s radius (°/s), once asked.
double? poiMaxAngular;
void setPoi(PoiSpec p) {
if (p.radius != poi.radius) poiMaxAngular = null;
poi = p;
notifyListeners();
}
void setPoiMaxAngular(double? v) {
poiMaxAngular = v;
notifyListeners();
}
void setFollowMe(FollowMeSpec f) {
followMe = f;
notifyListeners();
}
void setHyperlapse(HyperlapseSpec h) {
hyperlapse = h;
notifyListeners();
}
}