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>
183 lines
7.6 KiB
Dart
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);
|
|
}
|
|
}
|
|
}
|