Playback: the flight screen's playback button opens the album over the live view, so a video tapped there plays on the aircraft into the feed with a transport bar. Long press selects files to download or delete (delete asks first); one file opens in a viewer with the camera's preview. Leaving the album puts the camera back in its capture mode. Flight records: every flight is recorded from take-off to landing (1 Hz track, max height / distance / speed, distance flown, battery used) and kept on the phone. When it lands while signed in, it is uploaded to the PilotVault logbook through the existing /api/drones/auto and /api/flights endpoints as an automatic-path entry with the track as GeoJSON. Simulator flights are never uploaded; flights recorded while signed out retry on sign-in. Flight logs lists them with their upload state and a track map, and the profile stats come from real flights. Find My Drone: the aircraft's last fix is kept across lost links and restarts, and shown against the phone's own GPS with distance, direction and a hand-off to the maps app. Live streaming: a new LiveStreamBridge streams the main camera to an RTMP address set in Settings > General, with a LIVE pill on the flight screen that turns amber when the uplink falls behind and stops on tap. Pre-flight checklist (manual checks plus the aircraft's live status) and an in-app Academy with six short guides that work offline. None of this has run on hardware yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
267 lines
8.8 KiB
Dart
267 lines
8.8 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:latlong2/latlong.dart' show LatLng;
|
|
|
|
import '../flight_model.dart';
|
|
import 'hud_geometry.dart';
|
|
import 'state/aircraft_state.dart';
|
|
import 'state/parse.dart';
|
|
|
|
/// One sample of a recorded flight: where the aircraft was [t] seconds after take-off.
|
|
@immutable
|
|
class TrackPoint {
|
|
const TrackPoint(this.latitude, this.longitude, this.altitude, this.t);
|
|
|
|
/// Stored compactly as `[lat, lon, alt, t]`.
|
|
factory TrackPoint.fromJson(List<Object?> j) =>
|
|
TrackPoint(finite(j[0]) ?? 0, finite(j[1]) ?? 0, finite(j[2]) ?? 0, asInt(j[3]) ?? 0);
|
|
|
|
final double latitude;
|
|
final double longitude;
|
|
final double altitude; // m, relative to take-off
|
|
final int t; // s since take-off
|
|
|
|
LatLng get latLng => LatLng(latitude, longitude);
|
|
|
|
List<Object> toJson() => <Object>[
|
|
double.parse(latitude.toStringAsFixed(7)),
|
|
double.parse(longitude.toStringAsFixed(7)),
|
|
double.parse(altitude.toStringAsFixed(1)),
|
|
t,
|
|
];
|
|
}
|
|
|
|
/// One flight, take-off to landing, as the Fly App recorded it from telemetry
|
|
/// (plan P9 "flight records"). Kept on the phone by `FlightRecordStore` and
|
|
/// uploaded to the API Server's logbook by `FlightLogSync`.
|
|
class FlightRecord {
|
|
FlightRecord({
|
|
required this.id,
|
|
required this.start,
|
|
required this.end,
|
|
this.aircraftModel,
|
|
this.flightControllerSerial,
|
|
this.firmware,
|
|
this.homeLatitude,
|
|
this.homeLongitude,
|
|
List<TrackPoint>? track,
|
|
this.maxAltitude = 0,
|
|
this.maxDistance = 0,
|
|
this.maxSpeed = 0,
|
|
this.distanceFlown = 0,
|
|
this.batteryStart,
|
|
this.batteryEnd,
|
|
this.simulated = false,
|
|
this.logbookId,
|
|
this.uploadError,
|
|
}) : track = track ?? <TrackPoint>[];
|
|
|
|
factory FlightRecord.fromJson(Map<String, dynamic> j) => FlightRecord(
|
|
id: asString(j['id']) ?? '',
|
|
start: DateTime.fromMillisecondsSinceEpoch(asInt(j['start']) ?? 0),
|
|
end: DateTime.fromMillisecondsSinceEpoch(asInt(j['end']) ?? 0),
|
|
aircraftModel: asString(j['model']),
|
|
flightControllerSerial: asString(j['fcSerial']),
|
|
firmware: asString(j['firmware']),
|
|
homeLatitude: finite(j['homeLat']),
|
|
homeLongitude: finite(j['homeLon']),
|
|
track: <TrackPoint>[
|
|
for (final Object? p in (j['track'] as List<Object?>? ?? const <Object?>[]))
|
|
if (p is List && p.length >= 4) TrackPoint.fromJson(p),
|
|
],
|
|
maxAltitude: finite(j['maxAlt']) ?? 0,
|
|
maxDistance: finite(j['maxDist']) ?? 0,
|
|
maxSpeed: finite(j['maxSpeed']) ?? 0,
|
|
distanceFlown: finite(j['distance']) ?? 0,
|
|
batteryStart: asInt(j['batteryStart']),
|
|
batteryEnd: asInt(j['batteryEnd']),
|
|
simulated: asBool(j['simulated']) ?? false,
|
|
logbookId: asString(j['logbookId']),
|
|
uploadError: asString(j['uploadError']),
|
|
);
|
|
|
|
/// `fr-<take-off ms>`, also the file name.
|
|
final String id;
|
|
final DateTime start;
|
|
DateTime end;
|
|
final String? aircraftModel;
|
|
final String? flightControllerSerial;
|
|
final String? firmware;
|
|
double? homeLatitude;
|
|
double? homeLongitude;
|
|
final List<TrackPoint> track;
|
|
|
|
double maxAltitude; // m above take-off
|
|
double maxDistance; // m from home
|
|
double maxSpeed; // m/s horizontal
|
|
double distanceFlown; // m along the track
|
|
int? batteryStart; // %
|
|
int? batteryEnd; // %
|
|
|
|
/// Flown in the flight controller's simulator: never a logbook flight.
|
|
bool simulated;
|
|
|
|
/// The API Server's flight id once uploaded.
|
|
String? logbookId;
|
|
|
|
/// Why the last upload failed, until one succeeds.
|
|
String? uploadError;
|
|
|
|
bool get uploaded => logbookId != null;
|
|
Duration get duration => end.difference(start);
|
|
|
|
/// The last place the aircraft was seen on this flight.
|
|
TrackPoint? get lastPoint => track.isEmpty ? null : track.last;
|
|
|
|
Map<String, Object?> toJson() => <String, Object?>{
|
|
'id': id,
|
|
'start': start.millisecondsSinceEpoch,
|
|
'end': end.millisecondsSinceEpoch,
|
|
'model': aircraftModel,
|
|
'fcSerial': flightControllerSerial,
|
|
'firmware': firmware,
|
|
'homeLat': homeLatitude,
|
|
'homeLon': homeLongitude,
|
|
'track': <Object>[for (final TrackPoint p in track) p.toJson()],
|
|
'maxAlt': maxAltitude,
|
|
'maxDist': maxDistance,
|
|
'maxSpeed': maxSpeed,
|
|
'distance': distanceFlown,
|
|
'batteryStart': batteryStart,
|
|
'batteryEnd': batteryEnd,
|
|
'simulated': simulated,
|
|
'logbookId': logbookId,
|
|
'uploadError': uploadError,
|
|
};
|
|
|
|
/// `12 min 05 s`, `45 s`.
|
|
String get durationLabel {
|
|
final int s = duration.inSeconds;
|
|
if (s < 60) return '$s s';
|
|
final int h = s ~/ 3600, m = (s % 3600) ~/ 60;
|
|
final String ss = (s % 60).toString().padLeft(2, '0');
|
|
return h > 0 ? '$h h ${m.toString().padLeft(2, '0')} min' : '$m min $ss s';
|
|
}
|
|
}
|
|
|
|
/// A usable GPS fix: present and not the SDK's (0, 0) "no fix".
|
|
bool validFix(double? lat, double? lon) => lat != null && lon != null && !(lat == 0 && lon == 0) && lat.abs() <= 90 && lon.abs() <= 180;
|
|
|
|
/// Records every flight from telemetry (plan P9): a flight starts when the
|
|
/// flight controller reports `isFlying`, and ends once it has reported landed
|
|
/// for [landedGrace], or when the aircraft disconnects mid-air. Each finished
|
|
/// flight goes to [onFinished]; flights shorter than [minDuration] (a hop, a
|
|
/// glitch) are dropped.
|
|
///
|
|
/// The track keeps a point every [sampleEvery] with a valid fix; maxima are
|
|
/// taken on every tick.
|
|
class FlightRecorder extends ChangeNotifier {
|
|
FlightRecorder({
|
|
required this.model,
|
|
required this.onFinished,
|
|
DateTime Function()? clock,
|
|
this.sampleEvery = const Duration(seconds: 1),
|
|
this.landedGrace = const Duration(seconds: 5),
|
|
this.minDuration = const Duration(seconds: 5),
|
|
}) : _clock = clock ?? DateTime.now {
|
|
model.aircraft.addListener(_onAircraft);
|
|
model.addListener(_onModel);
|
|
}
|
|
|
|
final FlightModel model;
|
|
final void Function(FlightRecord record) onFinished;
|
|
final DateTime Function() _clock;
|
|
final Duration sampleEvery;
|
|
final Duration landedGrace;
|
|
final Duration minDuration;
|
|
|
|
FlightRecord? _current;
|
|
DateTime? _lastSample;
|
|
DateTime? _landedAt;
|
|
DateTime? _lastSeen;
|
|
|
|
/// The flight being recorded, or null on the ground.
|
|
FlightRecord? get current => _current;
|
|
bool get recording => _current != null;
|
|
|
|
void _onModel() {
|
|
// Gone mid-air: the flight ends where it was last seen.
|
|
if (!model.connected && _current != null) _finish(_lastSeen ?? _clock());
|
|
}
|
|
|
|
void _onAircraft() {
|
|
final DateTime now = _clock();
|
|
final bool? flying = model.aircraft.isFlying;
|
|
final FlightRecord? r = _current;
|
|
if (r == null) {
|
|
if (flying == true && model.connected) _begin(now);
|
|
return;
|
|
}
|
|
if (flying == null) return; // a cleared snapshot; the disconnect ends it
|
|
_lastSeen = now;
|
|
_sample(r, now);
|
|
if (flying) {
|
|
_landedAt = null;
|
|
} else {
|
|
_landedAt ??= now;
|
|
if (now.difference(_landedAt!) >= landedGrace) _finish(_landedAt!);
|
|
}
|
|
}
|
|
|
|
void _begin(DateTime now) {
|
|
final FlightRecord r = FlightRecord(
|
|
id: 'fr-${now.millisecondsSinceEpoch}',
|
|
start: now,
|
|
end: now,
|
|
aircraftModel: model.model,
|
|
flightControllerSerial: model.flightControllerSerial,
|
|
firmware: model.firmwareVersion,
|
|
batteryStart: model.battery.percent,
|
|
simulated: model.aircraft.simulatorActive == true,
|
|
);
|
|
_current = r;
|
|
_lastSample = null;
|
|
_landedAt = null;
|
|
_lastSeen = now;
|
|
_sample(r, now);
|
|
notifyListeners();
|
|
}
|
|
|
|
void _sample(FlightRecord r, DateTime now) {
|
|
final AircraftState a = model.aircraft;
|
|
r.end = now;
|
|
if (a.simulatorActive == true) r.simulated = true;
|
|
if (validFix(a.homeLatitude, a.homeLongitude)) {
|
|
r.homeLatitude = a.homeLatitude;
|
|
r.homeLongitude = a.homeLongitude;
|
|
}
|
|
final double alt = a.altitude ?? 0;
|
|
if (alt > r.maxAltitude) r.maxAltitude = alt;
|
|
if ((a.homeDistance ?? 0) > r.maxDistance) r.maxDistance = a.homeDistance!;
|
|
if ((a.horizontalSpeed ?? 0) > r.maxSpeed) r.maxSpeed = a.horizontalSpeed!;
|
|
if (model.battery.percent != null) r.batteryEnd = model.battery.percent;
|
|
|
|
if (!validFix(a.latitude, a.longitude)) return;
|
|
if (_lastSample != null && now.difference(_lastSample!) < sampleEvery) return;
|
|
_lastSample = now;
|
|
final TrackPoint p = TrackPoint(a.latitude!, a.longitude!, alt, now.difference(r.start).inSeconds);
|
|
final TrackPoint? last = r.lastPoint;
|
|
if (last != null) r.distanceFlown += HudGeometry.distance(last.latitude, last.longitude, p.latitude, p.longitude);
|
|
r.track.add(p);
|
|
}
|
|
|
|
void _finish(DateTime end) {
|
|
final FlightRecord r = _current!;
|
|
_current = null;
|
|
r.end = end.isBefore(r.start) ? r.start : end;
|
|
notifyListeners();
|
|
if (r.duration >= minDuration) onFinished(r);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
model.aircraft.removeListener(_onAircraft);
|
|
model.removeListener(_onModel);
|
|
super.dispose();
|
|
}
|
|
}
|