import 'dart:convert'; import 'dart:math' as math; /// One flight from briefing to logbook entry. /// /// idle → armed (briefing done, waiting for take-off) /// → active (aircraft airborne, track recording) /// → landed (review before filing; can resume) /// → idle (filed, queued, or discarded) /// /// Take-off and landing are detected from the paired aircraft's Remote ID, and /// the pilot can always start or stop by hand. All of it is plain data so the /// open session survives the app being killed mid-flight. enum SessionPhase { idle, armed, active, landed } class TrackPoint { const TrackPoint(this.at, this.lat, this.lon, this.height); final DateTime at; final double lat; final double lon; /// Metres above take-off; null when the broadcast had none. final double? height; List toJson() => [at.millisecondsSinceEpoch, lat, lon, height]; static TrackPoint fromJson(List j) => TrackPoint( DateTime.fromMillisecondsSinceEpoch((j[0] as num).toInt()), (j[1] as num).toDouble(), (j[2] as num).toDouble(), (j[3] as num?)?.toDouble(), ); } class SessionAlert { const SessionAlert(this.at, this.code, this.message); final DateTime at; final String code; final String message; Map toJson() => {'at': at.millisecondsSinceEpoch, 'code': code, 'message': message}; static SessionAlert fromJson(Map j) => SessionAlert(DateTime.fromMillisecondsSinceEpoch((j['at'] as num).toInt()), '${j['code']}', '${j['message']}'); } /// What one Remote ID fix says, reduced to what the session needs. class FixSample { const FixSample({ required this.at, required this.airborne, this.lat, this.lon, this.height, this.speed, }); final DateTime at; final bool airborne; final double? lat; final double? lon; final double? height; final double? speed; } class FlightSession { FlightSession(); /// EU Open category ceiling; above it the session raises an alert. static const double heightLimit = 120; /// How long the aircraft must look landed before the session stops itself. static const Duration landedHold = Duration(seconds: 15); /// Remote ID silence that ends an active flight (aircraft switched off, or /// out of range: either way the pilot reviews it). static const Duration silenceStop = Duration(seconds: 60); SessionPhase phase = SessionPhase.idle; // Briefing String droneId = ''; String droneName = ''; String ridSerial = ''; String purpose = 'commercial'; String category = 'open'; String areaRoute = ''; String weather = ''; String notes = ''; String incidents = ''; // Flight DateTime? armedAt; DateTime? startedAt; DateTime? endedAt; bool autoStarted = false; bool autoStopped = false; final List track = []; final List alerts = []; double maxHeight = 0; double distance = 0; DateTime? lastFixAt; DateTime? _landedSince; /// Set by the pilot on review when Remote ID had no height (or a wrong one). double? maxHeightOverride; double get effectiveMaxHeight => maxHeightOverride ?? maxHeight; Duration elapsed(DateTime now) { final DateTime? s = startedAt; if (s == null) return Duration.zero; return (endedAt ?? now).difference(s); } void arm(DateTime now) { phase = SessionPhase.armed; armedAt = now; } void start(DateTime now, {bool auto = false}) { phase = SessionPhase.active; startedAt ??= now; endedAt = null; autoStarted = auto; _landedSince = null; } void stop(DateTime at, {bool auto = false}) { phase = SessionPhase.landed; endedAt = at; autoStopped = auto; } /// Back to recording after a stop that was premature (a hover close to the /// ground, a Remote ID gap). void resume() { phase = SessionPhase.active; endedAt = null; _landedSince = null; } /// Feeds one fix of the paired aircraft. Returns true when the phase changed. bool onFix(FixSample f) { lastFixAt = f.at; switch (phase) { case SessionPhase.idle: case SessionPhase.landed: return false; case SessionPhase.armed: if (!f.airborne) return false; start(f.at, auto: true); _record(f); return true; case SessionPhase.active: _record(f); if (f.airborne) { _landedSince = null; return false; } _landedSince ??= f.at; if (f.at.difference(_landedSince!) >= landedHold) { // The flight ended when it touched down, not when the hold expired. stop(_landedSince!, auto: true); return true; } return false; } } /// Called periodically: ends an active flight whose Remote ID went silent. bool onTick(DateTime now) { if (phase != SessionPhase.active) return false; final DateTime? last = lastFixAt ?? startedAt; if (last == null || now.difference(last) < silenceStop) return false; _alertOnce(now, 'rid_lost', 'Remote ID lost for ${silenceStop.inSeconds} s; flight stopped at last contact'); stop(last, auto: true); return true; } void _record(FixSample f) { final double? h = f.height; if (h != null && h > maxHeight) maxHeight = h; if (h != null && h > heightLimit) { _alertOnce(f.at, 'height', 'Above ${heightLimit.round()} m (${h.round()} m)'); } if (f.lat == null || f.lon == null) return; final TrackPoint p = TrackPoint(f.at, f.lat!, f.lon!, h); if (track.isNotEmpty) { final TrackPoint prev = track.last; final double d = haversine(prev.lat, prev.lon, p.lat, p.lon); // Ignore sub-metre jitter so a hover doesn't add distance. if (d < 1 && p.at.difference(prev.at) < const Duration(seconds: 10)) return; distance += d; } track.add(p); } void _alertOnce(DateTime at, String code, String message) { if (alerts.any((SessionAlert a) => a.code == code)) return; alerts.add(SessionAlert(at, code, message)); } /// The `POST /api/flights` body (API Server `flightInput`). /// /// Logged on the Fly Agent's own path, `automatic_flyagentapp`. Remote ID gives /// times, track and height but is not the aircraft's flight data recorder, so /// the API Server still requires every § 5 field and they are all filled here. Map toFlightInput() { final DateTime s = (startedAt ?? armedAt ?? DateTime.now()).toLocal(); final DateTime e = (endedAt ?? DateTime.now()).toLocal(); final List alertLines = alerts.map((SessionAlert a) => '${_hhmm(a.at.toLocal())} ${a.message}').toList(); final String incidentText = [ if (incidents.trim().isNotEmpty) incidents.trim(), ...alertLines, ].join('\n'); final String source = 'Logged by PilotVault Fly Agent from Remote ID' '${ridSerial.isNotEmpty ? ' (serial $ridSerial)' : ''}' '${track.isNotEmpty ? ', ${track.length} fixes, ${(distance / 1000).toStringAsFixed(2)} km' : ''}.'; return { 'operationDate': _date(s), 'startTime': _hhmm(s), 'endTime': _hhmm(e), 'drone': droneId, 'areaRoute': areaRoute.trim().isNotEmpty ? areaRoute.trim() : _autoArea(), if (track.length >= 2) 'routeGeojson': routeGeoJson(), 'maxAltitudeAgl': double.parse(effectiveMaxHeight.toStringAsFixed(1)), 'category': category, 'purpose': purpose, 'loggingPath': 'automatic_flyagentapp', 'weather': weather, 'incidents': incidentText, 'notes': [if (notes.trim().isNotEmpty) notes.trim(), source].join('\n'), }; } Map routeGeoJson() => { 'type': 'LineString', 'coordinates': track .map((TrackPoint p) => [ double.parse(p.lon.toStringAsFixed(7)), double.parse(p.lat.toStringAsFixed(7)), if (p.height != null) double.parse(p.height!.toStringAsFixed(1)), ]) .toList(), }; String _autoArea() { if (track.isEmpty) return ''; final TrackPoint p = track.first; return 'Around ${p.lat.toStringAsFixed(5)}, ${p.lon.toStringAsFixed(5)} (Remote ID track)'; } // ── persistence ──────────────────────────────────────────────────────── String encode() => jsonEncode({ 'phase': phase.name, 'droneId': droneId, 'droneName': droneName, 'ridSerial': ridSerial, 'purpose': purpose, 'category': category, 'areaRoute': areaRoute, 'weather': weather, 'notes': notes, 'incidents': incidents, 'armedAt': armedAt?.millisecondsSinceEpoch, 'startedAt': startedAt?.millisecondsSinceEpoch, 'endedAt': endedAt?.millisecondsSinceEpoch, 'lastFixAt': lastFixAt?.millisecondsSinceEpoch, 'autoStarted': autoStarted, 'autoStopped': autoStopped, 'maxHeight': maxHeight, 'maxHeightOverride': maxHeightOverride, 'distance': distance, 'track': track.map((TrackPoint p) => p.toJson()).toList(), 'alerts': alerts.map((SessionAlert a) => a.toJson()).toList(), }); static FlightSession decode(String raw) { final Map j = (jsonDecode(raw) as Map).cast(); DateTime? t(String k) => j[k] == null ? null : DateTime.fromMillisecondsSinceEpoch((j[k] as num).toInt()); final FlightSession s = FlightSession() ..phase = SessionPhase.values.firstWhere((SessionPhase p) => p.name == j['phase'], orElse: () => SessionPhase.idle) ..droneId = '${j['droneId'] ?? ''}' ..droneName = '${j['droneName'] ?? ''}' ..ridSerial = '${j['ridSerial'] ?? ''}' ..purpose = '${j['purpose'] ?? 'commercial'}' ..category = '${j['category'] ?? 'open'}' ..areaRoute = '${j['areaRoute'] ?? ''}' ..weather = '${j['weather'] ?? ''}' ..notes = '${j['notes'] ?? ''}' ..incidents = '${j['incidents'] ?? ''}' ..armedAt = t('armedAt') ..startedAt = t('startedAt') ..endedAt = t('endedAt') ..lastFixAt = t('lastFixAt') ..autoStarted = j['autoStarted'] == true ..autoStopped = j['autoStopped'] == true ..maxHeight = (j['maxHeight'] as num?)?.toDouble() ?? 0 ..maxHeightOverride = (j['maxHeightOverride'] as num?)?.toDouble() ..distance = (j['distance'] as num?)?.toDouble() ?? 0; s.track.addAll(((j['track'] as List?) ?? const []).map((Object? e) => TrackPoint.fromJson(e! as List))); s.alerts.addAll(((j['alerts'] as List?) ?? const []) .map((Object? e) => SessionAlert.fromJson((e! as Map).cast()))); return s; } } String _two(int n) => n.toString().padLeft(2, '0'); String _date(DateTime d) => '${d.year}-${_two(d.month)}-${_two(d.day)}'; String _hhmm(DateTime d) => '${_two(d.hour)}:${_two(d.minute)}'; /// Great-circle distance in metres. double haversine(double lat1, double lon1, double lat2, double lon2) { const double r = 6371000; final double p1 = lat1 * math.pi / 180; final double p2 = lat2 * math.pi / 180; final double dp = (lat2 - lat1) * math.pi / 180; final double dl = (lon2 - lon1) * math.pi / 180; final double a = math.sin(dp / 2) * math.sin(dp / 2) + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) * math.sin(dl / 2); return 2 * r * math.atan2(math.sqrt(a), math.sqrt(1 - a)); }