P7a of the GO 4-parity plan. The rail's GRID button becomes MODES, which opens a picker of the product's intelligent flight modes with first-use terms; Waypoints is the first one built. Waypoints opens on the big map: tap to add waypoints or POIs, long-press to drag, "+" to insert on a leg, and a point editor for altitude, speed, heading (route / custom / POI), gimbal (set or aim at POI), camera action and hover, and turn radius, with a swipe-to-adjust readout. Route settings, a local mission library and undo sit in a tool column. Start runs the SafetyGate's pre-start check in the slide-to-confirm dialog, then uploads and starts; a run panel shows upload and flight progress with Pause, Resume and Stop. MissionBridge now listens to the waypoint operator and builds the full V1 mission (per-waypoint headings, turn direction, gimbal pitch, actions). Not yet run on a simulator or aircraft. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
625 lines
23 KiB
Dart
625 lines
23 KiB
Dart
import 'dart:math' as math;
|
||
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:latlong2/latlong.dart' show LatLng;
|
||
|
||
import '../hud_geometry.dart';
|
||
|
||
/// A Waypoints mission as the pilot edits it (plan P7, GO 4's Waypoints 2.0
|
||
/// editor), and its translation into what MSDK V4's `WaypointMissionOperator`
|
||
/// takes.
|
||
///
|
||
/// The editor works in GO 4's terms — per-point heading Route / Custom / POI,
|
||
/// "aim the gimbal at the POI", a camera action, a hover — and [toBridge]
|
||
/// resolves them into the V1 mission: a heading per waypoint and the turn
|
||
/// direction to it, a gimbal pitch on every waypoint once any is set, and the
|
||
/// STAY / photo / record actions. MSDK V1 has one mission-wide point of
|
||
/// interest, so a per-point POI becomes that point's computed heading.
|
||
///
|
||
/// Pure and immutable, so the whole translation and [validate] are unit-tested.
|
||
|
||
/// What the camera does on arrival at a waypoint.
|
||
enum WaypointCameraAction {
|
||
none('None'),
|
||
photo('Photo'),
|
||
startRecord('Start recording'),
|
||
stopRecord('Stop recording');
|
||
|
||
const WaypointCameraAction(this.label);
|
||
final String label;
|
||
}
|
||
|
||
/// Where a waypoint points the aircraft, when the route heading is [RouteHeadingMode.perPoint].
|
||
enum WaypointHeading {
|
||
/// Along the route, towards the next waypoint.
|
||
route('Route'),
|
||
custom('Custom'),
|
||
|
||
/// At the waypoint's linked POI.
|
||
poi('POI');
|
||
|
||
const WaypointHeading(this.label);
|
||
final String label;
|
||
}
|
||
|
||
/// The mission-wide heading ("All Waypoint Direction").
|
||
enum RouteHeadingMode {
|
||
auto('Route', 'AUTO'),
|
||
initial('Initial direction', 'USING_INITIAL_DIRECTION'),
|
||
rc('Manual (RC)', 'CONTROL_BY_REMOTE_CONTROLLER'),
|
||
perPoint('Per waypoint', 'USING_WAYPOINT_HEADING'),
|
||
poi('Towards POI', 'TOWARD_POINT_OF_INTEREST');
|
||
|
||
const RouteHeadingMode(this.label, this.sdk);
|
||
final String label;
|
||
final String sdk;
|
||
}
|
||
|
||
/// "Task Finished".
|
||
enum RouteFinishAction {
|
||
hover('Hover', 'NO_ACTION'),
|
||
goHome('Return to Home', 'GO_HOME'),
|
||
land('Land', 'AUTO_LAND'),
|
||
firstWaypoint('Back to first waypoint', 'GO_FIRST_WAYPOINT');
|
||
|
||
const RouteFinishAction(this.label, this.sdk);
|
||
final String label;
|
||
final String sdk;
|
||
}
|
||
|
||
/// "Route type": straight legs that stop at each waypoint, or curves through them.
|
||
enum RoutePathMode {
|
||
straight('Straight', 'NORMAL'),
|
||
curved('Curved', 'CURVED');
|
||
|
||
const RoutePathMode(this.label, this.sdk);
|
||
final String label;
|
||
final String sdk;
|
||
}
|
||
|
||
/// How the aircraft gets to the first waypoint.
|
||
enum RouteGotoFirst {
|
||
/// Climbs to the waypoint's altitude first if it is higher.
|
||
safely('Climb first', 'SAFELY'),
|
||
pointToPoint('Straight line', 'POINT_TO_POINT');
|
||
|
||
const RouteGotoFirst(this.label, this.sdk);
|
||
final String label;
|
||
final String sdk;
|
||
}
|
||
|
||
/// A point of interest waypoints can face, and aim the gimbal at.
|
||
@immutable
|
||
class MissionPoi {
|
||
const MissionPoi({required this.id, required this.at, this.height = 0});
|
||
|
||
final String id;
|
||
final LatLng at;
|
||
|
||
/// Height of the subject above the take-off point, for aiming the gimbal.
|
||
final double height;
|
||
|
||
MissionPoi copyWith({LatLng? at, double? height}) => MissionPoi(id: id, at: at ?? this.at, height: height ?? this.height);
|
||
|
||
Map<String, Object?> toJson() => <String, Object?>{'id': id, 'lat': at.latitude, 'lon': at.longitude, 'height': height};
|
||
|
||
static MissionPoi fromJson(Map<String, dynamic> j) => MissionPoi(
|
||
id: j['id'] as String,
|
||
at: LatLng((j['lat'] as num).toDouble(), (j['lon'] as num).toDouble()),
|
||
height: (j['height'] as num?)?.toDouble() ?? 0,
|
||
);
|
||
|
||
@override
|
||
bool operator ==(Object other) => other is MissionPoi && other.id == id && other.at == at && other.height == height;
|
||
|
||
@override
|
||
int get hashCode => Object.hash(id, at, height);
|
||
}
|
||
|
||
/// One waypoint.
|
||
@immutable
|
||
class WaypointSpec {
|
||
const WaypointSpec({
|
||
required this.at,
|
||
this.altitude = 50,
|
||
this.speed,
|
||
this.heading = WaypointHeading.route,
|
||
this.customHeading = 0,
|
||
this.gimbalPitch,
|
||
this.gimbalAimPoi = false,
|
||
this.action = WaypointCameraAction.none,
|
||
this.hoverSeconds = 0,
|
||
this.poiId,
|
||
this.turnRadius = 0.2,
|
||
});
|
||
|
||
final LatLng at;
|
||
|
||
/// Metres above the take-off point.
|
||
final double altitude;
|
||
|
||
/// m/s from here to the next waypoint; null flies the route's cruise speed.
|
||
final double? speed;
|
||
|
||
final WaypointHeading heading;
|
||
|
||
/// −180…180°, for [WaypointHeading.custom].
|
||
final int customHeading;
|
||
|
||
/// Degrees; null keeps whatever the previous waypoint set.
|
||
final double? gimbalPitch;
|
||
|
||
/// Aim the gimbal at the linked POI instead of [gimbalPitch].
|
||
final bool gimbalAimPoi;
|
||
|
||
final WaypointCameraAction action;
|
||
|
||
/// Seconds to hover on arrival, before the camera action.
|
||
final int hoverSeconds;
|
||
|
||
/// The linked [MissionPoi.id].
|
||
final String? poiId;
|
||
|
||
/// Metres; only curved routes use it.
|
||
final double turnRadius;
|
||
|
||
static const Object _keep = Object();
|
||
|
||
/// Nullable fields take [_keep] to stay unchanged, so `null` can clear them.
|
||
WaypointSpec copyWith({
|
||
LatLng? at,
|
||
double? altitude,
|
||
Object? speed = _keep,
|
||
WaypointHeading? heading,
|
||
int? customHeading,
|
||
Object? gimbalPitch = _keep,
|
||
bool? gimbalAimPoi,
|
||
WaypointCameraAction? action,
|
||
int? hoverSeconds,
|
||
Object? poiId = _keep,
|
||
double? turnRadius,
|
||
}) =>
|
||
WaypointSpec(
|
||
at: at ?? this.at,
|
||
altitude: altitude ?? this.altitude,
|
||
// Any num: an int literal (`?? -30`) arrives here as an int.
|
||
speed: identical(speed, _keep) ? this.speed : (speed as num?)?.toDouble(),
|
||
heading: heading ?? this.heading,
|
||
customHeading: customHeading ?? this.customHeading,
|
||
gimbalPitch: identical(gimbalPitch, _keep) ? this.gimbalPitch : (gimbalPitch as num?)?.toDouble(),
|
||
gimbalAimPoi: gimbalAimPoi ?? this.gimbalAimPoi,
|
||
action: action ?? this.action,
|
||
hoverSeconds: hoverSeconds ?? this.hoverSeconds,
|
||
poiId: identical(poiId, _keep) ? this.poiId : poiId as String?,
|
||
turnRadius: turnRadius ?? this.turnRadius,
|
||
);
|
||
|
||
Map<String, Object?> toJson() => <String, Object?>{
|
||
'lat': at.latitude,
|
||
'lon': at.longitude,
|
||
'altitude': altitude,
|
||
if (speed != null) 'speed': speed,
|
||
'heading': heading.name,
|
||
'customHeading': customHeading,
|
||
if (gimbalPitch != null) 'gimbalPitch': gimbalPitch,
|
||
if (gimbalAimPoi) 'gimbalAimPoi': true,
|
||
'action': action.name,
|
||
if (hoverSeconds > 0) 'hoverSeconds': hoverSeconds,
|
||
if (poiId != null) 'poi': poiId,
|
||
'turnRadius': turnRadius,
|
||
};
|
||
|
||
static WaypointSpec fromJson(Map<String, dynamic> j) => WaypointSpec(
|
||
at: LatLng((j['lat'] as num).toDouble(), (j['lon'] as num).toDouble()),
|
||
altitude: (j['altitude'] as num?)?.toDouble() ?? 50,
|
||
speed: (j['speed'] as num?)?.toDouble(),
|
||
heading: _byName(WaypointHeading.values, j['heading']) ?? WaypointHeading.route,
|
||
customHeading: (j['customHeading'] as num?)?.toInt() ?? 0,
|
||
gimbalPitch: (j['gimbalPitch'] as num?)?.toDouble(),
|
||
gimbalAimPoi: j['gimbalAimPoi'] == true,
|
||
action: _byName(WaypointCameraAction.values, j['action']) ?? WaypointCameraAction.none,
|
||
hoverSeconds: (j['hoverSeconds'] as num?)?.toInt() ?? 0,
|
||
poiId: j['poi'] as String?,
|
||
turnRadius: (j['turnRadius'] as num?)?.toDouble() ?? 0.2,
|
||
);
|
||
|
||
@override
|
||
bool operator ==(Object other) => other is WaypointSpec && mapEquals(other.toJson(), toJson());
|
||
|
||
@override
|
||
int get hashCode => Object.hashAll(toJson().values);
|
||
}
|
||
|
||
enum IssueLevel { warning, error }
|
||
|
||
/// Something wrong with a mission, optionally pinned to a waypoint (0-based).
|
||
@immutable
|
||
class MissionIssue {
|
||
const MissionIssue.error(this.message, {this.point}) : level = IssueLevel.error;
|
||
const MissionIssue.warning(this.message, {this.point}) : level = IssueLevel.warning;
|
||
|
||
final IssueLevel level;
|
||
final String message;
|
||
final int? point;
|
||
|
||
bool get blocks => level == IssueLevel.error;
|
||
|
||
@override
|
||
bool operator ==(Object other) => other is MissionIssue && other.level == level && other.message == message && other.point == point;
|
||
|
||
@override
|
||
int get hashCode => Object.hash(level, message, point);
|
||
|
||
@override
|
||
String toString() => '${level.name}: $message';
|
||
}
|
||
|
||
/// What [WaypointMissionSpec.validate] checks the route against: the
|
||
/// aircraft's own limits, when known.
|
||
@immutable
|
||
class MissionLimits {
|
||
const MissionLimits({this.maxAltitude, this.maxRadius, this.home, this.gimbalMin = -90, this.gimbalMax = 30});
|
||
|
||
/// The flight controller's altitude limit, m.
|
||
final double? maxAltitude;
|
||
|
||
/// The distance limit, m from home, when enabled.
|
||
final double? maxRadius;
|
||
final LatLng? home;
|
||
final double gimbalMin;
|
||
final double gimbalMax;
|
||
}
|
||
|
||
class WaypointMissionSpec {
|
||
const WaypointMissionSpec({
|
||
this.id = '',
|
||
this.name = 'New route',
|
||
this.points = const <WaypointSpec>[],
|
||
this.pois = const <MissionPoi>[],
|
||
this.cruiseSpeed = 8,
|
||
this.headingMode = RouteHeadingMode.auto,
|
||
this.finishAction = RouteFinishAction.goHome,
|
||
this.pathMode = RoutePathMode.straight,
|
||
this.exitOnSignalLost = true,
|
||
this.gotoFirst = RouteGotoFirst.safely,
|
||
this.repeatTimes = 1,
|
||
this.updatedAt,
|
||
});
|
||
|
||
// ── MSDK 4.18 limits (`Waypoint` / `WaypointMission` constants) ────────────
|
||
static const int maxPoints = 99;
|
||
static const int minPoints = 2;
|
||
static const double minAltitude = -200;
|
||
|
||
/// The flight controller's ceiling, whatever the SDK constant says (it is FLT_MAX).
|
||
static const double maxAltitudeSdk = 500;
|
||
static const double minSpeed = 1;
|
||
static const double maxSpeed = 15;
|
||
static const double minTurnRadius = 0.2;
|
||
static const double maxTurnRadius = 1000;
|
||
|
||
/// Adjacent waypoints closer than this are refused.
|
||
static const double minLeg = 0.5;
|
||
|
||
/// …and further apart than this.
|
||
static const double maxLeg = 2000;
|
||
|
||
/// `STAY`'s parameter is milliseconds up to 32767.
|
||
static const int maxHoverSeconds = 32;
|
||
|
||
/// Below this the pre-start check warns about trees and buildings.
|
||
static const double lowAltitude = 10;
|
||
|
||
final String id;
|
||
final String name;
|
||
final List<WaypointSpec> points;
|
||
final List<MissionPoi> pois;
|
||
final double cruiseSpeed;
|
||
final RouteHeadingMode headingMode;
|
||
final RouteFinishAction finishAction;
|
||
final RoutePathMode pathMode;
|
||
|
||
/// True: on RC signal loss the mission ends and the aircraft does its
|
||
/// signal-lost action. False: it finishes the route.
|
||
final bool exitOnSignalLost;
|
||
final RouteGotoFirst gotoFirst;
|
||
final int repeatTimes;
|
||
final DateTime? updatedAt;
|
||
|
||
bool get isEmpty => points.isEmpty;
|
||
|
||
MissionPoi? poi(String? id) {
|
||
if (id == null) return null;
|
||
for (final MissionPoi p in pois) {
|
||
if (p.id == id) return p;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
WaypointMissionSpec copyWith({
|
||
String? id,
|
||
String? name,
|
||
List<WaypointSpec>? points,
|
||
List<MissionPoi>? pois,
|
||
double? cruiseSpeed,
|
||
RouteHeadingMode? headingMode,
|
||
RouteFinishAction? finishAction,
|
||
RoutePathMode? pathMode,
|
||
bool? exitOnSignalLost,
|
||
RouteGotoFirst? gotoFirst,
|
||
int? repeatTimes,
|
||
DateTime? updatedAt,
|
||
}) =>
|
||
WaypointMissionSpec(
|
||
id: id ?? this.id,
|
||
name: name ?? this.name,
|
||
points: points ?? this.points,
|
||
pois: pois ?? this.pois,
|
||
cruiseSpeed: cruiseSpeed ?? this.cruiseSpeed,
|
||
headingMode: headingMode ?? this.headingMode,
|
||
finishAction: finishAction ?? this.finishAction,
|
||
pathMode: pathMode ?? this.pathMode,
|
||
exitOnSignalLost: exitOnSignalLost ?? this.exitOnSignalLost,
|
||
gotoFirst: gotoFirst ?? this.gotoFirst,
|
||
repeatTimes: repeatTimes ?? this.repeatTimes,
|
||
updatedAt: updatedAt ?? this.updatedAt,
|
||
);
|
||
|
||
// ── Geometry and estimates ────────────────────────────────────────────────
|
||
|
||
/// 3-D length of each leg, m: leg i runs from point i to point i+1.
|
||
List<double> get legs => <double>[
|
||
for (int i = 0; i + 1 < points.length; i++) _dist3(points[i], points[i + 1]),
|
||
];
|
||
|
||
static double _dist2(LatLng a, LatLng b) => HudGeometry.distance(a.latitude, a.longitude, b.latitude, b.longitude);
|
||
|
||
static double _dist3(WaypointSpec a, WaypointSpec b) {
|
||
final double h = _dist2(a.at, b.at), v = b.altitude - a.altitude;
|
||
return math.sqrt(h * h + v * v);
|
||
}
|
||
|
||
/// The route's length, m, once through.
|
||
double get length => legs.fold(0.0, (double s, double l) => s + l);
|
||
|
||
double speedOf(int i) => (points[i].speed ?? cruiseSpeed).clamp(minSpeed, maxSpeed);
|
||
|
||
/// A rough flight time for the route, s: each leg at its speed, the hovers,
|
||
/// ~2 s per photo, and ~3 s to brake and accelerate at every straight-route stop.
|
||
int get estimatedSeconds {
|
||
if (points.length < 2) return 0;
|
||
double t = 0;
|
||
final List<double> l = legs;
|
||
for (int i = 0; i < l.length; i++) {
|
||
t += l[i] / speedOf(i);
|
||
}
|
||
for (final WaypointSpec p in points) {
|
||
t += p.hoverSeconds;
|
||
if (p.action == WaypointCameraAction.photo) t += 2;
|
||
}
|
||
if (pathMode == RoutePathMode.straight) t += 3.0 * (points.length - 2).clamp(0, maxPoints);
|
||
return (t * repeatTimes.clamp(1, 255)).round();
|
||
}
|
||
|
||
// ── Resolution into the V1 mission ────────────────────────────────────────
|
||
|
||
static int _signed(double bearing) {
|
||
final int b = bearing.round() % 360;
|
||
return b > 180 ? b - 360 : b;
|
||
}
|
||
|
||
static double _bearing(LatLng a, LatLng b) => HudGeometry.bearing(a.latitude, a.longitude, b.latitude, b.longitude);
|
||
|
||
/// The heading each waypoint flies at, −180…180, when headings are per waypoint.
|
||
List<int> resolvedHeadings() {
|
||
final List<int> out = <int>[];
|
||
for (int i = 0; i < points.length; i++) {
|
||
final WaypointSpec p = points[i];
|
||
final MissionPoi? poiAt = poi(p.poiId);
|
||
switch (p.heading) {
|
||
case WaypointHeading.custom:
|
||
out.add(p.customHeading.clamp(-180, 180));
|
||
case WaypointHeading.poi when poiAt != null:
|
||
out.add(_signed(_bearing(p.at, poiAt.at)));
|
||
case WaypointHeading.poi:
|
||
case WaypointHeading.route:
|
||
if (points.length < 2) {
|
||
out.add(0);
|
||
} else if (i + 1 < points.length) {
|
||
out.add(_signed(_bearing(p.at, points[i + 1].at)));
|
||
} else {
|
||
out.add(_signed(_bearing(points[i - 1].at, p.at)));
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/// The gimbal pitch at every waypoint, or null when no waypoint sets one
|
||
/// (the pilot keeps the gimbal). A waypoint without its own pitch keeps the
|
||
/// previous one's; leading ones take the first set value.
|
||
List<double>? resolvedGimbal({double min = -90, double max = 30}) {
|
||
final List<double?> own = <double?>[
|
||
for (final WaypointSpec p in points) _ownPitch(p),
|
||
];
|
||
final double? first = own.firstWhere((double? v) => v != null, orElse: () => null);
|
||
if (first == null) return null;
|
||
double last = first;
|
||
return <double>[
|
||
for (final double? v in own) (last = v ?? last).clamp(min, max).toDouble(),
|
||
];
|
||
}
|
||
|
||
double? _ownPitch(WaypointSpec p) {
|
||
final MissionPoi? target = poi(p.poiId);
|
||
if (p.gimbalAimPoi && target != null) {
|
||
final double d = _dist2(p.at, target.at);
|
||
final double drop = p.altitude - target.height;
|
||
return d < 0.5 ? -90 : -math.atan2(drop, d) * 180 / math.pi;
|
||
}
|
||
return p.gimbalPitch;
|
||
}
|
||
|
||
/// The `uploadWaypointMission` payload for `MissionBridge.kt`: SDK enum
|
||
/// names and resolved per-waypoint values.
|
||
Map<String, Object?> toBridge({double gimbalMin = -90, double gimbalMax = 30}) {
|
||
final bool perPoint = headingMode == RouteHeadingMode.perPoint;
|
||
final List<int> headings = resolvedHeadings();
|
||
final List<double>? pitch = resolvedGimbal(min: gimbalMin, max: gimbalMax);
|
||
final bool curved = pathMode == RoutePathMode.curved;
|
||
|
||
final List<Map<String, Object?>> wps = <Map<String, Object?>>[];
|
||
int? previous;
|
||
for (int i = 0; i < points.length; i++) {
|
||
final WaypointSpec p = points[i];
|
||
final int h = headings[i];
|
||
// Turn the short way round from the previous waypoint's heading.
|
||
String turn = 'CLOCKWISE';
|
||
if (perPoint && previous != null) {
|
||
final int delta = ((h - previous) % 360 + 360) % 360;
|
||
if (delta > 180) turn = 'COUNTER_CLOCKWISE';
|
||
}
|
||
previous = h;
|
||
wps.add(<String, Object?>{
|
||
'lat': p.at.latitude,
|
||
'lon': p.at.longitude,
|
||
'altitude': p.altitude,
|
||
if (perPoint) 'heading': h,
|
||
'turnMode': turn,
|
||
// 0 = the mission's auto flight speed.
|
||
'speed': p.speed == null ? 0.0 : p.speed!.clamp(minSpeed, maxSpeed),
|
||
'cornerRadius': curved ? p.turnRadius.clamp(minTurnRadius, maxTurnRadius) : minTurnRadius,
|
||
if (pitch != null) 'gimbalPitch': pitch[i],
|
||
'actions': <Map<String, Object?>>[
|
||
if (p.hoverSeconds > 0) <String, Object?>{'type': 'STAY', 'param': (p.hoverSeconds.clamp(0, maxHoverSeconds) * 1000).clamp(0, 32767)},
|
||
if (p.action == WaypointCameraAction.photo) const <String, Object?>{'type': 'START_TAKE_PHOTO', 'param': 0},
|
||
if (p.action == WaypointCameraAction.startRecord) const <String, Object?>{'type': 'START_RECORD', 'param': 0},
|
||
if (p.action == WaypointCameraAction.stopRecord) const <String, Object?>{'type': 'STOP_RECORD', 'param': 0},
|
||
],
|
||
});
|
||
}
|
||
final MissionPoi? missionPoi = pois.isEmpty ? null : pois.first;
|
||
return <String, Object?>{
|
||
'waypoints': wps,
|
||
'autoFlightSpeed': cruiseSpeed.clamp(minSpeed, maxSpeed),
|
||
'maxFlightSpeed': maxSpeed,
|
||
'finishedAction': finishAction.sdk,
|
||
'headingMode': headingMode.sdk,
|
||
'flightPathMode': pathMode.sdk,
|
||
'gotoFirstWaypointMode': gotoFirst.sdk,
|
||
'exitMissionOnRCSignalLost': exitOnSignalLost,
|
||
'gimbalPitchRotation': pitch != null,
|
||
'repeatTimes': repeatTimes.clamp(1, 255),
|
||
if (headingMode == RouteHeadingMode.poi && missionPoi != null)
|
||
'pointOfInterest': <String, Object?>{'lat': missionPoi.at.latitude, 'lon': missionPoi.at.longitude},
|
||
};
|
||
}
|
||
|
||
// ── Validation ────────────────────────────────────────────────────────────
|
||
|
||
/// Everything that would make the SDK refuse the mission (errors) or that
|
||
/// the pilot should know before flying it (warnings).
|
||
List<MissionIssue> validate([MissionLimits limits = const MissionLimits()]) {
|
||
final List<MissionIssue> out = <MissionIssue>[];
|
||
if (points.length < minPoints) out.add(const MissionIssue.error('Add at least 2 waypoints.'));
|
||
if (points.length > maxPoints) out.add(const MissionIssue.error('A route can have at most $maxPoints waypoints.'));
|
||
|
||
final double ceiling = math.min(maxAltitudeSdk, limits.maxAltitude ?? maxAltitudeSdk);
|
||
for (int i = 0; i < points.length; i++) {
|
||
final WaypointSpec p = points[i];
|
||
final String n = 'Waypoint ${i + 1}';
|
||
if (p.altitude > ceiling) {
|
||
out.add(MissionIssue.error('$n is at ${p.altitude.round()} m, above the ${ceiling.round()} m altitude limit.', point: i));
|
||
} else if (p.altitude < minAltitude) {
|
||
out.add(MissionIssue.error('$n is too far below the take-off point.', point: i));
|
||
} else if (p.altitude < lowAltitude) {
|
||
out.add(MissionIssue.warning('$n is only ${p.altitude.round()} m above the take-off point.', point: i));
|
||
}
|
||
if (limits.maxRadius != null && limits.home != null && _dist2(limits.home!, p.at) > limits.maxRadius!) {
|
||
out.add(MissionIssue.error('$n is beyond the ${limits.maxRadius!.round()} m distance limit.', point: i));
|
||
}
|
||
if (p.heading == WaypointHeading.poi && poi(p.poiId) == null && headingMode == RouteHeadingMode.perPoint) {
|
||
out.add(MissionIssue.warning('$n faces a POI but none is linked: it flies along the route.', point: i));
|
||
}
|
||
}
|
||
|
||
final List<double> l = legs;
|
||
for (int i = 0; i < l.length; i++) {
|
||
final double flat = _dist2(points[i].at, points[i + 1].at);
|
||
if (flat < minLeg && (points[i].altitude - points[i + 1].altitude).abs() < minLeg) {
|
||
out.add(MissionIssue.error('Waypoints ${i + 1} and ${i + 2} are on top of each other.', point: i + 1));
|
||
} else if (flat > maxLeg) {
|
||
out.add(MissionIssue.error('Waypoints ${i + 1} and ${i + 2} are more than 2 km apart.', point: i + 1));
|
||
}
|
||
}
|
||
|
||
if (pathMode == RoutePathMode.curved) {
|
||
for (int i = 0; i < l.length; i++) {
|
||
final double sum = points[i].turnRadius + points[i + 1].turnRadius;
|
||
if (sum >= _dist2(points[i].at, points[i + 1].at)) {
|
||
out.add(MissionIssue.error('The turn radii of waypoints ${i + 1} and ${i + 2} overlap: make them smaller.', point: i + 1));
|
||
}
|
||
}
|
||
if (points.any((WaypointSpec p) => p.action != WaypointCameraAction.none || p.hoverSeconds > 0)) {
|
||
out.add(const MissionIssue.warning('A curved route flies through its waypoints without stopping: their actions and hovers are skipped.'));
|
||
}
|
||
}
|
||
|
||
if (headingMode == RouteHeadingMode.poi && pois.isEmpty) {
|
||
out.add(const MissionIssue.error('"Towards POI" needs a point of interest: add one on the map.'));
|
||
}
|
||
if (cruiseSpeed < minSpeed || cruiseSpeed > maxSpeed) {
|
||
out.add(const MissionIssue.error('Cruise speed must be between 1 and 15 m/s.'));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ── Storage (mission library, later the API Server) ───────────────────────
|
||
|
||
static const int formatVersion = 1;
|
||
|
||
Map<String, Object?> toJson() => <String, Object?>{
|
||
'version': formatVersion,
|
||
'id': id,
|
||
'name': name,
|
||
'points': <Map<String, Object?>>[for (final WaypointSpec p in points) p.toJson()],
|
||
'pois': <Map<String, Object?>>[for (final MissionPoi p in pois) p.toJson()],
|
||
'cruiseSpeed': cruiseSpeed,
|
||
'headingMode': headingMode.name,
|
||
'finishAction': finishAction.name,
|
||
'pathMode': pathMode.name,
|
||
'exitOnSignalLost': exitOnSignalLost,
|
||
'gotoFirst': gotoFirst.name,
|
||
'repeatTimes': repeatTimes,
|
||
if (updatedAt != null) 'updatedAt': updatedAt!.toUtc().toIso8601String(),
|
||
};
|
||
|
||
static WaypointMissionSpec fromJson(Map<String, dynamic> j) => WaypointMissionSpec(
|
||
id: j['id'] as String? ?? '',
|
||
name: j['name'] as String? ?? 'Route',
|
||
points: <WaypointSpec>[
|
||
for (final Object? p in (j['points'] as List<Object?>? ?? const <Object?>[]))
|
||
if (p is Map) WaypointSpec.fromJson(Map<String, dynamic>.from(p)),
|
||
],
|
||
pois: <MissionPoi>[
|
||
for (final Object? p in (j['pois'] as List<Object?>? ?? const <Object?>[]))
|
||
if (p is Map) MissionPoi.fromJson(Map<String, dynamic>.from(p)),
|
||
],
|
||
cruiseSpeed: (j['cruiseSpeed'] as num?)?.toDouble() ?? 8,
|
||
headingMode: _byName(RouteHeadingMode.values, j['headingMode']) ?? RouteHeadingMode.auto,
|
||
finishAction: _byName(RouteFinishAction.values, j['finishAction']) ?? RouteFinishAction.goHome,
|
||
pathMode: _byName(RoutePathMode.values, j['pathMode']) ?? RoutePathMode.straight,
|
||
exitOnSignalLost: j['exitOnSignalLost'] as bool? ?? true,
|
||
gotoFirst: _byName(RouteGotoFirst.values, j['gotoFirst']) ?? RouteGotoFirst.safely,
|
||
repeatTimes: (j['repeatTimes'] as num?)?.toInt() ?? 1,
|
||
updatedAt: DateTime.tryParse(j['updatedAt'] as String? ?? ''),
|
||
);
|
||
}
|
||
|
||
T? _byName<T extends Enum>(List<T> values, Object? name) {
|
||
for (final T v in values) {
|
||
if (v.name == name) return v;
|
||
}
|
||
return null;
|
||
}
|