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>
323 lines
11 KiB
Dart
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));
|
|
}
|