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>
485 lines
17 KiB
Dart
485 lines
17 KiB
Dart
import 'dart:ui' show Offset, Rect;
|
||
|
||
import 'package:flutter/foundation.dart';
|
||
|
||
import 'parse.dart';
|
||
|
||
/// The waypoint operator as `MissionBridge.kt` last reported it (plan P7).
|
||
@immutable
|
||
class WaypointProgress {
|
||
const WaypointProgress({
|
||
this.state = 'UNKNOWN',
|
||
this.uploaded,
|
||
this.total,
|
||
this.target,
|
||
this.reached = false,
|
||
this.executeState,
|
||
});
|
||
|
||
factory WaypointProgress.fromMap(Map<String, dynamic> e, WaypointProgress previous) {
|
||
final Map<String, dynamic>? up = asMap(e['upload']);
|
||
final Map<String, dynamic>? ex = asMap(e['execution']);
|
||
return WaypointProgress(
|
||
state: asString(e['state']) ?? previous.state,
|
||
uploaded: up == null ? previous.uploaded : asInt(up['index']),
|
||
total: asInt(ex?['total']) ?? asInt(up?['total']) ?? previous.total,
|
||
target: ex == null ? previous.target : asInt(ex['target']),
|
||
reached: ex == null ? previous.reached : asBool(ex['reached']) ?? false,
|
||
executeState: ex == null ? previous.executeState : asString(ex['executeState']),
|
||
);
|
||
}
|
||
|
||
/// MSDK `WaypointMissionState`: UNKNOWN, DISCONNECTED, NOT_SUPPORTED,
|
||
/// RECOVERING, READY_TO_UPLOAD, UPLOADING, READY_TO_EXECUTE, EXECUTING,
|
||
/// EXECUTION_PAUSED.
|
||
final String state;
|
||
|
||
/// Index of the last waypoint uploaded, and the mission's waypoint count.
|
||
final int? uploaded;
|
||
final int? total;
|
||
|
||
/// The waypoint being flown to, 0-based.
|
||
final int? target;
|
||
final bool reached;
|
||
|
||
/// MSDK `WaypointMissionExecuteState` (MOVING, DOING_ACTION, …).
|
||
final String? executeState;
|
||
|
||
bool get uploading => state == 'UPLOADING';
|
||
bool get readyToExecute => state == 'READY_TO_EXECUTE';
|
||
bool get executing => state == 'EXECUTING';
|
||
bool get paused => state == 'EXECUTION_PAUSED';
|
||
|
||
/// Flying the mission, paused or not.
|
||
bool get active => executing || paused;
|
||
|
||
/// The operator can take a new mission.
|
||
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.
|
||
double? get uploadFraction {
|
||
if (!uploading || total == null || total == 0) return null;
|
||
return (((uploaded ?? -1) + 1) / total!).clamp(0.0, 1.0);
|
||
}
|
||
|
||
String get executeLabel => switch (executeState) {
|
||
'INITIALIZING' => 'Starting',
|
||
'MOVING' || 'CURVE_MODE_MOVING' => 'Flying',
|
||
'CURVE_MODE_TURNING' => 'Turning',
|
||
'BEGIN_ACTION' || 'DOING_ACTION' => 'Action',
|
||
'FINISHED_ACTION' => 'Action done',
|
||
'RETURN_TO_FIRST_WAYPOINT' => 'Back to start',
|
||
'PAUSED' => 'Paused',
|
||
_ => paused ? 'Paused' : 'Flying',
|
||
};
|
||
|
||
@override
|
||
bool operator ==(Object other) =>
|
||
other is WaypointProgress &&
|
||
other.state == state &&
|
||
other.uploaded == uploaded &&
|
||
other.total == total &&
|
||
other.target == target &&
|
||
other.reached == reached &&
|
||
other.executeState == executeState;
|
||
|
||
@override
|
||
int get hashCode => Object.hash(state, uploaded, total, target, reached, executeState);
|
||
}
|
||
|
||
/// The ActiveTrack operator as last reported (plan P7b).
|
||
@immutable
|
||
class ActiveTrackProgress {
|
||
const ActiveTrackProgress({
|
||
this.state = 'UNKNOWN',
|
||
this.targetState,
|
||
this.targetType,
|
||
this.rect,
|
||
this.reason,
|
||
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;
|
||
final bool autoSensing;
|
||
|
||
factory ActiveTrackProgress.fromMap(Map<String, dynamic> e, ActiveTrackProgress previous) {
|
||
final Map<String, dynamic>? t = asMap(e['tracking']);
|
||
final String state = asString(e['state']) ?? previous.state;
|
||
// A tracking-state-less event keeps the last target only while still tracking.
|
||
final bool keep = t == null && _tracking.contains(state);
|
||
final Map<String, dynamic>? r = asMap(t?['rect']);
|
||
return ActiveTrackProgress(
|
||
state: state,
|
||
targetState: t == null ? (keep ? previous.targetState : null) : asString(t['targetState']),
|
||
targetType: t == null ? (keep ? previous.targetType : null) : asString(t['targetType']),
|
||
rect: t == null ? (keep ? previous.rect : null) : _rect(r),
|
||
reason: t == null ? (keep ? previous.reason : null) : asString(t['reason']),
|
||
// Sensed subjects only mean something while auto-sensing looks for them.
|
||
subjects: t == null
|
||
? (state == 'AUTO_SENSING' ? previous.subjects : const <SensedSubject>[])
|
||
: <SensedSubject>[
|
||
for (final Map<String, dynamic> s in asMapList(t['subjects']))
|
||
if (_rect(asMap(s['rect'])) case final Rect box) SensedSubject(asInt(s['index']) ?? 0, box, asString(s['type'])),
|
||
],
|
||
autoSensingSupported: asBool(e['autoSensingSupported']) ?? previous.autoSensingSupported,
|
||
autoSensing: asBool(e['autoSensing']) ?? previous.autoSensing,
|
||
percent: t == null ? (keep ? previous.percent : null) : asInt(t['percent']),
|
||
);
|
||
}
|
||
|
||
static Rect? _rect(Map<String, dynamic>? r) {
|
||
if (r == null) return null;
|
||
final double? l = finite(r['l']), t = finite(r['t']), rr = finite(r['r']), b = finite(r['b']);
|
||
if (l == null || t == null || rr == null || b == null || rr <= l || b <= t) return null;
|
||
return Rect.fromLTRB(l, t, rr, b);
|
||
}
|
||
|
||
static const Set<String> _tracking = <String>{
|
||
'AIRCRAFT_FOLLOWING',
|
||
'ONLY_CAMERA_FOLLOWING',
|
||
'WAITING_FOR_CONFIRMATION',
|
||
'CANNOT_CONFIRM',
|
||
'FINDING_TRACKED_TARGET',
|
||
'PERFORMING_QUICK_SHOT',
|
||
};
|
||
|
||
/// MSDK `ActiveTrackState`.
|
||
final String state;
|
||
|
||
/// `ActiveTrackTargetState`: TRACKING_WITH_HIGH_CONFIDENCE, …_LOW_…, WAITING_FOR_CONFIRMATION, CANNOT_CONFIRM.
|
||
final String? targetState;
|
||
|
||
/// HUMAN, CAR, VAN, BIKE, BOAT, …
|
||
final String? targetType;
|
||
|
||
/// The subject's box, 0..1 across the live view.
|
||
final Rect? rect;
|
||
|
||
/// `ActiveTrackCannotConfirmReason`, when the aircraft cannot follow.
|
||
final String? reason;
|
||
|
||
/// A subject is selected and the operator is working on it.
|
||
bool get active => _tracking.contains(state);
|
||
bool get following => state == 'AIRCRAFT_FOLLOWING';
|
||
bool get waitingForConfirmation => state == 'WAITING_FOR_CONFIRMATION';
|
||
bool get cannotConfirm => state == 'CANNOT_CONFIRM' || targetState == 'CANNOT_CONFIRM';
|
||
bool get unsupported => state == 'NOT_SUPPORT';
|
||
bool get lowConfidence => targetState == 'TRACKING_WITH_LOW_CONFIDENCE';
|
||
|
||
String get stateLabel => switch (state) {
|
||
'AIRCRAFT_FOLLOWING' => 'Following',
|
||
'ONLY_CAMERA_FOLLOWING' => 'Camera only',
|
||
'WAITING_FOR_CONFIRMATION' => 'Confirm subject',
|
||
'CANNOT_CONFIRM' => 'Cannot follow',
|
||
'FINDING_TRACKED_TARGET' => 'Subject lost',
|
||
'PERFORMING_QUICK_SHOT' => 'QuickShot',
|
||
'CANNOT_START' => 'Cannot start',
|
||
'NOT_SUPPORT' => 'Not supported',
|
||
'AUTO_SENSING' => subjects.isEmpty ? 'Looking for subjects' : 'Tap a subject',
|
||
_ => 'Draw a box',
|
||
};
|
||
|
||
String? get reasonLabel => switch (reason) {
|
||
'UNSTABLE_TARGET' => 'Subject moving erratically',
|
||
'TARGET_TOO_HIGH' => 'Subject too high',
|
||
'BLOCKED_BY_OBSTACLE' => 'Obstacle in the way',
|
||
'GIMBAL_ATTITUDE_ERROR' => 'Gimbal angle',
|
||
'TARGET_TOO_FAR' => 'Subject too far',
|
||
'TARGET_TOO_CLOSE' => 'Subject too close',
|
||
'AIRCRAFT_TOO_HIGH' => 'Aircraft too high',
|
||
'AIRCRAFT_TOO_LOW' => 'Aircraft too low',
|
||
'OBSTACLE_SENSOR_ERROR' => 'Obstacle sensor error',
|
||
_ => null,
|
||
};
|
||
|
||
@override
|
||
bool operator ==(Object other) =>
|
||
other is ActiveTrackProgress &&
|
||
other.state == state &&
|
||
other.targetState == targetState &&
|
||
other.targetType == targetType &&
|
||
other.rect == rect &&
|
||
other.reason == reason &&
|
||
listEquals(other.subjects, subjects) &&
|
||
other.autoSensingSupported == autoSensingSupported &&
|
||
other.autoSensing == autoSensing &&
|
||
other.percent == percent;
|
||
|
||
@override
|
||
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).
|
||
@immutable
|
||
class SensedSubject {
|
||
const SensedSubject(this.index, this.rect, this.type);
|
||
|
||
final int index;
|
||
final Rect rect;
|
||
final String? type;
|
||
|
||
@override
|
||
bool operator ==(Object other) => other is SensedSubject && other.index == index && other.rect == rect && other.type == type;
|
||
|
||
@override
|
||
int get hashCode => Object.hash(index, rect, type);
|
||
}
|
||
|
||
/// The TapFly operator as last reported (plan P7b).
|
||
@immutable
|
||
class TapFlyProgress {
|
||
const TapFlyProgress({this.state = 'UNKNOWN', this.image, this.bypass, this.speed, this.relativeHeading});
|
||
|
||
factory TapFlyProgress.fromMap(Map<String, dynamic> e, TapFlyProgress previous) {
|
||
final Map<String, dynamic>? x = asMap(e['execution']);
|
||
final String state = asString(e['state']) ?? previous.state;
|
||
final double? ix = finite(x?['imageX']), iy = finite(x?['imageY']);
|
||
final bool keep = x == null && _flying.contains(state);
|
||
return TapFlyProgress(
|
||
state: state,
|
||
image: x == null ? (keep ? previous.image : null) : (ix == null || iy == null ? null : Offset(ix, iy)),
|
||
bypass: x == null ? (keep ? previous.bypass : null) : asString(x['bypass']),
|
||
speed: x == null ? (keep ? previous.speed : null) : finite(x['speed']),
|
||
relativeHeading: x == null ? (keep ? previous.relativeHeading : null) : finite(x['relativeHeading']),
|
||
);
|
||
}
|
||
|
||
static const Set<String> _flying = <String>{'EXECUTION_STARTING', 'EXECUTING', 'EXECUTION_PAUSED', 'EXECUTION_RESETTING'};
|
||
|
||
/// MSDK `TapFlyMissionState`.
|
||
final String state;
|
||
|
||
/// Where the aircraft is heading on the live view, 0..1.
|
||
final Offset? image;
|
||
|
||
/// NONE / LEFT / RIGHT / OVER while going round an obstacle.
|
||
final String? bypass;
|
||
final double? speed;
|
||
final double? relativeHeading;
|
||
|
||
bool get active => _flying.contains(state);
|
||
bool get paused => state == 'EXECUTION_PAUSED';
|
||
bool get unsupported => state == 'NOT_SUPPORT';
|
||
|
||
String? get bypassLabel => switch (bypass) {
|
||
'LEFT' => 'Going round an obstacle: left',
|
||
'RIGHT' => 'Going round an obstacle: right',
|
||
'OVER' => 'Climbing over an obstacle',
|
||
_ => null,
|
||
};
|
||
|
||
@override
|
||
bool operator ==(Object other) =>
|
||
other is TapFlyProgress &&
|
||
other.state == state &&
|
||
other.image == image &&
|
||
other.bypass == bypass &&
|
||
other.speed == speed &&
|
||
other.relativeHeading == relativeHeading;
|
||
|
||
@override
|
||
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
|
||
/// ([waypoint]); the older ActiveTrack / QuickShot events carry a bare `state`.
|
||
/// [state] is the summary the rail and the gate read.
|
||
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.
|
||
String? waypointResult;
|
||
|
||
/// Bumped on every finish, so listeners can tell a new result from an old one.
|
||
int waypointFinishes = 0;
|
||
|
||
String _legacy = 'idle';
|
||
String? error;
|
||
|
||
/// 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';
|
||
return 'idle';
|
||
}
|
||
|
||
set state(String v) {
|
||
_legacy = v;
|
||
notifyListeners();
|
||
}
|
||
|
||
bool get running => state != 'idle';
|
||
bool get tracking => state == 'tracking' || state == 'quickshot';
|
||
|
||
void apply(Map<String, dynamic> e) {
|
||
switch (asString(e['operator'])) {
|
||
case 'waypoint':
|
||
_applyWaypoint(e);
|
||
return;
|
||
case 'activeTrack':
|
||
final ActiveTrackProgress next = ActiveTrackProgress.fromMap(e, activeTrack);
|
||
final String? err = asString(e['error']);
|
||
if (next == activeTrack && err == null) return;
|
||
activeTrack = next;
|
||
if (err != null) error = err;
|
||
// The operator now speaks for itself; drop the bridge's old guess.
|
||
if (_legacy == 'tracking' || _legacy == 'quickshot') _legacy = 'idle';
|
||
notifyListeners();
|
||
return;
|
||
case 'tapFly':
|
||
final TapFlyProgress next = TapFlyProgress.fromMap(e, tapFly);
|
||
final String? err = asString(e['error']);
|
||
if (next == tapFly && err == null) return;
|
||
tapFly = next;
|
||
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']);
|
||
final String? err = asString(e['error']);
|
||
if (event == 'finish') {
|
||
waypointResult = err ?? '';
|
||
waypointFinishes++;
|
||
} else if (event == 'start') {
|
||
waypointResult = null;
|
||
}
|
||
if (err != null) error = err;
|
||
if (next == waypoint && event == null && err == null) return;
|
||
waypoint = next;
|
||
notifyListeners();
|
||
}
|
||
|
||
void clear() {
|
||
_legacy = 'idle';
|
||
error = null;
|
||
waypoint = const WaypointProgress();
|
||
activeTrack = const ActiveTrackProgress();
|
||
tapFly = const TapFlyProgress();
|
||
hotpoint = const HotpointProgress();
|
||
followMe = const FollowMeProgress();
|
||
notifyListeners();
|
||
}
|
||
}
|