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>
140 lines
6.0 KiB
Dart
140 lines
6.0 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:latlong2/latlong.dart' show LatLng;
|
|
|
|
import '../hud_geometry.dart';
|
|
import 'waypoint_mission.dart';
|
|
|
|
/// The route's clearance over the ground (plan P7's terrain check).
|
|
///
|
|
/// Waypoint altitudes are relative to the take-off point, so a route that
|
|
/// climbs a hillside can fly into it while every waypoint reads "50 m". This
|
|
/// samples the ground along every leg (an elevation service, [ElevationSource])
|
|
/// and compares it with the aircraft's planned height above sea level: take-off
|
|
/// elevation + the altitude interpolated along the leg.
|
|
///
|
|
/// Pure apart from the source, so the maths is unit-tested with a fake.
|
|
abstract class ElevationSource {
|
|
/// Ground elevation in metres above sea level for each point, in order.
|
|
Future<List<double>> elevations(List<LatLng> points);
|
|
}
|
|
|
|
/// One sample along the route that comes too close to the ground.
|
|
class TerrainIssue {
|
|
const TerrainIssue({required this.leg, required this.clearance, required this.at});
|
|
|
|
/// The leg it is on: from waypoint [leg] to [leg] + 1 (0-based).
|
|
final int leg;
|
|
|
|
/// Metres between the aircraft and the ground there (negative = underground).
|
|
final double clearance;
|
|
final LatLng at;
|
|
|
|
MissionIssue toIssue() => clearance < 0
|
|
? MissionIssue.error(
|
|
'Between waypoints ${leg + 1} and ${leg + 2} the ground rises ${(-clearance).round()} m above the route.',
|
|
point: leg + 1,
|
|
)
|
|
: MissionIssue.warning(
|
|
'Between waypoints ${leg + 1} and ${leg + 2} the route passes only ${clearance.round()} m over the ground.',
|
|
point: leg + 1,
|
|
);
|
|
}
|
|
|
|
class TerrainCheck {
|
|
const TerrainCheck(this.source, {this.spacing = 50, this.minClearance = 15, this.maxSamples = 100});
|
|
|
|
final ElevationSource source;
|
|
|
|
/// Metres between samples along a leg.
|
|
final double spacing;
|
|
|
|
/// Less clearance than this is reported.
|
|
final double minClearance;
|
|
|
|
/// The service takes at most this many points per request.
|
|
final int maxSamples;
|
|
|
|
/// Samples along the route: every waypoint, and points every [spacing] m
|
|
/// between them, each with its leg and planned altitude (relative to take-off).
|
|
List<(int leg, LatLng at, double altitude)> samples(WaypointMissionSpec spec) {
|
|
final List<(int, LatLng, double)> out = <(int, LatLng, double)>[];
|
|
final List<WaypointSpec> p = spec.points;
|
|
for (int i = 0; i + 1 < p.length; i++) {
|
|
final LatLng a = p[i].at, b = p[i + 1].at;
|
|
final double d = HudGeometry.distance(a.latitude, a.longitude, b.latitude, b.longitude);
|
|
final int n = (d / spacing).ceil().clamp(1, 1000);
|
|
for (int k = 0; k < n; k++) {
|
|
final double t = k / n;
|
|
out.add((
|
|
i,
|
|
LatLng(a.latitude + (b.latitude - a.latitude) * t, a.longitude + (b.longitude - a.longitude) * t),
|
|
p[i].altitude + (p[i + 1].altitude - p[i].altitude) * t,
|
|
));
|
|
}
|
|
}
|
|
if (p.isNotEmpty) out.add((p.length - 2 < 0 ? 0 : p.length - 2, p.last.at, p.last.altitude));
|
|
// Evenly thinned to the service's limit.
|
|
if (out.length > maxSamples) {
|
|
final double step = out.length / maxSamples;
|
|
return <(int, LatLng, double)>[for (int k = 0; k < maxSamples; k++) out[(k * step).floor()]];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/// The places the route comes within [minClearance] of the ground; [home]
|
|
/// is the take-off point the altitudes are measured from. The worst per leg.
|
|
Future<List<TerrainIssue>> run(WaypointMissionSpec spec, LatLng home) async {
|
|
final List<(int, LatLng, double)> s = samples(spec);
|
|
if (s.isEmpty) return const <TerrainIssue>[];
|
|
final List<double> ground = await source.elevations(<LatLng>[home, for (final (int, LatLng, double) x in s) x.$2]);
|
|
if (ground.length != s.length + 1) throw const FormatException('The elevation service answered for a different number of points');
|
|
final double base = ground.first;
|
|
final Map<int, TerrainIssue> worst = <int, TerrainIssue>{};
|
|
for (int k = 0; k < s.length; k++) {
|
|
final (int leg, LatLng at, double alt) = s[k];
|
|
final double clearance = base + alt - ground[k + 1];
|
|
if (clearance >= minClearance) continue;
|
|
final TerrainIssue? w = worst[leg];
|
|
if (w == null || clearance < w.clearance) worst[leg] = TerrainIssue(leg: leg, clearance: clearance, at: at);
|
|
}
|
|
final List<TerrainIssue> out = worst.values.toList()..sort((TerrainIssue a, TerrainIssue b) => a.leg.compareTo(b.leg));
|
|
return out;
|
|
}
|
|
}
|
|
|
|
/// Open-Meteo's free elevation API (Copernicus 90 m DEM, no key). Sends the
|
|
/// sampled coordinates to api.open-meteo.com, so the check only runs when the
|
|
/// pilot asks for it.
|
|
class OpenMeteoElevation implements ElevationSource {
|
|
const OpenMeteoElevation();
|
|
|
|
@override
|
|
Future<List<double>> elevations(List<LatLng> points) async {
|
|
final List<double> out = <double>[];
|
|
// 100 coordinates per request is the API's limit.
|
|
for (int i = 0; i < points.length; i += 100) {
|
|
final List<LatLng> chunk = points.sublist(i, (i + 100).clamp(0, points.length));
|
|
final Uri uri = Uri.https('api.open-meteo.com', '/v1/elevation', <String, String>{
|
|
'latitude': chunk.map((LatLng p) => p.latitude.toStringAsFixed(5)).join(','),
|
|
'longitude': chunk.map((LatLng p) => p.longitude.toStringAsFixed(5)).join(','),
|
|
});
|
|
final HttpClient client = HttpClient()..connectionTimeout = const Duration(seconds: 10);
|
|
try {
|
|
final HttpClientRequest req = await client.getUrl(uri);
|
|
final HttpClientResponse res = await req.close().timeout(const Duration(seconds: 15));
|
|
final String body = await res.transform(utf8.decoder).join();
|
|
if (res.statusCode != 200) throw HttpException('Elevation service answered ${res.statusCode}', uri: uri);
|
|
final Object? json = jsonDecode(body);
|
|
final Object? list = json is Map ? json['elevation'] : null;
|
|
if (list is! List) throw const FormatException('No elevation in the answer');
|
|
out.addAll(<double>[for (final Object? e in list) (e as num).toDouble()]);
|
|
} finally {
|
|
client.close();
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
}
|