Files
PilotVault/Fly App/lib/fly/flight_record_sync.dart
T
tajniak81andClaude Opus 5.5 bc41d8b128 Split the automatic logging path by the app that logged the flight
logging_path is now manual, automatic_flyapp or automatic_flyagentapp. The
Fly App's entries are backed by the aircraft's flight recorder and keep the
missing-FDR red flag; the Fly Agent App's come from Remote ID, which is not a
flight recorder, so they must carry the § 5 fields like a manual entry. The API
maps the old "automatic" onto automatic_flyapp so Fly App builds already on
devices keep filing. The migration has been applied to the remote PocketBase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:36:16 +02:00

183 lines
7.6 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import '../pb_auth.dart';
import 'flight_record.dart';
/// The API Server's logbook (`/api/drones/auto`, `/api/flights`) as the
/// recorded-flight upload needs it. [ApiLogbookRemote] is the real one.
abstract class LogbookRemote {
/// Signed in, so uploads can run.
bool get available;
/// The logbook's drone for this aircraft, created on first sight (keyed by
/// the flight controller's serial, like the Web App's fleet auto-fill).
Future<String> ensureDrone({required String flightControllerSerial, String? model, String? firmware});
/// Creates the logbook entry; answers its id.
Future<String> createFlight(Map<String, Object?> entry);
}
class LogbookException implements Exception {
const LogbookException(this.message);
final String message;
@override
String toString() => message;
}
/// `2026-10-20`, in local time like the Web App's date field.
String logbookDate(DateTime t) =>
'${t.year.toString().padLeft(4, '0')}-${t.month.toString().padLeft(2, '0')}-${t.day.toString().padLeft(2, '0')}';
/// `14:05`, the logbook's `HH:MM`.
String logbookTime(DateTime t) => '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
/// The track as a GeoJSON LineString, thinned evenly to at most [maxPoints]
/// (the `route_geojson` field caps at 200 kB). Null with fewer than two points.
Map<String, Object?>? routeGeoJson(List<TrackPoint> track, {int maxPoints = 500}) {
if (track.length < 2) return null;
final List<TrackPoint> pts;
if (track.length <= maxPoints) {
pts = track;
} else {
final double step = (track.length - 1) / (maxPoints - 1);
pts = <TrackPoint>[for (int i = 0; i < maxPoints; i++) track[(i * step).round()]];
}
return <String, Object?>{
'type': 'LineString',
// GeoJSON is lon, lat (, alt).
'coordinates': <List<double>>[
for (final TrackPoint p in pts)
<double>[
double.parse(p.longitude.toStringAsFixed(6)),
double.parse(p.latitude.toStringAsFixed(6)),
double.parse(p.altitude.toStringAsFixed(1)),
],
],
};
}
/// Where the flight took place, for the logbook's "area or route flown":
/// the take-off point, as coordinates.
String areaLabel(FlightRecord r) {
double? lat = r.homeLatitude, lon = r.homeLongitude;
if (!validFix(lat, lon) && r.track.isNotEmpty) {
lat = r.track.first.latitude;
lon = r.track.first.longitude;
}
if (!validFix(lat, lon)) return 'Position not recorded';
String deg(double v, String pos, String neg) => '${v.abs().toStringAsFixed(5)}° ${v >= 0 ? pos : neg}';
return 'Take-off ${deg(lat!, 'N', 'S')}, ${deg(lon!, 'E', 'W')}';
}
/// The `/api/flights` body for [r] flown with logbook drone [droneId]: the
/// Fly App's own logging path (`automatic_flyapp`), the §5 times and maximum height, the track
/// as GeoJSON, and the flight's numbers in the notes.
Map<String, Object?> logbookEntry(FlightRecord r, {required String droneId}) {
final String notes = <String>[
'Recorded by PilotVault Fly',
r.durationLabel,
'${(r.distanceFlown / 1000).toStringAsFixed(2)} km flown',
'max ${r.maxDistance.round()} m from home',
'max ${r.maxSpeed.toStringAsFixed(1)} m/s',
if (r.batteryStart != null && r.batteryEnd != null) 'battery ${r.batteryStart}% → ${r.batteryEnd}%',
].join(' · ');
final Map<String, Object?>? route = routeGeoJson(r.track);
return <String, Object?>{
'operationDate': logbookDate(r.start),
'startTime': logbookTime(r.start),
'endTime': logbookTime(r.end),
'drone': droneId,
'areaRoute': areaLabel(r),
if (route != null) 'routeGeojson': route,
// Height above take-off: the closest the aircraft's barometer gets to AGL.
'maxAltitudeAgl': double.parse(r.maxAltitude.toStringAsFixed(1)),
'loggingPath': 'automatic_flyapp',
'notes': notes,
};
}
/// Uploads one recorded flight to the logbook. Returns the record with
/// [FlightRecord.logbookId] set, or [FlightRecord.uploadError] on failure.
/// Simulator flights and flights without a serial are refused up front.
Future<FlightRecord> uploadFlightRecord(FlightRecord r, LogbookRemote remote) async {
if (r.uploaded) return r;
try {
if (r.simulated) throw const LogbookException('Simulator flights are not logbook flights');
if (!remote.available) throw const LogbookException('Sign in to upload flights');
final String serial = (r.flightControllerSerial ?? '').trim();
if (serial.isEmpty) throw const LogbookException('The aircraft\'s serial was not read during this flight');
final String drone = await remote.ensureDrone(flightControllerSerial: serial, model: r.aircraftModel, firmware: r.firmware);
r.logbookId = await remote.createFlight(logbookEntry(r, droneId: drone));
r.uploadError = null;
} on LogbookException catch (e) {
r.uploadError = e.message;
} catch (e) {
r.uploadError = e is SocketException || e is TimeoutException ? 'The API Server could not be reached' : e.toString();
}
return r;
}
/// [LogbookRemote] over the API Server, as the signed-in pilot ([auth]).
class ApiLogbookRemote implements LogbookRemote {
ApiLogbookRemote({PbAuth? auth}) : _auth = auth;
final PbAuth? _auth;
PbAuth get _a => _auth ?? auth;
static const Duration _timeout = Duration(seconds: 15);
@override
bool get available => _a.isAuthed;
@override
Future<String> ensureDrone({required String flightControllerSerial, String? model, String? firmware}) async {
final (int status, Map<String, dynamic> body) = await _send('POST', '/api/drones/auto', <String, Object?>{
'flightControllerSerial': flightControllerSerial,
if (model != null) 'model': model,
if (firmware != null) 'firmware': firmware,
});
final Object? drone = body['drone'];
final Object? id = drone is Map ? drone['id'] : null;
if ((status == 200 || status == 201) && id is String && id.isNotEmpty) return id;
throw LogbookException(_error(body, 'Could not add the aircraft to the fleet ($status)'));
}
@override
Future<String> createFlight(Map<String, Object?> entry) async {
final (int status, Map<String, dynamic> body) = await _send('POST', '/api/flights', entry);
final Object? flight = body['flight'];
final Object? id = flight is Map ? flight['id'] : null;
if ((status == 200 || status == 201) && id is String && id.isNotEmpty) return id;
throw LogbookException(_error(body, 'The logbook refused the flight ($status)'));
}
static String _error(Map<String, dynamic> body, String fallback) {
final Object? e = body['error'] ?? body['message'];
return e is String && e.isNotEmpty ? e : fallback;
}
Future<(int, Map<String, dynamic>)> _send(String method, String path, Object body) async {
final HttpClient http = HttpClient()..connectionTimeout = _timeout;
try {
final HttpClientRequest req = await http.openUrl(method, Uri.parse('${_a.serverUrl}$path'));
req.headers.set(HttpHeaders.authorizationHeader, _a.token);
req.headers.contentType = ContentType.json;
req.add(utf8.encode(jsonEncode(body)));
final HttpClientResponse resp = await req.close().timeout(_timeout);
final String text = await resp.transform(utf8.decoder).join().timeout(_timeout);
Map<String, dynamic> json = <String, dynamic>{};
try {
final Object? decoded = text.isEmpty ? null : jsonDecode(text);
if (decoded is Map) json = Map<String, dynamic>.from(decoded);
} catch (_) {}
if (resp.statusCode == 401) throw const LogbookException('Your session expired: sign in again');
return (resp.statusCode, json);
} finally {
http.close(force: true);
}
}
}