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 ensureDrone({required String flightControllerSerial, String? model, String? firmware}); /// Creates the logbook entry; answers its id. Future createFlight(Map 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? routeGeoJson(List track, {int maxPoints = 500}) { if (track.length < 2) return null; final List pts; if (track.length <= maxPoints) { pts = track; } else { final double step = (track.length - 1) / (maxPoints - 1); pts = [for (int i = 0; i < maxPoints; i++) track[(i * step).round()]]; } return { 'type': 'LineString', // GeoJSON is lon, lat (, alt). 'coordinates': >[ for (final TrackPoint p in pts) [ 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 /// app-recorded ("automatic") path, the §5 times and maximum height, the track /// as GeoJSON, and the flight's numbers in the notes. Map logbookEntry(FlightRecord r, {required String droneId}) { final String notes = [ '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? route = routeGeoJson(r.track); return { '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', '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 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 ensureDrone({required String flightControllerSerial, String? model, String? firmware}) async { final (int status, Map body) = await _send('POST', '/api/drones/auto', { '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 createFlight(Map entry) async { final (int status, Map 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 body, String fallback) { final Object? e = body['error'] ?? body['message']; return e is String && e.isNotEmpty ? e : fallback; } Future<(int, Map)> _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 json = {}; try { final Object? decoded = text.isEmpty ? null : jsonDecode(text); if (decoded is Map) json = Map.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); } } }