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>
284 lines
10 KiB
Dart
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();
|
|
}
|
|
}
|