Files
PilotVault/Fly Agent App/lib/session/session_controller.dart
T
tajniak81andClaude Opus 5.5 3dd31b257a Add the Fly Agent App, a Remote ID companion to DJI Fly
A Flutter app that runs next to DJI Fly without the DJI SDK. It receives the
aircraft's Remote ID (Bluetooth, BT5, Wi-Fi NaN and Beacon) in a foreground
service, streams the paired aircraft to the hub over /ws/device, and turns
take-off to landing into a logbook entry filed through /api/flights, queued
when offline. Covers phases A0-A3 of the Fly Agent plan; needs no server change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:25:52 +02:00

209 lines
5.8 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../api/api.dart';
import '../rid/rid_aircraft.dart';
import '../rid/rid_service.dart';
import 'flight_session.dart';
/// A logbook entry that could not be filed (offline): kept until it uploads.
class PendingFlight {
const PendingFlight(this.id, this.body, this.error);
final String id;
final Map<String, dynamic> body;
final String error;
String get summary => '${body['operationDate']} ${body['startTime']}–${body['endTime']}';
}
/// Owns the current [FlightSession]: feeds it the paired aircraft's fixes,
/// persists it, and files it to the logbook (queueing when offline).
class SessionController extends ChangeNotifier {
SessionController({required this.api, required this.rid});
final Api api;
final RidService rid;
FlightSession session = FlightSession();
final List<PendingFlight> pending = <PendingFlight>[];
StreamSubscription<RidAircraft>? _sub;
Timer? _tick;
DateTime _lastSave = DateTime.fromMillisecondsSinceEpoch(0);
SharedPreferences? _prefs;
bool filing = false;
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
final String? raw = _prefs!.getString('session');
if (raw != null) {
try {
session = FlightSession.decode(raw);
} catch (_) {
session = FlightSession();
}
}
final String? q = _prefs!.getString('pending_flights');
if (q != null) {
try {
for (final Object? e in jsonDecode(q) as List<Object?>) {
final Map<String, dynamic> m = (e! as Map).cast<String, dynamic>();
pending.add(PendingFlight('${m['id']}', (m['body'] as Map).cast<String, dynamic>(), '${m['error'] ?? ''}'));
}
} catch (_) {}
}
_sub = rid.pairedLocations.listen(_onFix);
_tick = Timer.periodic(const Duration(seconds: 1), (_) {
if (session.onTick(DateTime.now())) _save(force: true);
// Elapsed time on screen.
if (session.phase == SessionPhase.active || session.phase == SessionPhase.armed) notifyListeners();
});
notifyListeners();
}
void _onFix(RidAircraft a) {
if (session.ridSerial.isNotEmpty && a.serial != session.ridSerial) return;
final bool changed = session.onFix(FixSample(
at: a.lastLocationAt ?? DateTime.now(),
airborne: a.isAirborne,
lat: a.latitude,
lon: a.longitude,
height: a.height,
speed: a.speedHorizontal,
));
_save(force: changed);
if (changed) notifyListeners();
}
/// Briefing done: wait for take-off.
void arm({
required String droneId,
required String droneName,
required String purpose,
required String category,
required String areaRoute,
required String weather,
}) {
session = FlightSession()
..droneId = droneId
..droneName = droneName
..ridSerial = rid.pairing?.serial ?? ''
..purpose = purpose
..category = category
..areaRoute = areaRoute
..weather = weather;
session.arm(DateTime.now());
_save(force: true);
notifyListeners();
}
void startNow() {
session.start(DateTime.now());
_save(force: true);
notifyListeners();
}
void stopNow() {
session.stop(DateTime.now());
_save(force: true);
notifyListeners();
}
void resume() {
session.resume();
_save(force: true);
notifyListeners();
}
void discard() {
session = FlightSession();
_save(force: true);
notifyListeners();
}
/// Saves review edits made on the landed screen.
void edited() {
_save(force: true);
notifyListeners();
}
/// Files the landed session. Returns null on success, else a message; a
/// network failure queues the entry instead of losing it.
Future<String?> file() async {
final Map<String, dynamic> body = session.toFlightInput();
filing = true;
notifyListeners();
try {
await api.createFlight(body);
session = FlightSession();
return null;
} on ApiException catch (e) {
if (!e.isNetwork) return e.message; // the pilot has to fix the entry
pending.add(PendingFlight(DateTime.now().millisecondsSinceEpoch.toString(), body, e.message));
_savePending();
session = FlightSession();
return 'Offline: the entry is queued and will be filed when the server is reachable.';
} finally {
filing = false;
_save(force: true);
notifyListeners();
}
}
/// Retries queued entries. Returns how many were filed.
Future<int> retryPending() async {
int ok = 0;
for (final PendingFlight p in List<PendingFlight>.of(pending)) {
try {
await api.createFlight(p.body);
pending.remove(p);
ok++;
} on ApiException catch (e) {
final int i = pending.indexOf(p);
if (i >= 0) pending[i] = PendingFlight(p.id, p.body, e.message);
if (e.isNetwork) break;
}
}
_savePending();
notifyListeners();
return ok;
}
void dropPending(PendingFlight p) {
pending.remove(p);
_savePending();
notifyListeners();
}
void _save({bool force = false}) {
final DateTime now = DateTime.now();
// A fix a second would otherwise rewrite the whole track every second.
if (!force && now.difference(_lastSave) < const Duration(seconds: 10)) return;
_lastSave = now;
if (session.phase == SessionPhase.idle) {
_prefs?.remove('session');
} else {
_prefs?.setString('session', session.encode());
}
}
void _savePending() {
_prefs?.setString(
'pending_flights',
jsonEncode(pending
.map((PendingFlight p) => <String, dynamic>{'id': p.id, 'body': p.body, 'error': p.error})
.toList()),
);
}
@override
void dispose() {
_save(force: true);
_sub?.cancel();
_tick?.cancel();
super.dispose();
}
}