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>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-20 23:06:16 +02:00
1 parent 91ca150a35
commit 636da1ac2c
57 files changed
+5363 -87

No files matched your search

+8 -2
View File
@@ -7,7 +7,7 @@ enum FlightCommand { takeOff, land, cancelLanding, confirmLanding, goHome, cance
/// What is running when the rail offers [FlightCommand.stopMode], i.e. which
/// operator "Stop" has to address.
enum ActiveMode { none, waypoint, activeTrack, tapFly, other }
enum ActiveMode { none, waypoint, activeTrack, tapFly, hotpoint, followMe, other }
/// The left rail's slots for the current flight state (plan P3, GO 4's
/// `fpv_leftmenu`): one primary button that walks Take-off → Land → Cancel
@@ -36,7 +36,7 @@ class RailState {
/// Flight-controller modes in which an intelligent mode is flying the aircraft.
static const Set<String> _waypointModes = <String>{'GPS_WAYPOINT'};
static const Set<String> _trackModes = <String>{'ACTIVE_TRACK', 'ACTIVE_TRACK_SPOTLIGHT'};
static const Set<String> _otherModes = <String>{'GPS_HOT_POINT', 'GPS_FOLLOW_ME', 'DRAW', 'TERRAIN_FOLLOW'};
static const Set<String> _otherModes = <String>{'DRAW', 'TERRAIN_FOLLOW'};
static RailState of({required bool connected, required AircraftState a, MissionState? mission}) {
final bool reported = connected && a.updatedAt != null;
@@ -74,11 +74,17 @@ class RailState {
return ActiveMode.activeTrack;
case 'tapfly':
return ActiveMode.tapFly;
case 'hotpoint':
return ActiveMode.hotpoint;
case 'followme':
return ActiveMode.followMe;
}
final String mode = a.flightModeEnum ?? '';
if (_waypointModes.contains(mode)) return ActiveMode.waypoint;
if (_trackModes.contains(mode)) return ActiveMode.activeTrack;
if (mode == 'TAP_FLY') return ActiveMode.tapFly;
if (mode == 'GPS_HOT_POINT') return ActiveMode.hotpoint;
if (mode == 'GPS_FOLLOW_ME') return ActiveMode.followMe;
if (_otherModes.contains(mode)) return ActiveMode.other;
return ActiveMode.none;
}
+73
View File
@@ -1,8 +1,13 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'rc_buttons.dart';
import 'units.dart';
import 'virtual_sticks.dart' show StickMode;
export 'rc_buttons.dart' show RcButton, RcButtonAction;
export 'units.dart' show UnitSystem;
/// The video overlay grid (camera menu → Other, and the rail's GRID button).
enum GridStyle {
off('Off'),
@@ -39,6 +44,13 @@ abstract final class FlyPrefs {
static const String _autoUploadKey = 'fly.autoUploadFlights';
static const String _liveUrlKey = 'fly.liveStreamUrl';
static const String _liveAudioKey = 'fly.liveStreamAudio';
static const String _unitsKey = 'fly.units';
static const String _c1Key = 'fly.rcC1';
static const String _c2Key = 'fly.rcC2';
static const String _videoCacheKey = 'fly.videoCache';
static const String _videoCacheLimitKey = 'fly.videoCacheLimitMb';
static const String _lensCalibratedKey = 'fly.lensCalibrated';
static const String _tutorialsKey = 'fly.modeTutorials';
/// Open the pre-P2 `FlightControlPage` instead of the HUD v2 — kept
/// reachable until P3's safety flows pass their simulator run (plan P2/P3).
@@ -77,6 +89,26 @@ abstract final class FlyPrefs {
/// Send the phone's microphone with the live stream.
static final ValueNotifier<bool> liveStreamAudio = ValueNotifier<bool>(false);
/// Metric or imperial readouts on the HUD and in settings (plan P6).
static final ValueNotifier<UnitSystem> units = ValueNotifier<UnitSystem>(UnitSystem.metric);
/// What the remote's C1 / C2 buttons do (plan P6); GO 4's defaults.
static final ValueNotifier<RcButtonAction> c1 = ValueNotifier<RcButtonAction>(RcButtonAction.gimbalRecenter);
static final ValueNotifier<RcButtonAction> c2 = ValueNotifier<RcButtonAction>(RcButtonAction.gimbalToggleDown);
/// Record the live feed to the phone while flying (GO 4's video cache, plan P6).
static final ValueNotifier<bool> videoCache = ValueNotifier<bool>(false);
/// The cache's size cap; the oldest recordings go first.
static final ValueNotifier<int> videoCacheLimitMb = ValueNotifier<int>(2048);
/// Cameras (by display name) whose manual-focus lens was calibrated here, so
/// the first-MF prompt is not repeated (plan P6b).
static final ValueNotifier<Set<String>> lensCalibrated = ValueNotifier<Set<String>>(<String>{});
/// Intelligent modes whose tutorial the pilot has seen (plan P7), by name.
static final ValueNotifier<Set<String>> seenTutorials = ValueNotifier<Set<String>>(<String>{});
static SharedPreferences? _prefs;
static Future<void> load() async {
@@ -95,6 +127,13 @@ abstract final class FlyPrefs {
autoUploadFlights.value = _prefs!.getBool(_autoUploadKey) ?? true;
liveStreamUrl.value = _prefs!.getString(_liveUrlKey) ?? '';
liveStreamAudio.value = _prefs!.getBool(_liveAudioKey) ?? false;
units.value = _byName(UnitSystem.values, _prefs!.getString(_unitsKey)) ?? UnitSystem.metric;
c1.value = _byName(RcButtonAction.values, _prefs!.getString(_c1Key)) ?? RcButtonAction.gimbalRecenter;
c2.value = _byName(RcButtonAction.values, _prefs!.getString(_c2Key)) ?? RcButtonAction.gimbalToggleDown;
videoCache.value = _prefs!.getBool(_videoCacheKey) ?? false;
videoCacheLimitMb.value = _prefs!.getInt(_videoCacheLimitKey) ?? 2048;
lensCalibrated.value = (_prefs!.getStringList(_lensCalibratedKey) ?? const <String>[]).toSet();
seenTutorials.value = (_prefs!.getStringList(_tutorialsKey) ?? const <String>[]).toSet();
} catch (_) {
// Storage unavailable: run on the defaults.
}
@@ -166,6 +205,40 @@ abstract final class FlyPrefs {
await _prefs?.setBool(_liveAudioKey, on);
}
static Future<void> setUnits(UnitSystem u) async {
units.value = u;
await _prefs?.setString(_unitsKey, u.name);
}
static Future<void> setRcButton(RcButton b, RcButtonAction a) async {
(b == RcButton.c1 ? c1 : c2).value = a;
await _prefs?.setString(b == RcButton.c1 ? _c1Key : _c2Key, a.name);
}
static RcButtonAction rcButton(RcButton b) => (b == RcButton.c1 ? c1 : c2).value;
static Future<void> setVideoCache(bool on) async {
videoCache.value = on;
await _prefs?.setBool(_videoCacheKey, on);
}
static Future<void> setVideoCacheLimitMb(int mb) async {
videoCacheLimitMb.value = mb;
await _prefs?.setInt(_videoCacheLimitKey, mb);
}
static Future<void> markLensCalibrated(String camera) async {
if (lensCalibrated.value.contains(camera)) return;
lensCalibrated.value = <String>{...lensCalibrated.value, camera};
await _prefs?.setStringList(_lensCalibratedKey, lensCalibrated.value.toList()..sort());
}
static Future<void> markTutorialSeen(String mode) async {
if (seenTutorials.value.contains(mode)) return;
seenTutorials.value = <String>{...seenTutorials.value, mode};
await _prefs?.setStringList(_tutorialsKey, seenTutorials.value.toList()..sort());
}
static T? _byName<T extends Enum>(List<T> values, String? name) {
for (final T v in values) {
if (v.name == name) return v;
+43
View File
@@ -120,6 +120,49 @@ class GimbalTuning {
];
}
/// GO 4's gimbal movement presets: one tap sets max speed and smoothness on
/// both axes, as fractions of each value's range on this gimbal (MSDK V4 has
/// no preset setter for aircraft gimbals, only the per-value ones).
enum GimbalPreset {
fast('Fast', 0.8, 0.2),
medium('Medium', 0.5, 0.5),
slow('Slow', 0.2, 0.8);
const GimbalPreset(this.label, this.speed, this.smoothing);
final String label;
/// Fraction of the max-speed range.
final double speed;
/// Fraction of the smoothing range.
final double smoothing;
static const List<String> speedIds = <String>['pitchMaxSpeed', 'yawMaxSpeed'];
static const List<String> smoothingIds = <String>['pitchSmoothing', 'yawSmoothing'];
/// The values this preset writes, for the tuning ids [ranges] covers.
Map<String, int> valuesFor(Map<String, AxisRange> ranges) => <String, int>{
for (final String id in speedIds)
if (ranges[id] case final AxisRange r) id: (r.min + (r.max - r.min) * speed).round(),
for (final String id in smoothingIds)
if (ranges[id] case final AxisRange r) id: (r.min + (r.max - r.min) * smoothing).round(),
};
/// The preset the current [settings] match (within a step), or null for a custom mix.
static GimbalPreset? matching(Map<String, AxisRange> ranges, Object? Function(String id) setting) {
for (final GimbalPreset p in values) {
final Map<String, int> want = p.valuesFor(ranges);
if (want.isEmpty) return null;
final bool all = want.entries.every((MapEntry<String, int> e) {
final Object? v = setting(e.key);
return v is num && (v - e.value).abs() <= GimbalTuning.stepFor(ranges[e.key]!);
});
if (all) return p;
}
return null;
}
}
/// The one-tap gimbal actions of the sheet and when they are refused.
abstract final class GimbalActions {
/// Fine-tune nudge per tap, degrees (GO 4's Adjust Gimbal step).
+9 -3
View File
@@ -95,9 +95,11 @@ abstract final class HudFeatures {
'overlay.radar', 'Vision radar', 'Overlays', (ProductProfile p) => p.hasObstacleSensing);
static final HudFeature virtualSticks = HudFeature(
'control.virtualSticks', 'On-screen sticks', 'Control', (ProductProfile p) => p.supportsVirtualStick);
/// Precision take-off records the spot with the downward cameras.
static final HudFeature precisionTakeoff = HudFeature(
'control.precisionTakeoff', 'Precision Takeoff option', 'Control', (ProductProfile p) => p.hasVps);
/// Precision take-off records the spot with the downward cameras. The flight
/// controller's own `IS_PRECISION_TAKE_OFF_SUPPORTED` decides once it has
/// answered; until then downward vision is the stand-in.
static final HudFeature precisionTakeoff = HudFeature('control.precisionTakeoff', 'Precision Takeoff option', 'Control',
(ProductProfile p) => p.flightController?.precisionTakeOffSupported ?? p.hasVps);
/// ADS-B In: the receiver's own reports decide at runtime; the profile only rules out non-aircraft.
static final HudFeature airSense = HudFeature(
'overlay.airSense', 'AirSense (ADS-B) warnings', 'Overlays', (ProductProfile p) => p.isAircraft);
@@ -123,6 +125,9 @@ abstract final class HudFeatures {
(ProductProfile p) => p.link == LinkType.lightbridge);
static final HudFeature linkWifi = HudFeature(
'settings.wifi', 'Wi-Fi transmission settings', 'Settings', (ProductProfile p) => p.link == LinkType.wifi);
/// C1 / C2: every remote with custom buttons; Wi-Fi-only flying has none.
static final HudFeature customButtons = HudFeature(
'settings.customButtons', 'C1 / C2 button actions', 'Settings', (ProductProfile p) => p.remoteController != null && p.hasRcLink);
// ── Calibration & maintenance wizards (plan P6b) ──────────────────────────
// Runtime support (`CalibrationState`) can still drop a wizard the profile allows.
@@ -194,6 +199,7 @@ abstract final class HudFeatures {
linkOcuSync,
linkLightbridge,
linkWifi,
customButtons,
calibrationCompass,
calibrationImu,
calibrationRc,
+99 -9
View File
@@ -5,7 +5,7 @@ import '../product_profile.dart';
///
/// Which modes a product has comes from [ProductProfile.modes] (the product
/// matrix until runtime capture confirms it); which of those this app can fly
/// yet is [ModeInfo.built]. A mode that is supported but not built is listed
/// is [ModeInfo.built]. A mode that is supported but not built is listed
/// greyed out rather than hidden, so the pilot sees what is coming.
class ModeInfo {
const ModeInfo({
@@ -15,6 +15,7 @@ class ModeInfo {
required this.group,
required this.blurb,
required this.terms,
this.tutorial = const <String>[],
this.built = false,
});
@@ -33,6 +34,10 @@ class ModeInfo {
/// The first-use disclaimer: what the pilot stays responsible for.
final List<String> terms;
/// GO 4's first-use tutorial: how to fly the mode, step by step. Shown once
/// after the terms, and again from the mode's panel.
final List<String> tutorial;
/// This app can fly the mode.
final bool built;
}
@@ -43,6 +48,13 @@ abstract final class ModeCatalog {
'The aircraft flies itself in this mode. Push the remote\'s pause button, or tap Stop, to make it hover.',
];
static const List<String> _waypointTutorial = <String>[
'Tap the map to drop waypoints; tap a waypoint to edit its altitude, speed, heading, gimbal and camera action.',
'Long-press and drag a waypoint to move it. The + between two waypoints inserts one there.',
'ROUTE sets the cruise speed, the heading and what happens at the end; LIBRARY saves the route.',
'START checks the route against the aircraft, uploads it and flies it. PAUSE holds the aircraft where it is.',
];
static const List<ModeInfo> all = <ModeInfo>[
ModeInfo(
mode: IntelligentMode.waypoint,
@@ -56,6 +68,7 @@ abstract final class ModeCatalog {
'The route flies straight between waypoints at the altitudes you set. It does not avoid terrain, trees or buildings: check the route and its altitudes before you start.',
'If the remote loses signal the aircraft follows the route\'s signal-lost setting.',
],
tutorial: _waypointTutorial,
),
ModeInfo(
mode: IntelligentMode.waypointV2,
@@ -63,7 +76,12 @@ abstract final class ModeCatalog {
icon: 'route',
group: 'Map',
blurb: 'Enterprise waypoint missions.',
terms: _common,
built: true,
terms: <String>[
..._common,
'Waypoints 2.0 flies the route\'s positions, altitudes, speeds and headings. Camera actions are not sent to the aircraft yet: run the camera yourself.',
],
tutorial: _waypointTutorial,
),
ModeInfo(
mode: IntelligentMode.pointOfInterest,
@@ -71,7 +89,17 @@ abstract final class ModeCatalog {
icon: 'circle',
group: 'Map',
blurb: 'Circle a subject at a set radius.',
terms: _common,
built: true,
terms: <String>[
..._common,
'The aircraft circles at a fixed height and does not brake for obstacles on the circle: check the whole circle is clear.',
],
tutorial: <String>[
'Tap the map on the subject, or AIRCRAFT to use where the aircraft is now.',
'Set the radius, the altitude and the speed; pick the direction and which way the aircraft faces.',
'GO flies to the circle and starts circling. Radius and speed can change while it circles.',
'The remote\'s sticks nudge the orbit; PAUSE holds position, STOP ends it.',
],
),
ModeInfo(
mode: IntelligentMode.followMe,
@@ -79,7 +107,18 @@ abstract final class ModeCatalog {
icon: 'user',
group: 'Map',
blurb: 'The aircraft follows this phone.',
terms: _common,
built: true,
terms: <String>[
..._common,
'The aircraft follows the phone\'s GPS, which can be off by several metres, and it does not avoid obstacles while it follows.',
'Keep the phone with you and its screen on: if the app stops sending your position the aircraft stops following and hovers.',
],
tutorial: <String>[
'Take off and climb to at least 10 m with open space around you.',
'Set the height to follow at and whether the aircraft faces you or the remote steers its heading.',
'GO: the aircraft keeps its distance as you walk. Keep the app open.',
'STOP, or the remote\'s pause button, ends it.',
],
),
ModeInfo(
mode: IntelligentMode.activeTrack,
@@ -93,6 +132,12 @@ abstract final class ModeCatalog {
'ActiveTrack follows what the camera sees. It can lose the subject behind trees or in crowds, and it does not see obstacles to the sides or behind on every aircraft.',
'Do not use it to follow cars or boats at speed, or over water and roads you cannot see the end of.',
],
tutorial: <String>[
'Drag a box round the subject on the video (or tap a person-sized one), then GO.',
'Trace follows behind, Profile alongside, Spotlight keeps the camera on it while you fly.',
'A green box is locked on, amber is unsure; confirm with ✓ when the aircraft asks.',
'Gestures: with Gesture mode on (Settings → Vision), raise your arms to be tracked, frame your fingers for a photo.',
],
),
ModeInfo(
mode: IntelligentMode.tapFly,
@@ -105,6 +150,11 @@ abstract final class ModeCatalog {
..._common,
'TapFly keeps flying towards the point until you stop it. Obstacle avoidance only works in the direction the sensors face, and not at all in Free.',
],
tutorial: <String>[
'Tap where to fly on the video, then GO.',
'Forward flies towards the point; Backward away from it; Free lets you turn the aircraft while it flies.',
'Set the speed with − / +; tap a new point to change course.',
],
),
ModeInfo(
mode: IntelligentMode.quickShots,
@@ -112,7 +162,16 @@ abstract final class ModeCatalog {
icon: 'dronie',
group: 'Shots',
blurb: 'Dronie, Rocket, Circle, Helix…',
terms: _common,
built: true,
terms: <String>[
..._common,
'A QuickShot flies a set path around the subject, up to 120 m away and high: keep that space clear.',
],
tutorial: <String>[
'Pick the shot, draw a box round the subject on the video, then GO.',
'The aircraft records the video, flies the path and comes back to where it started.',
'STOP, the sticks or the remote\'s pause button end it early.',
],
),
ModeInfo(
mode: IntelligentMode.hyperlapse,
@@ -120,7 +179,16 @@ abstract final class ModeCatalog {
icon: 'hyperlapse',
group: 'Shots',
blurb: 'A moving timelapse.',
terms: _common,
built: true,
terms: <String>[
'You fly the aircraft during a Free hyperlapse; the camera only shoots the frames. Fly slowly and steadily.',
'Stop the sequence before landing or flying home.',
],
tutorial: <String>[
'Set the interval between frames and how long the finished video should be.',
'START shoots the frames; fly a slow, steady line meanwhile.',
'The frames are on the SD card; assemble them into the video on a computer.',
],
),
ModeInfo(
mode: IntelligentMode.courseLock,
@@ -128,7 +196,15 @@ abstract final class ModeCatalog {
icon: 'compass',
group: 'Orientation',
blurb: 'Forward stays the current heading.',
terms: _common,
built: true,
terms: <String>[
'Course Lock changes what the sticks do: forward is the locked course, whichever way the aircraft faces. Turn it off before flying normally again.',
],
tutorial: <String>[
'Point the aircraft where "forward" should be, then turn Course Lock on.',
'Turn the aircraft freely: the sticks keep flying the locked course.',
'LOCK HEADING takes the current heading as the new course.',
],
),
ModeInfo(
mode: IntelligentMode.homeLock,
@@ -136,7 +212,14 @@ abstract final class ModeCatalog {
icon: 'home',
group: 'Orientation',
blurb: 'Pulling back always flies home.',
terms: _common,
built: true,
terms: <String>[
'Home Lock changes what the sticks do: back is always towards home, whichever way the aircraft faces. Turn it off before flying normally again.',
],
tutorial: <String>[
'Fly at least 10 m from home, then turn Home Lock on.',
'Pull the stick back to come home, push it to fly away, whichever way the aircraft points.',
],
),
ModeInfo(
mode: IntelligentMode.tripod,
@@ -144,7 +227,14 @@ abstract final class ModeCatalog {
icon: 'gauge',
group: 'Orientation',
blurb: 'Slow, precise control for framing.',
terms: _common,
built: true,
terms: <String>[
'Tripod mode limits the speed to about 1 m/s and makes the sticks very gentle. Turn it off to fly away from danger quickly.',
],
tutorial: <String>[
'Tripod: about 1 m/s and gentle sticks, for framing and indoor-tight spaces.',
'Cinematic: smooth braking and slow turns for steady video; set how gently it brakes and turns.',
],
),
];
+283
View File
@@ -0,0 +1,283 @@
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();
}
}
+139
View File
@@ -0,0 +1,139 @@
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;
}
}
+31 -1
View File
@@ -2,6 +2,8 @@ import 'dart:ui' show Offset, Rect, Size;
import 'package:flutter/foundation.dart';
import 'orbit_modes.dart' show QuickShot;
/// What the pilot has chosen in an ActiveTrack or TapFly session, before and
/// while the operator runs: shared by the video overlay and the mode panel.
class VisionSession extends ChangeNotifier {
@@ -9,6 +11,22 @@ class VisionSession extends ChangeNotifier {
TapFlyDirection direction = TapFlyDirection.forward;
double speed = 5;
/// ActiveTrack Circle's orbit, m/s; negative goes anticlockwise.
double circleSpeed = 3;
/// The QuickShot the next box flies.
QuickShot quickShot = QuickShot.dronie;
void setCircleSpeed(double v) {
circleSpeed = v;
notifyListeners();
}
void setQuickShot(QuickShot s) {
quickShot = s;
notifyListeners();
}
/// A box drawn (or tapped) on the video, 0..1, waiting for GO.
Rect? pendingBox;
@@ -77,12 +95,24 @@ enum TrackMode {
profile('Profile', 'PROFILE'),
/// Stays put and keeps the camera on it; the pilot flies.
spotlight('Spotlight', 'SPOTLIGHT');
spotlight('Spotlight', 'SPOTLIGHT'),
/// Spotlight that also turns the aircraft (the gimbal alone cannot keep
/// up): Mavic 2-class aircraft.
spotlightPro('Spotlight Pro', 'SPOTLIGHT_PRO'),
/// Orbits the subject at [VisionSession.circleSpeed].
circle('Circle', 'CIRCLE');
const TrackMode(this.label, this.sdk);
final String label;
final String sdk;
/// The follow styles GO 4 offers: Spotlight Pro and Circle came with the
/// omni-sensing aircraft (Mavic 2); everything else has the first three.
static List<TrackMode> offered({required bool omni}) =>
<TrackMode>[trace, profile, spotlight, if (omni) spotlightPro, if (omni) circle];
static TrackMode? fromSdk(String? s) {
for (final TrackMode m in values) {
if (m.sdk == s) return m;
@@ -365,6 +365,21 @@ class WaypointMissionSpec {
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.
+5
View File
@@ -226,9 +226,11 @@ class FlightControllerCaps {
this.accessLockerSupported,
this.onboardSdkAvailable,
this.simulatorAvailable,
this.precisionTakeOffSupported,
});
factory FlightControllerCaps.fromMap(Map<String, dynamic> m) => FlightControllerCaps(
precisionTakeOffSupported: m['precisionTakeOffSupported'] as bool?,
flightAssistantSupported: m['flightAssistantSupported'] as bool?,
virtualStickAvailable: m['virtualStickAvailable'] as bool?,
rtkSupported: m['rtkSupported'] as bool?,
@@ -251,6 +253,9 @@ class FlightControllerCaps {
final bool? accessLockerSupported;
final bool? onboardSdkAvailable;
final bool? simulatorAvailable;
/// `IS_PRECISION_TAKE_OFF_SUPPORTED`; null until the flight controller answered.
final bool? precisionTakeOffSupported;
}
class RemoteControllerCaps {
+35
View File
@@ -0,0 +1,35 @@
/// What a remote's custom button (C1 / C2) does (GO 4's RC → Button
/// Customization, plan P6).
///
/// On the consumer remotes MSDK V4 only reports that C1 / C2 was pressed
/// (`RemoteControllerKey.CUSTOM_BUTTON_1/2`): the action is the app's, as it is
/// in GO 4. The flight screen performs it; nothing is written to the remote.
enum RcButtonAction {
none('Not set'),
gimbalRecenter('Recenter gimbal'),
gimbalToggleDown('Gimbal forward / down'),
mapSwap('Map / camera view'),
cameraSettings('Camera settings'),
aircraftStatus('Aircraft status'),
aeLock('AE lock on / off'),
radar('Radar chart on / off');
const RcButtonAction(this.label);
final String label;
}
/// A press of one of the remote's custom buttons.
enum RcButton {
c1('C1'),
c2('C2');
const RcButton(this.label);
final String label;
/// The `rcButton` event's `button` value.
static RcButton? fromEvent(Object? v) => switch (v) {
'C1' => RcButton.c1,
'C2' => RcButton.c2,
_ => null,
};
}
+327 -1
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:ui' show Offset, Rect;
import 'package:flutter/foundation.dart';
@@ -10,6 +11,7 @@ import 'calibration.dart';
import 'flight_status.dart';
import 'fly_prefs.dart';
import 'hud_geometry.dart';
import 'modes/orbit_modes.dart';
import 'modes/vision_modes.dart';
import 'modes/waypoint_mission.dart';
import 'product_profile.dart';
@@ -158,6 +160,8 @@ class SafetyGate {
case ActiveMode.waypoint:
case ActiveMode.activeTrack:
case ActiveMode.tapFly:
case ActiveMode.hotpoint:
case ActiveMode.followMe:
break;
}
}
@@ -421,6 +425,8 @@ class SafetyGate {
checks.add(const GateCheck.block('The mission system is not ready yet. Wait a moment.'));
}
if (model.mission.tracking) checks.add(const GateCheck.block('Stop ActiveTrack first.'));
if (model.mission.hotpoint.active) checks.add(const GateCheck.block('Stop Point of Interest first.'));
if (model.mission.followMe.active) checks.add(const GateCheck.block('Stop Follow Me first.'));
for (final MissionIssue i in spec.validate(limits)) {
checks.add(i.blocks ? GateCheck.block(i.message) : GateCheck.warn(i.message));
@@ -472,6 +478,9 @@ class SafetyGate {
if (shoots && model.camera.storage != null && !model.camera.storage!.usable) {
checks.add(GateCheck.warn("${model.camera.storage!.status}: the route's photos and video will not be saved."));
}
if (usesWaypointsV2 && (shoots || spec.points.any((WaypointSpec p) => p.hoverSeconds > 0 || p.gimbalPitch != null))) {
checks.add(const GateCheck.warn('Waypoints 2.0: the route flies, but its camera actions, hovers and gimbal angles are not sent yet.'));
}
return checks;
}
@@ -489,11 +498,42 @@ class SafetyGate {
final int h = rthHeight.clamp(minRthHeight, maxRthHeight);
if (h != _a.goHomeHeight?.round()) await dji.setGoHomeHeight(h);
}
await dji.uploadWaypointMission(spec.toBridge(gimbalMin: limits.gimbalMin, gimbalMax: limits.gimbalMax));
await dji.uploadWaypointMission(spec.toBridge(gimbalMin: limits.gimbalMin, gimbalMax: limits.gimbalMax), v2: usesWaypointsV2);
onUploaded?.call();
await dji.startWaypointMission();
}
/// The connected aircraft flies Waypoints 2.0 only (Matrice 300): the same
/// route goes to the V2 operator.
bool get usesWaypointsV2 =>
model.profile.supportsMode(IntelligentMode.waypointV2) && !model.profile.supportsMode(IntelligentMode.waypoint);
/// The cruise speed of the mission in flight, from the run panel.
Future<void> setWaypointCruiseSpeed(double speed) async {
if (!model.mission.waypoint.active) {
throw const MissionRefused(<GateCheck>[GateCheck.block('No waypoint mission is flying.')]);
}
await dji.setWaypointAutoFlightSpeed(speed.clamp(WaypointMissionSpec.minSpeed, WaypointMissionSpec.maxSpeed));
}
/// Where the last mission was cut short, if it belongs to [spec]: the
/// waypoint (0-based) to resume from, else null.
Future<int?> interruptionFor(WaypointMissionSpec spec) async {
if (!model.connected || spec.points.length < 2) return null;
try {
final Map<String, dynamic>? i = await dji.getWaypointInterruption();
if (i == null) return null;
final int? index = (i['index'] as num?)?.toInt();
final double? lat = (i['lat'] as num?)?.toDouble(), lon = (i['lon'] as num?)?.toDouble();
if (index == null || lat == null || lon == null || index <= 0) return null;
// A resume needs two waypoints left to fly.
if (index > spec.points.length - 2) return null;
return spec.matchesInterruption(index, LatLng(lat, lon)) ? index : null;
} catch (_) {
return null;
}
}
/// Holds the aircraft where it is; the mission can resume from there.
Future<void> pauseWaypointMission() async {
if (!model.mission.waypoint.executing) {
@@ -544,6 +584,8 @@ class SafetyGate {
if (_a.goingHome == true || mode == 'GO_HOME') checks.add(const GateCheck.block('Cancel Return to Home first.'));
if (mode == 'AUTO_LANDING' || mode == 'CONFIRM_LANDING') checks.add(const GateCheck.block('The aircraft is landing.'));
if (model.mission.waypoint.active) checks.add(const GateCheck.block('Stop the waypoint mission first.'));
if (model.mission.hotpoint.active) checks.add(const GateCheck.block('Stop Point of Interest first.'));
if (model.mission.followMe.active) checks.add(const GateCheck.block('Stop Follow Me first.'));
if (mode == 'ATTI' || mode == 'GPS_SPORT' || mode == 'MANUAL') {
checks.add(const GateCheck.block('Switch the remote to P mode first.'));
}
@@ -667,6 +709,282 @@ class SafetyGate {
await dji.setTapFlySpeed(speed.clamp(tapFlyMinSpeed, tapFlyMaxSpeed));
}
/// ActiveTrack Circle's orbit speed, m/s (negative anticlockwise).
Future<void> setTrackCircleSpeed(double speed) async {
if (!model.connected) throw const MissionRefused(<GateCheck>[GateCheck.block('No aircraft connected.')]);
await dji.setActiveTrackCircularSpeed(speed.clamp(-10.0, 10.0));
}
// ── QuickShots (plan P7) ────────────────────────────────────────────────────
/// Preconditions for flying [shot] around a boxed subject: ActiveTrack's,
/// plus room for the aircraft to climb and fly away.
List<GateCheck> checkQuickShot(QuickShot shot) {
final List<GateCheck> checks = <GateCheck>[];
if (!model.connected) return checks..add(const GateCheck.block('No aircraft connected.'));
if (_a.updatedAt == null) return checks..add(const GateCheck.block('Waiting for the flight controller to report.'));
if (model.mission.activeTrack.unsupported) return checks..add(const GateCheck.block('This aircraft cannot fly QuickShots.'));
_visionCommon(checks);
if (model.mission.tapFly.active) checks.add(const GateCheck.block('Stop TapFly first.'));
if (model.mission.activeTrack.active) checks.add(const GateCheck.block('Stop ActiveTrack first.'));
if (model.camera.storage != null && !model.camera.storage!.usable) {
checks.add(GateCheck.block('${model.camera.storage!.status}: the QuickShot video would not be saved.'));
}
final int? left = _a.goHome?.remainingFlightTime;
if (left != null && left > 0 && left < 120) checks.add(const GateCheck.warn('Under 2 minutes of flight left: the shot may be cut short.'));
switch (shot) {
case QuickShot.rocket:
case QuickShot.dronie:
case QuickShot.asteroid:
case QuickShot.helix:
checks.add(const GateCheck.warn('The aircraft climbs up to 120 m and flies away from the subject: keep the space above and behind clear.'));
case QuickShot.circle:
case QuickShot.boomerang:
checks.add(const GateCheck.warn('The aircraft flies around the subject: keep the circle clear.'));
case QuickShot.dollyZoom:
checks.add(const GateCheck.warn('The aircraft backs away from the subject: keep the space behind it clear.'));
}
if (!model.profile.hasObstacleSensing) {
checks.add(const GateCheck.warn('This aircraft cannot see obstacles on the way.'));
}
return checks;
}
/// Re-checks and flies [shot] around the subject in [rect] (0..1 of the live view).
Future<void> startQuickShot(Rect rect, QuickShot shot) async {
final List<GateCheck> checks = checkQuickShot(shot);
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
final Rect r = Rect.fromLTRB(rect.left.clamp(0, 1), rect.top.clamp(0, 1), rect.right.clamp(0, 1), rect.bottom.clamp(0, 1));
await dji.startQuickShot(shot.sdk, x: r.left, y: r.top, w: r.width, h: r.height);
}
// ── Point of Interest (plan P7) ─────────────────────────────────────────────
/// What a mission-type mode needs before it may fly the aircraft on its own:
/// connected, airborne in P mode with GPS, nothing else in charge, battery.
void _autoCommon(List<GateCheck> checks, String name) {
final String mode = _a.flightModeEnum ?? '';
if (_a.isFlying != true) checks.add(GateCheck.block('Take off before starting $name.'));
if (_a.goingHome == true || mode == 'GO_HOME') checks.add(const GateCheck.block('Cancel Return to Home first.'));
if (mode == 'AUTO_LANDING' || mode == 'CONFIRM_LANDING') checks.add(const GateCheck.block('The aircraft is landing.'));
if (mode == 'ATTI' || mode == 'GPS_SPORT' || mode == 'MANUAL') checks.add(const GateCheck.block('Switch the remote to P mode first.'));
final ActiveMode running = RailState.activeMode(_a, model.mission);
if (running != ActiveMode.none) checks.add(const GateCheck.block('Stop the intelligent flight mode that is running first.'));
if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel) checks.add(GateCheck.block('GPS too weak for $name.'));
final int? percent = model.battery.percent;
final int critical = model.battery.seriousLowWarningThreshold ?? 15;
final int low = model.battery.lowWarningThreshold ?? 30;
if (_a.seriousLowBattery == true || (percent != null && percent <= critical)) {
checks.add(GateCheck.block('Battery critically low${percent == null ? '' : ' ($percent%)'}.'));
} else if (_a.lowBattery == true || (percent != null && percent <= low)) {
checks.add(GateCheck.warn('Battery low${percent == null ? '' : ' ($percent%)'}.'));
}
if (_a.compassHasError == true) checks.add(const GateCheck.block('Compass error. Calibrate the compass or move away from metal.'));
}
/// Preconditions for circling [spec]; [maxAngular] is the aircraft's limit at its radius.
List<GateCheck> checkPoi(PoiSpec spec, {double? maxAngular}) {
final List<GateCheck> checks = <GateCheck>[];
if (!model.connected) return checks..add(const GateCheck.block('No aircraft connected.'));
if (_a.updatedAt == null) return checks..add(const GateCheck.block('Waiting for the flight controller to report.'));
if (model.mission.hotpoint.unsupported) return checks..add(const GateCheck.block('This aircraft cannot fly Point of Interest.'));
_autoCommon(checks, 'Point of Interest');
final double? lat = _a.latitude, lon = _a.longitude;
for (final (bool blocks, String m) in spec.issues(maxAngular: maxAngular, aircraft: lat == null || lon == null ? null : LatLng(lat, lon))) {
checks.add(blocks ? GateCheck.block(m) : GateCheck.warn(m));
}
// POI flies sideways round the circle, where most aircraft have no sensors.
checks.add(const GateCheck.warn('The aircraft does not brake for obstacles on the circle: check it is clear.'));
final int? left = _a.goHome?.remainingFlightTime;
if (left != null && left > 0 && left < spec.lapSeconds + (_a.goHome?.timeNeededToGoHome ?? 0)) {
checks.add(GateCheck.warn('One lap takes about ${formatDuration(spec.lapSeconds)}: more than the battery has to spare.'));
}
return checks;
}
Future<void> startPoi(PoiSpec spec, {double? maxAngular}) async {
final List<GateCheck> checks = checkPoi(spec, maxAngular: maxAngular);
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
final Map<String, Object?> m = spec.toBridge(maxAngular: maxAngular);
await dji.startHotpoint(
lat: m['lat']! as double,
lon: m['lon']! as double,
altitude: (m['altitude']! as num).toDouble(),
radius: (m['radius']! as num).toDouble(),
angularVelocity: (m['angularVelocity']! as num).toDouble(),
clockwise: m['clockwise']! as bool,
startPoint: m['startPoint']! as String,
heading: m['heading']! as String,
);
}
Future<void> pausePoi() async {
if (!model.mission.hotpoint.active || model.mission.hotpoint.paused) {
throw const MissionRefused(<GateCheck>[GateCheck.block('Point of Interest is not circling.')]);
}
await dji.pauseHotpoint();
}
Future<void> resumePoi() async {
final List<GateCheck> checks = <GateCheck>[
if (!model.mission.hotpoint.paused) const GateCheck.block('Point of Interest is not paused.'),
if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel) const GateCheck.block('GPS too weak to carry on.'),
];
if (checks.isNotEmpty) throw MissionRefused(checks);
await dji.resumeHotpoint();
}
/// Changes the orbit in flight; [speed] m/s becomes °/s on the current radius.
Future<void> adjustPoi({double? radius, double? speed, required bool clockwise, required double currentRadius}) async {
if (!model.mission.hotpoint.active) throw const MissionRefused(<GateCheck>[GateCheck.block('Point of Interest is not running.')]);
if (radius != null) await dji.setHotpointRadius(radius.clamp(PoiSpec.minRadius, PoiSpec.maxRadius));
if (speed != null) {
final double r = radius ?? currentRadius;
final double w = PoiSpec.angularFor(speed.clamp(PoiSpec.minSpeed, PoiSpec.maxSpeed), r);
await dji.setHotpointAngularVelocity(clockwise ? w : -w);
}
}
// ── Follow Me (plan P7) ─────────────────────────────────────────────────────
/// Preconditions for following this phone. [phoneAccuracy] is its fix, m.
List<GateCheck> checkFollowMe(FollowMeSpec spec, {double? phoneAccuracy}) {
final List<GateCheck> checks = <GateCheck>[];
if (!model.connected) return checks..add(const GateCheck.block('No aircraft connected.'));
if (_a.updatedAt == null) return checks..add(const GateCheck.block('Waiting for the flight controller to report.'));
if (model.mission.followMe.unsupported) return checks..add(const GateCheck.block('This aircraft cannot run Follow Me.'));
_autoCommon(checks, 'Follow Me');
final double? pLat = model.phoneLatitude, pLon = model.phoneLongitude;
if (pLat == null || pLon == null) {
checks.add(const GateCheck.block('This phone has no location fix: Follow Me follows the phone.'));
} else {
if (phoneAccuracy != null && phoneAccuracy > FollowMeSpec.maxAccuracy) {
checks.add(GateCheck.warn('The phone\'s fix is only good to ${phoneAccuracy.round()} m: the aircraft will wander.'));
}
final double? lat = _a.latitude, lon = _a.longitude;
if (lat != null && lon != null) {
final double d = HudGeometry.distance(lat, lon, pLat, pLon);
if (d > 200) checks.add(GateCheck.warn('The aircraft is ${d.round()} m from you: it flies to you first.'));
}
}
if (spec.altitude < FollowMeSpec.minAltitude) checks.add(const GateCheck.block('Follow at 10 m or higher.'));
if (spec.altitude < 20) checks.add(const GateCheck.warn('Low: trees and lamp posts along your way are not avoided.'));
checks.add(const GateCheck.warn('The aircraft does not avoid obstacles while it follows you.'));
return checks;
}
Future<void> startFollowMe(FollowMeSpec spec) async {
final List<GateCheck> checks = checkFollowMe(spec);
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
await dji.startFollowMe(lat: model.phoneLatitude!, lon: model.phoneLongitude!, altitude: spec.altitude, heading: spec.heading.sdk);
}
/// Sends the phone's fix while following; ignored once it is not.
Future<void> updateFollowMe() async {
final double? lat = model.phoneLatitude, lon = model.phoneLongitude;
if (!model.mission.followMe.active || lat == null || lon == null) return;
await dji.updateFollowMeTarget(lat, lon);
}
// ── Orientation, Tripod, Cinematic (plan P7) ────────────────────────────────
/// Preconditions for switching Intelligent Orientation Control to [mode].
List<GateCheck> checkOrientation(OrientationMode mode) {
final List<GateCheck> checks = <GateCheck>[];
if (!model.connected) return checks..add(const GateCheck.block('No aircraft connected.'));
if (_a.updatedAt == null) return checks..add(const GateCheck.block('Waiting for the flight controller to report.'));
if (mode == OrientationMode.normal) return checks;
if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel) checks.add(GateCheck.block('GPS too weak for ${mode.label}.'));
if (mode == OrientationMode.homeLock) {
if (_a.homeLocationSet != true) checks.add(const GateCheck.block('No home point recorded yet.'));
final double? d = _a.homeDistance;
if (d != null && d < homeLockMinDistance) checks.add(const GateCheck.block('Fly 10 m from home first: Home Lock needs a direction to pull back along.'));
}
final String fm = _a.flightModeEnum ?? '';
if (fm == 'GPS_SPORT' || fm == 'ATTI') checks.add(const GateCheck.block('Switch the remote to P mode first.'));
checks.add(GateCheck.warn(mode == OrientationMode.courseLock
? 'Forward on the stick is now the locked course, whichever way the aircraft faces.'
: 'Pulling back now always flies towards home, whichever way the aircraft faces.'));
return checks;
}
Future<void> setOrientation(OrientationMode mode) async {
final List<GateCheck> checks = checkOrientation(mode);
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
await dji.setFlightOrientationMode(mode.sdk);
}
/// Course Lock takes the current heading as "forward".
Future<void> lockCourse() async {
if (_a.orientationMode != 'COURSE_LOCK') throw const MissionRefused(<GateCheck>[GateCheck.block('Course Lock is not on.')]);
await dji.lockCourseUsingCurrentHeading();
}
/// Tripod (slow, fine control) or Cinematic (smooth braking) on or off.
/// Turning one on is a speed change the pilot feels at once, so it warns.
List<GateCheck> checkSlowMode({required bool cinematic, required bool on}) => <GateCheck>[
if (!model.connected) const GateCheck.block('No aircraft connected.'),
if (model.connected && on && RailState.activeMode(_a, model.mission) != ActiveMode.none)
const GateCheck.block('Stop the intelligent flight mode that is running first.'),
if (model.connected && on)
GateCheck.warn(cinematic
? 'The aircraft brakes and turns slowly: give it room to stop.'
: 'Speed drops to about 1 m/s and the sticks become very gentle.'),
];
Future<void> setSlowMode({required bool cinematic, required bool on}) async {
final List<GateCheck> checks = checkSlowMode(cinematic: cinematic, on: on);
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
await dji.setKey(DjiKey.flightController(cinematic ? 'CINEMATIC_MODE_ENABLED' : 'TRIPOD_MODE_ENABLED'), on);
}
// ── Hyperlapse (plan P7) ────────────────────────────────────────────────────
/// Preconditions for a Free hyperlapse: a camera that shoots intervals, a
/// card with room, and nothing else shooting.
List<GateCheck> checkHyperlapse(HyperlapseSpec spec) {
final List<GateCheck> checks = <GateCheck>[];
if (!model.connected) return checks..add(const GateCheck.block('No aircraft connected.'));
if (!model.profile.hasCamera) return checks..add(const GateCheck.block('This aircraft has no camera.'));
if (model.camera.isRecording) checks.add(const GateCheck.block('Stop recording first.'));
if (model.camera.shootingContinuous) checks.add(const GateCheck.block('The camera is already shooting a sequence.'));
final int? shots = model.camera.storage?.availableCaptureCount;
if (model.camera.storage != null && !model.camera.storage!.usable) {
checks.add(GateCheck.block('${model.camera.storage!.status}: the frames would not be saved.'));
} else if (shots != null && shots < spec.frames) {
checks.add(GateCheck.block('The card has room for $shots photos; this hyperlapse needs ${spec.frames}.'));
}
final int? left = _a.goHome?.remainingFlightTime;
if (left != null && left > 0 && left < spec.shootingSeconds + (_a.goHome?.timeNeededToGoHome ?? 0)) {
checks.add(GateCheck.warn('Shooting takes ${formatDuration(spec.shootingSeconds)}: more than the battery has to spare.'));
}
if (_a.isFlying != true) checks.add(const GateCheck.warn('The aircraft is on the ground: take off, then fly slowly and steadily.'));
return checks;
}
/// Ends an open-ended (255) interval sequence once it has its frames.
Timer? _hyperlapseStop;
/// Starts the interval sequence; the camera's own interval mode runs it.
Future<void> startHyperlapse(HyperlapseSpec spec) async {
final List<GateCheck> checks = checkHyperlapse(spec);
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
await dji.setCameraMode('photo');
await dji.setShootPhotoMode('INTERVAL');
await dji.setPhotoTimeInterval(count: spec.cameraCount, seconds: spec.intervalSeconds);
await dji.startShootPhoto();
_hyperlapseStop?.cancel();
if (spec.cameraCount == 255) {
_hyperlapseStop = Timer(Duration(seconds: spec.shootingSeconds), () => dji.stopShootPhoto().catchError((Object _) {}));
}
}
Future<void> stopHyperlapse() {
_hyperlapseStop?.cancel();
_hyperlapseStop = null;
return dji.stopShootPhoto();
}
// ── Virtual sticks (plan P8) ────────────────────────────────────────────────
/// Preconditions for handing the aircraft to the on-screen sticks.
@@ -691,6 +1009,10 @@ class SafetyGate {
checks.add(const GateCheck.block('Stop ActiveTrack first.'));
case ActiveMode.tapFly:
checks.add(const GateCheck.block('Stop TapFly first.'));
case ActiveMode.hotpoint:
checks.add(const GateCheck.block('Stop Point of Interest first.'));
case ActiveMode.followMe:
checks.add(const GateCheck.block('Stop Follow Me first.'));
case ActiveMode.other:
checks.add(const GateCheck.block('Stop the intelligent flight mode first.'));
case ActiveMode.none:
@@ -791,6 +1113,10 @@ class SafetyGate {
await dji.stopActiveTrack();
case ActiveMode.tapFly:
await dji.stopTapFly();
case ActiveMode.hotpoint:
await dji.stopHotpoint();
case ActiveMode.followMe:
await dji.stopFollowMe();
case ActiveMode.none:
case ActiveMode.other:
break; // refused by check()
@@ -214,6 +214,41 @@ class KeyReadoutSpec extends SettingSpec {
}
}
/// A string the pilot types (aircraft name, a remote's name). [secret] masks
/// it and never shows the stored value; [maxLength] caps what is typed;
/// [send] replaces the plain `setKey` for values the SDK only takes through a
/// bespoke method.
class KeyTextSpec extends SettingSpec {
const KeyTextSpec({
required super.id,
required super.label,
required DjiKey key,
this.secret = false,
this.maxLength = 32,
this.send,
super.hint,
super.requires,
super.visibleWhen,
super.groundOnly,
}) : _key = key;
final DjiKey _key;
final bool secret;
final int maxLength;
final Future<void> Function(DjiService dji, String value)? send;
@override
DjiKey get key => _key;
String? current(KeyState s) => asString(s.value(_key));
String shown(KeyState s) {
final String? v = current(s);
if (secret) return v == null || v.isEmpty ? '—' : '••••••';
return v == null || v.isEmpty ? '—' : v;
}
}
/// A button that runs an argument-less action key (`START_PAIRING`).
class KeyActionSpec extends SettingSpec {
const KeyActionSpec({
@@ -295,6 +330,35 @@ String formatValue(Object? v) => switch (v) {
/// `≈ 394 ft` for [m] metres.
String feetHint(int m) => '≈ ${(m * 3.28084).round()} ft';
/// One battery `WarningRecord` (as `KeyBridge.kt` maps it) → what went wrong.
String formatWarningRecord(Object? v) {
final Map<String, dynamic>? m = asMap(v);
if (m == null) return formatValue(v);
final List<String> found = <String>[
if (asBool(m['overCurrent']) == true) 'over-current',
if (asBool(m['overHeated']) == true) 'overheated',
if (asBool(m['lowTemperature']) == true) 'too cold',
if (asBool(m['shortCircuit']) == true) 'short circuit',
if (asBool(m['underVoltage']) == true) 'under-voltage',
if ((asInt(m['lowVoltageCell']) ?? 0) > 0) 'low cell ${asInt(m['lowVoltageCell'])}',
if ((asInt(m['damagedCell']) ?? 0) > 0) 'damaged cell ${asInt(m['damagedCell'])}',
];
if (found.isEmpty) return asBool(m['error']) == true ? 'Error' : 'None';
return found.join(', ');
}
/// A list of `WarningRecord`s → how many flights had a warning, and the worst.
String formatWarningHistory(Object? v) {
if (v is! List) return formatValue(v);
if (v.isEmpty) return 'None';
final List<String> bad = <String>[
for (final Object? r in v)
if (formatWarningRecord(r) case final String s when s != 'None') s,
];
if (bad.isEmpty) return 'None in ${v.length} records';
return '${bad.length} of ${v.length}: ${bad.first}';
}
/// Millivolt cell list → `3.85 3.86 3.84 V`.
String formatCells(Object? v) {
if (v is! List || v.isEmpty) return '—';
+295 -1
View File
@@ -31,6 +31,7 @@ abstract final class SettingsKeys {
static const DjiKey lowBattery = DjiKey.flightController('LOW_BATTERY_WARNING_THRESHOLD');
static const DjiKey criticalBattery = DjiKey.flightController('SERIOUS_LOW_BATTERY_WARNING_THRESHOLD');
static const DjiKey fcSerial = DjiKey.flightController('SERIAL_NUMBER');
static const DjiKey aircraftName = DjiKey.flightController('AIRCRAFT_NAME');
// Flight assistant (vision)
static const DjiKey collision = DjiKey.flightAssistant('COLLISION_AVOIDANCE_ENABLED');
@@ -56,6 +57,25 @@ abstract final class SettingsKeys {
static const DjiKey rcName = DjiKey.remoteController('DISPLAY_NAME');
static const DjiKey rcSerial = DjiKey.remoteController('SERIAL_NUMBER');
static const DjiKey rcFirmware = DjiKey.remoteController('FIRMWARE_VERSION');
static const DjiKey customButton1 = DjiKey.remoteController('CUSTOM_BUTTON_1');
// Dual operator (master / slave)
static const DjiKey rcRole = DjiKey.remoteController('MODE');
static const DjiKey rcLinkName = DjiKey.remoteController('NAME');
static const DjiKey rcLinkPassword = DjiKey.remoteController('PASSWORD');
static const DjiKey masterSlaveSupported = DjiKey.remoteController('IS_MASTER_SLAVE_MODE_SUPPORTED');
static const DjiKey masterSlaveState = DjiKey.remoteController('MASTER_SLAVE_STATE');
// Secondary video output (Lightbridge remotes / CrystalSky HDMI, Cendence SDI)
static const DjiKey secondaryOut = DjiKey.remoteController('SECONDARY_VIDEO_OUTPUT_ENABLED');
static const DjiKey secondaryPort = DjiKey.remoteController('SECONDARY_VIDEO_OUTPUT_PORT');
static const DjiKey secondaryDisplay = DjiKey.remoteController('SECONDARY_VIDEO_DISPLAY_MODE');
static const DjiKey secondaryOsd = DjiKey.remoteController('SECONDARY_VIDEO_OSD_ENABLED');
static const DjiKey secondaryOsdUnit = DjiKey.remoteController('SECONDARY_VIDEO_OSD_UNIT');
static const DjiKey hdmiFormat = DjiKey.remoteController('HDMI_OUTPUT_FORMAT');
static const DjiKey sdiFormat = DjiKey.remoteController('SDI_OUTPUT_FORMAT');
static const DjiKey pipPosition = DjiKey.remoteController('PIP_POSITION');
static DjiKey osdMargin(String side) => DjiKey.remoteController('SECONDARY_VIDEO_OSD_${side}_MARGIN');
// Image transmission
static const DjiKey uplink = DjiKey.airLink('UPLINK_SIGNAL_QUALITY');
@@ -76,6 +96,8 @@ abstract final class SettingsKeys {
static const DjiKey wifiSsid = DjiKey.wifi('SSID');
static const DjiKey wifiBand = DjiKey.wifi('WIFI_FREQUENCY_BAND');
static const DjiKey wifiChannel = DjiKey.wifi('CHANNEL_NUMBER');
static const DjiKey wifiPassword = DjiKey.wifi('PASSWORD');
static const DjiKey wifiReboot = DjiKey.wifi('REBOOT');
// Product
static const DjiKey firmwarePackage = DjiKey.product('FIRMWARE_PACKAGE_VERSION');
@@ -111,12 +133,38 @@ abstract final class SettingsTabs {
static const String liveStream = 'liveStream';
static const String about = 'about';
static const String developer = 'developer';
static const String units = 'units';
static const String customButtons = 'customButtons';
static const String masterSearch = 'masterSearch';
static const String slaveList = 'slaveList';
static const String spectrum = 'spectrum';
static const String videoCache = 'videoCache';
static const String flightLogs = 'flightLogs';
static const String batteryHistory = 'batteryHistory';
static bool _isOn(KeyState s, DjiKey k) => asBool(s.value(k)) == true;
static bool _manual(KeyState s, DjiKey k) => asString(s.value(k)) == 'MANUAL';
/// The remote's dual-operator role; `NORMAL` until it says otherwise.
static String _role(KeyState s) => asString(s.value(SettingsKeys.rcRole)) ?? 'NORMAL';
static const List<(String, String)> _channelModes = <(String, String)>[('AUTO', 'Auto'), ('MANUAL', 'Custom')];
static const List<(String, String)> _videoFormats = <(String, String)>[
('RESOLUTION_1080P_60FPS', '1080p60'),
('RESOLUTION_1080P_50FPS', '1080p50'),
('RESOLUTION_1080P_30FPS', '1080p30'),
('RESOLUTION_1080P_25FPS', '1080p25'),
('RESOLUTION_1080P_24FPS', '1080p24'),
('RESOLUTION_1080I_60FPS', '1080i60'),
('RESOLUTION_1080I_50FPS', '1080i50'),
('RESOLUTION_720P_60FPS', '720p60'),
('RESOLUTION_720P_50FPS', '720p50'),
('RESOLUTION_720P_30FPS', '720p30'),
('RESOLUTION_720P_25FPS', '720p25'),
('RESOLUTION_720P_24FPS', '720p24'),
];
/// The tabs for [p]; battery groups repeat per battery.
static List<SettingsTab> all(ProductProfile p) => <SettingsTab>[
SettingsTab(id: safety, label: 'Safety', icon: 'shield', rows: _safety),
@@ -311,6 +359,135 @@ abstract final class SettingsTabs {
max: 100,
step: 5,
),
GroupSpec('BUTTONS', requires: HudFeatures.customButtons),
CustomSpec(id: customButtons, requires: HudFeatures.customButtons, keys: const <DjiKey>[SettingsKeys.customButton1]),
// ── Dual operator ─────────────────────────────────────────────────────────
GroupSpec('DUAL OPERATOR', requires: HudFeatures.masterSlaveBadge),
KeyChoiceSpec(
id: 'rcRole',
label: 'Remote role',
hint: 'Master flies; slave runs the gimbal and camera',
key: SettingsKeys.rcRole,
options: const <(String, String)>[('NORMAL', 'Single'), ('MASTER', 'Master'), ('SLAVE', 'Slave')],
requires: HudFeatures.masterSlaveBadge,
groundOnly: true,
),
KeyTextSpec(
id: 'rcLinkName',
label: 'Remote name',
hint: 'How the other remote finds this one',
key: SettingsKeys.rcLinkName,
maxLength: 6,
requires: HudFeatures.masterSlaveBadge,
visibleWhen: (SettingsContext c) => _role(c.keys) != 'NORMAL',
),
KeyTextSpec(
id: 'rcLinkPassword',
label: 'Connection password',
hint: 'Four digits the slave types to join',
key: SettingsKeys.rcLinkPassword,
secret: true,
maxLength: 4,
requires: HudFeatures.masterSlaveBadge,
visibleWhen: (SettingsContext c) => _role(c.keys) == 'MASTER',
),
CustomSpec(
id: masterSearch,
requires: HudFeatures.masterSlaveBadge,
visibleWhen: (SettingsContext c) => _role(c.keys) == 'SLAVE',
),
CustomSpec(
id: slaveList,
requires: HudFeatures.masterSlaveBadge,
visibleWhen: (SettingsContext c) => _role(c.keys) == 'MASTER',
),
// ── Secondary video output (HDMI / SDI) ───────────────────────────────────
GroupSpec('VIDEO OUTPUT', requires: HudFeatures.linkLightbridge),
KeySwitchSpec(
id: 'secondaryOut',
label: 'HDMI / SDI output',
hint: "The remote's video out to a monitor or recorder",
key: SettingsKeys.secondaryOut,
requires: HudFeatures.linkLightbridge,
),
KeyChoiceSpec(
id: 'secondaryPort',
label: 'Output port',
key: SettingsKeys.secondaryPort,
options: const <(String, String)>[('HDMI', 'HDMI'), ('SDI', 'SDI')],
requires: HudFeatures.linkLightbridge,
visibleWhen: (SettingsContext c) => _isOn(c.keys, SettingsKeys.secondaryOut),
),
KeyChoiceSpec(
id: 'hdmiFormat',
label: 'HDMI format',
key: SettingsKeys.hdmiFormat,
options: _videoFormats,
requires: HudFeatures.linkLightbridge,
visibleWhen: (SettingsContext c) => _isOn(c.keys, SettingsKeys.secondaryOut) && asString(c.keys.value(SettingsKeys.secondaryPort)) != 'SDI',
),
KeyChoiceSpec(
id: 'sdiFormat',
label: 'SDI format',
key: SettingsKeys.sdiFormat,
options: _videoFormats,
requires: HudFeatures.linkLightbridge,
visibleWhen: (SettingsContext c) => _isOn(c.keys, SettingsKeys.secondaryOut) && asString(c.keys.value(SettingsKeys.secondaryPort)) == 'SDI',
),
KeyChoiceSpec(
id: 'secondaryDisplay',
label: 'Picture',
hint: 'Which feed, and which one inset (dual-camera aircraft)',
key: SettingsKeys.secondaryDisplay,
options: const <(String, String)>[
('SOURCE_1_ONLY', 'Feed 1'),
('SOURCE_2_ONLY', 'Feed 2'),
('SOURCE_1_MAIN', 'Feed 1 + inset'),
('SOURCE_2_MAIN', 'Feed 2 + inset'),
],
requires: HudFeatures.linkLightbridge,
visibleWhen: (SettingsContext c) => _isOn(c.keys, SettingsKeys.secondaryOut),
),
KeyChoiceSpec(
id: 'pipPosition',
label: 'Inset position',
key: SettingsKeys.pipPosition,
options: const <(String, String)>[
('TOP_LEFT', 'Top left'),
('TOP_RIGHT', 'Top right'),
('BOTTOM_LEFT', 'Bottom left'),
('BOTTOM_RIGHT', 'Bottom right'),
],
requires: HudFeatures.linkLightbridge,
visibleWhen: (SettingsContext c) =>
_isOn(c.keys, SettingsKeys.secondaryOut) && (asString(c.keys.value(SettingsKeys.secondaryDisplay)) ?? '').endsWith('_MAIN'),
),
KeySwitchSpec(
id: 'secondaryOsd',
label: 'Show flight data (OSD)',
key: SettingsKeys.secondaryOsd,
requires: HudFeatures.linkLightbridge,
visibleWhen: (SettingsContext c) => _isOn(c.keys, SettingsKeys.secondaryOut),
),
KeyChoiceSpec(
id: 'secondaryOsdUnit',
label: 'OSD units',
key: SettingsKeys.secondaryOsdUnit,
options: const <(String, String)>[('METRIC', 'Metric'), ('IMPERIAL', 'Imperial')],
requires: HudFeatures.linkLightbridge,
visibleWhen: (SettingsContext c) => _isOn(c.keys, SettingsKeys.secondaryOut) && _isOn(c.keys, SettingsKeys.secondaryOsd),
),
for (final (String side, String label) in const <(String, String)>[('TOP', 'top'), ('BOTTOM', 'bottom'), ('LEFT', 'left'), ('RIGHT', 'right')])
KeyNumberSpec(
id: 'osdMargin$side',
label: 'OSD margin, $label',
key: SettingsKeys.osdMargin(side),
min: 0,
max: 50,
unit: 'px',
requires: HudFeatures.linkLightbridge,
visibleWhen: (SettingsContext c) => _isOn(c.keys, SettingsKeys.secondaryOut) && _isOn(c.keys, SettingsKeys.secondaryOsd),
),
const GroupSpec('CONTROLLER'),
const KeyChoiceSpec(
id: 'chargeMobile',
@@ -347,6 +524,7 @@ abstract final class SettingsTabs {
static final List<SettingSpec> _transmission = <SettingSpec>[
const KeyReadoutSpec(id: 'uplink', label: 'Control signal', key: SettingsKeys.uplink, format: _percent),
const KeyReadoutSpec(id: 'downlink', label: 'Video signal', key: SettingsKeys.downlink, format: _percent),
const CustomSpec(id: spectrum),
// OcuSync
GroupSpec('OCUSYNC', requires: HudFeatures.linkOcuSync),
KeyReadoutSpec(id: 'osDataRate', label: 'Video bitrate', key: SettingsKeys.osDataRate, format: _mbps, requires: HudFeatures.linkOcuSync),
@@ -433,7 +611,40 @@ abstract final class SettingsTabs {
),
// Wi-Fi
GroupSpec('WI-FI', requires: HudFeatures.linkWifi),
KeyReadoutSpec(id: 'wifiSsid', label: 'Network', key: SettingsKeys.wifiSsid, requires: HudFeatures.linkWifi),
KeyTextSpec(
id: 'wifiSsid',
label: 'Network name',
hint: 'Takes effect after Restart Wi-Fi',
key: SettingsKeys.wifiSsid,
requires: HudFeatures.linkWifi,
groundOnly: true,
send: (DjiService dji, String v) => dji.setWifiCredentials(ssid: v),
),
KeyTextSpec(
id: 'wifiPassword',
label: 'Wi-Fi password',
hint: 'At least 8 characters; takes effect after Restart Wi-Fi',
key: SettingsKeys.wifiPassword,
secret: true,
maxLength: 63,
requires: HudFeatures.linkWifi,
groundOnly: true,
send: (DjiService dji, String v) {
if (v.length < 8) throw ArgumentError('The password needs at least 8 characters');
return dji.setWifiCredentials(password: v);
},
),
KeyActionSpec(
id: 'wifiReboot',
label: 'Restart Wi-Fi',
hint: 'Applies a new name or password; the phone has to join the network again',
key: SettingsKeys.wifiReboot,
button: 'Restart',
requires: HudFeatures.linkWifi,
groundOnly: true,
confirmTitle: 'Restart the aircraft Wi-Fi',
confirmText: 'The connection drops. Join the network again in the phone\'s Wi-Fi settings, with the new name or password if you changed them.',
),
KeyChoiceSpec(
id: 'wifiBand',
label: 'Frequency band',
@@ -481,6 +692,66 @@ abstract final class SettingsTabs {
},
),
for (int i = 0; i < n; i++) ..._batteryRows(i, n),
// Inspire 2 / M200: the two packs fly as a pair and must be matched.
GroupSpec('BATTERY PAIR', requires: HudFeatures.dualBattery),
KeyReadoutSpec(
id: 'pairingState',
label: 'Pairing',
key: SettingsKeys.battery('PAIRING_STATE', 0),
requires: HudFeatures.dualBattery,
),
KeyActionSpec(
id: 'pairBatteries',
label: 'Pair the two batteries',
hint: 'After swapping a pack: the pair then ages and balances together',
key: SettingsKeys.battery('PAIR_BATTERIES', 0),
button: 'Pair',
requires: HudFeatures.dualBattery,
groundOnly: true,
confirmTitle: 'Pair batteries',
confirmText: 'The two batteries in the aircraft are recorded as a pair. Use two packs of similar age and charge.',
visibleWhen: (SettingsContext c) => asString(c.keys.value(SettingsKeys.battery('PAIRING_STATE', 0))) != 'PAIRED',
),
// Non-smart packs (A3 / N3 / M600 on plain LiPos): the FC judges cells by voltage.
GroupSpec('CELL VOLTAGE', requires: HudFeatures.nonSmartBattery),
KeyNumberSpec(
id: 'cellCount',
label: 'Number of cells',
key: SettingsKeys.battery('NUMBER_OF_CELLS', 0),
min: 3,
max: 12,
unit: 'S',
requires: HudFeatures.nonSmartBattery,
groundOnly: true,
),
for (final int level in const <int>[1, 2]) ...<SettingSpec>[
KeyNumberSpec(
id: 'cellLevel$level',
label: level == 1 ? 'Low cell voltage' : 'Critical cell voltage',
key: SettingsKeys.battery('LEVEL_${level}_CELL_VOLTAGE_THRESHOLD', 0),
min: level == 1 ? 3500 : 3300,
max: level == 1 ? 3900 : 3700,
step: 10,
unit: 'mV',
requires: HudFeatures.nonSmartBattery,
bounds: level == 1
? (KeyState s, int lo, int hi) {
final int? l2 = asInt(s.value(SettingsKeys.battery('LEVEL_2_CELL_VOLTAGE_THRESHOLD', 0)));
return (l2 == null ? lo : (l2 + 50).clamp(lo, hi), hi);
}
: (KeyState s, int lo, int hi) {
final int? l1 = asInt(s.value(SettingsKeys.battery('LEVEL_1_CELL_VOLTAGE_THRESHOLD', 0)));
return (lo, l1 == null ? hi : (l1 - 50).clamp(lo, hi));
},
),
KeyChoiceSpec(
id: 'cellBehavior$level',
label: level == 1 ? 'At low voltage' : 'At critical voltage',
key: SettingsKeys.battery('LEVEL_${level}_CELL_VOLTAGE_BEHAVIOR', 0),
options: const <(String, String)>[('FLASH_LED', 'Flash LEDs'), ('GO_HOME', 'Return home'), ('LAND', 'Land')],
requires: HudFeatures.nonSmartBattery,
),
],
];
}
@@ -504,19 +775,42 @@ abstract final class SettingsTabs {
),
KeyReadoutSpec(id: 'batterySerial$i', label: 'Serial number', key: k('SERIAL_NUMBER')),
KeyReadoutSpec(id: 'batteryFirmware$i', label: 'Firmware', key: k('FIRMWARE_VERSION')),
KeyReadoutSpec(id: 'batteryLatest$i', label: 'Latest warning', key: k('LATEST_WARNING_RECORD'), format: formatWarningRecord),
KeyReadoutSpec(
id: 'batteryHistory$i',
label: 'Warning history',
hint: 'What the pack recorded over its last flights',
key: k('HISTORICAL_WARNING_RECORDS'),
format: formatWarningHistory,
),
];
}
// ── General ─────────────────────────────────────────────────────────────────
static final List<SettingSpec> _general = <SettingSpec>[
const GroupSpec('UNITS'),
const CustomSpec(id: units),
const GroupSpec('MAP'),
const CustomSpec(id: showRoute),
const CustomSpec(id: clearRoute),
GroupSpec('GEO UNLOCKS', requires: HudFeatures.flyZones),
CustomSpec(id: flyZoneLicences, requires: HudFeatures.flyZones),
GroupSpec('VIDEO CACHE', requires: HudFeatures.cameraControls),
CustomSpec(id: videoCache, requires: HudFeatures.cameraControls),
const GroupSpec('FLIGHT RECORDS'),
const CustomSpec(id: flightLogs),
GroupSpec('LIVE STREAMING', requires: HudFeatures.liveStream),
CustomSpec(id: liveStream, requires: HudFeatures.liveStream),
GroupSpec('ON-SCREEN STICKS', requires: HudFeatures.virtualSticks),
CustomSpec(id: stickPads, requires: HudFeatures.virtualSticks),
const GroupSpec('ABOUT'),
const KeyTextSpec(
id: 'aircraftName',
label: 'Aircraft name',
hint: 'Shown in the logbook and to other DJI apps',
key: SettingsKeys.aircraftName,
maxLength: 32,
),
const CustomSpec(id: about, keys: <DjiKey>[SettingsKeys.firmwarePackage, SettingsKeys.fcSerial]),
const GroupSpec('DEVELOPER'),
const CustomSpec(id: developer),
+77
View File
@@ -59,6 +59,70 @@ class GoHomeAssessment {
batteryNeededToLand, maxRadiusCanFlyAndGoHome, smartRTHState, smartRTHCountdown);
}
/// The RTK module's fix (`rtk` group), on aircraft that have one.
@immutable
class RtkStatus {
const RtkStatus({this.solution, this.inUse, this.satellites, this.error});
factory RtkStatus.fromMap(Map<String, dynamic> m) => RtkStatus(
solution: asString(m['solution']),
inUse: asBool(m['inUse']),
satellites: asInt(m['satellites']),
error: asString(m['error']),
);
/// `NONE` / `SINGLE_POINT` / `FLOAT` / `FIXED_POINT`.
final String? solution;
final bool? inUse;
final int? satellites;
final String? error;
/// GO 4's chip text: FIX, FLOAT, SINGLE or a dash.
String get label => switch (solution) {
'FIXED_POINT' => 'FIX',
'FLOAT' => 'FLOAT',
'SINGLE_POINT' => 'SINGLE',
_ => '—',
};
bool get fixed => solution == 'FIXED_POINT';
@override
bool operator ==(Object other) =>
other is RtkStatus && other.solution == solution && other.inUse == inUse && other.satellites == satellites && other.error == error;
@override
int get hashCode => Object.hash(solution, inUse, satellites, error);
}
/// One compass's magnetic reading (`compassSensors`), for the interference meter.
@immutable
class CompassSensor {
const CompassSensor(this.index, this.state, this.value);
final int index;
/// `NORMAL_MODULUS` / `WEAK_MODULUS` / `SERIOUS_MODULUS` / `UNCALIBRATED` / …
final String? state;
/// The field's modulus as the flight controller reports it (raw units).
final int? value;
/// 0 = clean, 1 = weak interference, 2 = serious, null = not graded.
int? get level => switch (state) {
'NORMAL_MODULUS' => 0,
'WEAK_MODULUS' => 1,
'SERIOUS_MODULUS' || 'DATA_EXCEPTION' || 'DIRECTION_EXCEPTION' => 2,
_ => null,
};
@override
bool operator ==(Object other) => other is CompassSensor && other.index == index && other.state == state && other.value == value;
@override
int get hashCode => Object.hash(index, state, value);
}
/// The flight controller's state: position, motion, attitude, flight mode and
/// the safety flags. Fed from the flat keys of every `telemetry` tick (~10 Hz).
class AircraftState extends ChangeNotifier {
@@ -128,6 +192,12 @@ class AircraftState extends ChangeNotifier {
/// `NOT_CALIBRATING` / `HORIZONTAL` / `VERTICAL` / `SUCCESSFUL` / `FAILED`.
String? compassCalibrationState;
/// Per-compass interference readings; empty until the compass reports.
List<CompassSensor> compassSensors = const <CompassSensor>[];
/// The RTK fix, on aircraft with the module; null otherwise.
RtkStatus? rtk;
DateTime? updatedAt;
/// VPS height when the sensor is actually in use, else null.
@@ -180,6 +250,11 @@ class AircraftState extends ChangeNotifier {
compassHasError = asBool(t['compassHasError']);
compassCalibrating = asBool(t['compassCalibrating']);
compassCalibrationState = asString(t['compassCalibrationState']);
compassSensors = <CompassSensor>[
for (final Map<String, dynamic> c in asMapList(t['compassSensors'])) CompassSensor(asInt(c['index']) ?? 0, asString(c['state']), asInt(c['value'])),
];
final Map<String, dynamic>? r = asMap(t['rtk']);
rtk = r == null ? null : RtkStatus.fromMap(r);
updatedAt = DateTime.now();
notifyListeners();
}
@@ -196,6 +271,8 @@ class AircraftState extends ChangeNotifier {
horizontalSpeed = verticalSpeed = heading = pitch = roll = velocityX = velocityY = velocityZ = null;
goHomeHeight = ultrasonicHeight = null;
goHome = null;
compassSensors = const <CompassSensor>[];
rtk = null;
updatedAt = null;
notifyListeners();
}
+27 -3
View File
@@ -322,7 +322,15 @@ class FlyZoneState extends ChangeNotifier {
}
void applyDatabase(Map<String, dynamic> m) {
database = FlyZoneDatabase.fromMap(m);
final FlyZoneDatabase next = FlyZoneDatabase.fromMap(m);
// A read that came before the remote check keeps the version heard since.
database = next.preciseRemoteVersion == null ? next.withRemote(database?.preciseRemoteVersion) : next;
notifyListeners();
}
/// The SDK heard which precise database DJI's server has (`flyZoneDbRemote`).
void applyRemoteVersion(String? v) {
database = (database ?? const FlyZoneDatabase()).withRemote(v);
notifyListeners();
}
@@ -424,13 +432,14 @@ class FlyZoneLicence {
/// The GEO database's versions and state (`FlyZoneDatabaseState` names).
@immutable
class FlyZoneDatabase {
const FlyZoneDatabase({this.basicVersion, this.basicState, this.preciseVersion, this.preciseState});
const FlyZoneDatabase({this.basicVersion, this.basicState, this.preciseVersion, this.preciseState, this.preciseRemoteVersion});
factory FlyZoneDatabase.fromMap(Map<String, dynamic> m) => FlyZoneDatabase(
basicVersion: asString(m['basicVersion']),
basicState: asString(m['basicState']),
preciseVersion: asString(m['preciseVersion']),
preciseState: asString(m['preciseState']),
preciseRemoteVersion: asString(m['preciseRemoteVersion']),
);
final String? basicVersion;
@@ -438,7 +447,22 @@ class FlyZoneDatabase {
final String? preciseVersion;
final String? preciseState;
bool get outOfDate => basicState == 'OUT_OF_DATE' || preciseState == 'OUT_OF_DATE';
/// The newest precise database on DJI's server, once the SDK has asked.
final String? preciseRemoteVersion;
FlyZoneDatabase withRemote(String? v) => FlyZoneDatabase(
basicVersion: basicVersion,
basicState: basicState,
preciseVersion: preciseVersion,
preciseState: preciseState,
preciseRemoteVersion: v,
);
/// DJI has a newer precise database than the aircraft's (it updates with the firmware).
bool get preciseBehind =>
preciseRemoteVersion != null && preciseVersion != null && preciseRemoteVersion!.isNotEmpty && preciseRemoteVersion != preciseVersion;
bool get outOfDate => basicState == 'OUT_OF_DATE' || preciseState == 'OUT_OF_DATE' || preciseBehind;
static String stateLabel(String? s) => switch (s) {
'UP_TO_DATE' => 'Up to date',
+84
View File
@@ -0,0 +1,84 @@
import 'package:flutter/foundation.dart';
import '../rc_buttons.dart';
import 'parse.dart';
/// One press of a remote's custom button (`rcButton` event). [seq] grows with
/// every press, so two presses of the same button are two values.
@immutable
class RcButtonPress {
const RcButtonPress(this.button, this.seq);
final RcButton button;
final int seq;
@override
bool operator ==(Object other) => other is RcButtonPress && other.button == button && other.seq == seq;
@override
int get hashCode => Object.hash(button, seq);
}
/// A remote seen by master search, or linked as a slave (`rcMasters`, `getSlaves`).
@immutable
class RcPeer {
const RcPeer({required this.id, this.name, this.signal});
factory RcPeer.fromMap(Map<String, dynamic> m) => RcPeer(id: asInt(m['id']) ?? 0, name: asString(m['name']), signal: asInt(m['signal']));
final int id;
final String? name;
/// 0–100.
final int? signal;
@override
bool operator ==(Object other) => other is RcPeer && other.id == id && other.name == name && other.signal == signal;
@override
int get hashCode => Object.hash(id, name, signal);
}
/// One bar of the video link's spectrum chart.
@immutable
class SpectrumBar {
const SpectrumBar(this.label, this.power);
/// Channel number, or the band's centre in MHz.
final String label;
/// dBm (OcuSync RSSI, Lightbridge / Wi-Fi channel power); more negative is quieter.
final double power;
}
/// The video link's interference (`spectrum` events, while the chart is up).
@immutable
class Spectrum {
const Spectrum(this.link, this.bars);
/// `ocusync` / `lightbridge` / `wifi`.
final String link;
final List<SpectrumBar> bars;
factory Spectrum.fromEvent(Map<String, dynamic> e) {
final List<SpectrumBar> bars = <SpectrumBar>[
for (final Map<String, dynamic> b in asMapList(e['bands']))
if (finite(b['from']) case final double from)
if (finite(b['to']) case final double to)
if (finite(b['rssi']) case final double rssi) SpectrumBar(((from + to) / 2).toStringAsFixed(0), rssi),
for (final Map<String, dynamic> c in asMapList(e['channels']))
if (asInt(c['channel']) case final int ch)
if (finite(c['power']) case final double p) SpectrumBar('$ch', p),
];
return Spectrum(asString(e['link']) ?? '', bars);
}
/// The quietest bar, the channel to pick in Custom mode.
SpectrumBar? get quietest {
SpectrumBar? best;
for (final SpectrumBar b in bars) {
if (best == null || b.power < best.power) best = b;
}
return best;
}
}
+112 -7
View File
@@ -42,7 +42,7 @@ class WaypointProgress {
final int? target;
final bool reached;
/// MSDK `WaypointMissionExecuteState` (MOVING, DOING_ACTION, …).
/// MSDK `WaypointMissionExecuteState` (MOVING, DOING_ACTION, …).
final String? executeState;
bool get uploading => state == 'UPLOADING';
@@ -57,7 +57,7 @@ class WaypointProgress {
bool get available => state == 'READY_TO_UPLOAD' || state == 'READY_TO_EXECUTE';
bool get unsupported => state == 'NOT_SUPPORTED';
/// 0…1 through the upload, or null outside it.
/// 0…1 through the upload, or null outside it.
double? get uploadFraction {
if (!uploading || total == null || total == 0) return null;
return (((uploaded ?? -1) + 1) / total!).clamp(0.0, 1.0);
@@ -100,8 +100,12 @@ class ActiveTrackProgress {
this.subjects = const <SensedSubject>[],
this.autoSensingSupported = false,
this.autoSensing = false,
this.percent,
});
/// A QuickShot's progress, 0–100, while it flies.
final int? percent;
/// Subjects the aircraft found itself (auto-sensing), to tap instead of drawing.
final List<SensedSubject> subjects;
final bool autoSensingSupported;
@@ -128,6 +132,7 @@ class ActiveTrackProgress {
],
autoSensingSupported: asBool(e['autoSensingSupported']) ?? previous.autoSensingSupported,
autoSensing: asBool(e['autoSensing']) ?? previous.autoSensing,
percent: t == null ? (keep ? previous.percent : null) : asInt(t['percent']),
);
}
@@ -150,10 +155,10 @@ class ActiveTrackProgress {
/// MSDK `ActiveTrackState`.
final String state;
/// `ActiveTrackTargetState`: TRACKING_WITH_HIGH_CONFIDENCE, …_LOW_…, WAITING_FOR_CONFIRMATION, CANNOT_CONFIRM.
/// `ActiveTrackTargetState`: TRACKING_WITH_HIGH_CONFIDENCE, …_LOW_…, WAITING_FOR_CONFIRMATION, CANNOT_CONFIRM.
final String? targetState;
/// HUMAN, CAR, VAN, BIKE, BOAT, …
/// HUMAN, CAR, VAN, BIKE, BOAT, …
final String? targetType;
/// The subject's box, 0..1 across the live view.
@@ -206,10 +211,11 @@ class ActiveTrackProgress {
other.reason == reason &&
listEquals(other.subjects, subjects) &&
other.autoSensingSupported == autoSensingSupported &&
other.autoSensing == autoSensing;
other.autoSensing == autoSensing &&
other.percent == percent;
@override
int get hashCode => Object.hash(state, targetState, targetType, rect, reason, Object.hashAll(subjects), autoSensingSupported, autoSensing);
int get hashCode => Object.hash(state, targetState, targetType, rect, reason, Object.hashAll(subjects), autoSensingSupported, autoSensing, percent);
}
/// One subject auto-sensing found: its operator index and box (0..1 of the picture).
@@ -284,6 +290,71 @@ class TapFlyProgress {
int get hashCode => Object.hash(state, image, bypass, speed, relativeHeading);
}
/// The Point of Interest operator as last reported (plan P7).
@immutable
class HotpointProgress {
const HotpointProgress({this.state = 'UNKNOWN', this.radius, this.maxAngularVelocity});
factory HotpointProgress.fromMap(Map<String, dynamic> e, HotpointProgress previous) => HotpointProgress(
state: asString(e['state']) ?? previous.state,
radius: finite(e['radius']) ?? previous.radius,
maxAngularVelocity: finite(e['maxAngularVelocity']) ?? previous.maxAngularVelocity,
);
/// `HotpointMissionState`: UNKNOWN, DISCONNECTED, NOT_SUPPORTED, RECOVERING,
/// READY_TO_EXECUTE, INITIAL_PHASE (flying to the circle), EXECUTING, EXECUTION_PAUSED.
final String state;
final double? radius;
final double? maxAngularVelocity;
bool get active => state == 'INITIAL_PHASE' || state == 'EXECUTING' || state == 'EXECUTION_PAUSED';
bool get paused => state == 'EXECUTION_PAUSED';
bool get unsupported => state == 'NOT_SUPPORTED';
String get label => switch (state) {
'INITIAL_PHASE' => 'Flying to the circle',
'EXECUTING' => 'Circling',
'EXECUTION_PAUSED' => 'Paused',
'READY_TO_EXECUTE' => 'Ready',
'NOT_SUPPORTED' => 'Not supported',
_ => 'Set up',
};
@override
bool operator ==(Object other) =>
other is HotpointProgress && other.state == state && other.radius == radius && other.maxAngularVelocity == maxAngularVelocity;
@override
int get hashCode => Object.hash(state, radius, maxAngularVelocity);
}
/// The Follow Me operator as last reported (plan P7).
@immutable
class FollowMeProgress {
const FollowMeProgress({this.state = 'UNKNOWN', this.distance});
factory FollowMeProgress.fromMap(Map<String, dynamic> e, FollowMeProgress previous) => FollowMeProgress(
state: asString(e['state']) ?? previous.state,
distance: finite(e['distance']) ?? (asString(e['state']) == 'EXECUTING' ? previous.distance : null),
);
/// `FollowMeMissionState`: … READY_TO_EXECUTE, EXECUTING, GOT_STUCK, NOT_READY.
final String state;
/// Metres from the aircraft to the phone, while following.
final double? distance;
bool get active => state == 'EXECUTING' || state == 'GOT_STUCK';
bool get stuck => state == 'GOT_STUCK';
bool get unsupported => state == 'NOT_SUPPORTED';
@override
bool operator ==(Object other) => other is FollowMeProgress && other.state == state && other.distance == distance;
@override
int get hashCode => Object.hash(state, distance);
}
/// Waypoint / ActiveTrack / QuickShot execution, from the bridge's `mission` events.
///
/// Waypoint events carry `operator: 'waypoint'` and the operator's own state
@@ -293,6 +364,12 @@ class MissionState extends ChangeNotifier {
WaypointProgress waypoint = const WaypointProgress();
ActiveTrackProgress activeTrack = const ActiveTrackProgress();
TapFlyProgress tapFly = const TapFlyProgress();
HotpointProgress hotpoint = const HotpointProgress();
FollowMeProgress followMe = const FollowMeProgress();
/// How the last POI / Follow Me ended, like [waypointResult]; bumped in [modeFinishes].
String? modeResult;
int modeFinishes = 0;
/// How the last waypoint mission ended: null while none has, `''` when it
/// finished cleanly, else the SDK's error.
@@ -304,11 +381,13 @@ class MissionState extends ChangeNotifier {
String _legacy = 'idle';
String? error;
/// idle / ready / uploading / executing / tracking / quickshot / tapfly.
/// idle / ready / uploading / executing / tracking / quickshot / tapfly / hotpoint / followme.
String get state {
if (waypoint.active) return 'executing';
if (activeTrack.active) return activeTrack.state == 'PERFORMING_QUICK_SHOT' ? 'quickshot' : 'tracking';
if (tapFly.active) return 'tapfly';
if (hotpoint.active) return 'hotpoint';
if (followMe.active) return 'followme';
if (_legacy != 'idle') return _legacy;
if (waypoint.uploading) return 'uploading';
if (waypoint.readyToExecute) return 'ready';
@@ -346,12 +425,36 @@ class MissionState extends ChangeNotifier {
if (err != null) error = err;
notifyListeners();
return;
case 'hotpoint':
final HotpointProgress next = HotpointProgress.fromMap(e, hotpoint);
if (!_finish(e) && next == hotpoint) return;
hotpoint = next;
notifyListeners();
return;
case 'followMe':
final FollowMeProgress next = FollowMeProgress.fromMap(e, followMe);
if (!_finish(e) && next == followMe) return;
followMe = next;
notifyListeners();
return;
}
_legacy = asString(e['state']) ?? 'idle';
error = asString(e['error']);
notifyListeners();
}
/// Records a POI / Follow Me `finish` (and its error); true when the event said anything.
bool _finish(Map<String, dynamic> e) {
final String? err = asString(e['error']);
if (err != null) error = err;
if (asString(e['event']) == 'finish') {
modeResult = err ?? '';
modeFinishes++;
return true;
}
return err != null;
}
void _applyWaypoint(Map<String, dynamic> e) {
final WaypointProgress next = WaypointProgress.fromMap(e, waypoint);
final String? event = asString(e['event']);
@@ -374,6 +477,8 @@ class MissionState extends ChangeNotifier {
waypoint = const WaypointProgress();
activeTrack = const ActiveTrackProgress();
tapFly = const TapFlyProgress();
hotpoint = const HotpointProgress();
followMe = const FollowMeProgress();
notifyListeners();
}
}
+15
View File
@@ -1,6 +1,8 @@
import 'dart:ui' show Size;
import '../flight_model.dart';
import 'rc_buttons.dart';
import 'state/link_extras.dart';
import 'state/live_stream_state.dart' show MediaPlayback;
import 'state/parse.dart';
@@ -66,6 +68,19 @@ class TelemetryReducer {
case 'mediaPlayback':
model.mediaPlayback.value = MediaPlayback.fromEvent(event);
return true;
case 'rcButton':
final RcButton? b = RcButton.fromEvent(event['button']);
if (model.connected && b != null) model.rcButton.value = RcButtonPress(b, (model.rcButton.value?.seq ?? 0) + 1);
return true;
case 'rcMasters':
model.rcMasters.value = <RcPeer>[for (final Map<String, dynamic> m in asMapList(event['masters'])) RcPeer.fromMap(m)];
return true;
case 'spectrum':
model.spectrum.value = Spectrum.fromEvent(event);
return true;
case 'flyZoneDbRemote':
model.flyZones.applyRemoteVersion(asString(event['preciseRemoteVersion']));
return true;
case 'videoSize':
final int? w = asInt(event['width']), h = asInt(event['height']);
if (w != null && h != null && w > 0 && h > 0) model.videoSize.value = Size(w.toDouble(), h.toDouble());
+48
View File
@@ -0,0 +1,48 @@
/// Metric or imperial readouts (GO 4's General → Units, plan P6).
///
/// The aircraft always reports metres and m/s, and every setting is still
/// written in metres; only what the pilot reads changes.
enum UnitSystem {
metric('Metric (m, m/s)'),
imperial('Imperial (ft, mph)');
const UnitSystem(this.label);
final String label;
}
abstract final class Units {
static const double feetPerMetre = 3.28084;
static const double mphPerMs = 2.23694;
static const double kmhPerMs = 3.6;
/// A height or short distance: `12 m` / `39 ft`.
static String length(double? m, UnitSystem u, {int decimals = 0}) {
if (m == null) return '—';
return u == UnitSystem.metric ? '${m.toStringAsFixed(decimals)} m' : '${(m * feetPerMetre).toStringAsFixed(decimals)} ft';
}
/// A distance that can run long: metres then km, or feet then miles.
static String distance(double? m, UnitSystem u) {
if (m == null) return '—';
if (u == UnitSystem.metric) return m < 1000 ? '${m.round()} m' : '${(m / 1000).toStringAsFixed(m < 10000 ? 2 : 1)} km';
final double ft = m * feetPerMetre;
return ft < 5280 ? '${ft.round()} ft' : '${(ft / 5280).toStringAsFixed(ft < 52800 ? 2 : 1)} mi';
}
/// A speed: `8.2 m/s` / `18.3 mph`.
static String speed(double? ms, UnitSystem u, {int decimals = 1}) {
if (ms == null) return '—';
return u == UnitSystem.metric ? '${ms.toStringAsFixed(decimals)} m/s' : '${(ms * mphPerMs).toStringAsFixed(decimals)} mph';
}
/// The bare number for a HUD readout whose unit is drawn apart.
static double lengthValue(double m, UnitSystem u) => u == UnitSystem.metric ? m : m * feetPerMetre;
static double speedValue(double ms, UnitSystem u) => u == UnitSystem.metric ? ms : ms * mphPerMs;
static String lengthUnit(UnitSystem u) => u == UnitSystem.metric ? 'm' : 'ft';
static String speedUnit(UnitSystem u) => u == UnitSystem.metric ? 'm/s' : 'mph';
/// The other system's equivalent beside a metric setting: `≈ 394 ft` / `≈ 120 m`.
static String altHint(int metres, UnitSystem u) =>
u == UnitSystem.metric ? '≈ ${(metres * feetPerMetre).round()} ft' : '${(metres * feetPerMetre).round()} ft';
}