Files
PilotVault/Fly App/lib/fly/modes/waypoint_mission.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

640 lines
24 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
);
/// The rest of the route from waypoint [index] on (0-based), to resume an
/// interrupted mission there. Keeps the route settings and POIs.
WaypointMissionSpec from(int index) => copyWith(points: points.sublist(index.clamp(0, points.length)));
/// Whether an interruption at [at] (waypoint [index]) belongs to this route:
/// the route has that waypoint and it lies within 50 m of where it stopped.
bool matchesInterruption(int index, LatLng at) {
if (index < 0 || index >= points.length) return false;
// Interrupted between index−1 and index: near either end of that leg counts.
final LatLng p = points[index].at;
final LatLng q = index > 0 ? points[index - 1].at : p;
final double leg = _dist2(q, p);
return _dist2(at, p) <= leg + 50 && _dist2(at, q) <= leg + 50;
}
// ── 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;
}