Files
PilotVault/Fly Agent App/lib/session/flight_session.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

323 lines
11 KiB
Dart

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<Object?> toJson() => <Object?>[at.millisecondsSinceEpoch, lat, lon, height];
static TrackPoint fromJson(List<Object?> 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<String, Object> toJson() => <String, Object>{'at': at.millisecondsSinceEpoch, 'code': code, 'message': message};
static SessionAlert fromJson(Map<String, dynamic> 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<TrackPoint> track = <TrackPoint>[];
final List<SessionAlert> alerts = <SessionAlert>[];
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<String, dynamic> toFlightInput() {
final DateTime s = (startedAt ?? armedAt ?? DateTime.now()).toLocal();
final DateTime e = (endedAt ?? DateTime.now()).toLocal();
final List<String> alertLines = alerts.map((SessionAlert a) => '${_hhmm(a.at.toLocal())} ${a.message}').toList();
final String incidentText = <String>[
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 <String, dynamic>{
'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': <String>[if (notes.trim().isNotEmpty) notes.trim(), source].join('\n'),
};
}
Map<String, dynamic> routeGeoJson() => <String, dynamic>{
'type': 'LineString',
'coordinates': track
.map((TrackPoint p) => <double>[
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(<String, dynamic>{
'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<String, dynamic> j = (jsonDecode(raw) as Map).cast<String, dynamic>();
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 <Object?>[]).map((Object? e) => TrackPoint.fromJson(e! as List<Object?>)));
s.alerts.addAll(((j['alerts'] as List?) ?? const <Object?>[])
.map((Object? e) => SessionAlert.fromJson((e! as Map).cast<String, dynamic>())));
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));
}