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>
225 lines
7.7 KiB
Dart
225 lines
7.7 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import 'odid_decoder.dart';
|
|
import 'rid_aircraft.dart';
|
|
|
|
/// State of one scanner as reported by the native side.
|
|
class TransportStatus {
|
|
const TransportStatus(this.state, this.detail);
|
|
final String state; // scanning | stopped | unavailable | denied | failed
|
|
final String detail;
|
|
}
|
|
|
|
/// The paired aircraft: its broadcast serial and the PilotVault drone it is.
|
|
class RidPairing {
|
|
const RidPairing({required this.serial, required this.droneId, required this.droneName});
|
|
final String serial;
|
|
final String droneId;
|
|
final String droneName;
|
|
|
|
Map<String, dynamic> toJson() => <String, dynamic>{'serial': serial, 'droneId': droneId, 'droneName': droneName};
|
|
|
|
static RidPairing? fromJson(Object? j) {
|
|
if (j is! Map) return null;
|
|
final String serial = (j['serial'] ?? '').toString();
|
|
if (serial.isEmpty) return null;
|
|
return RidPairing(
|
|
serial: serial,
|
|
droneId: (j['droneId'] ?? '').toString(),
|
|
droneName: (j['droneName'] ?? '').toString(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Remote ID receiver: drives the native scanners, decodes frames and keeps
|
|
/// the list of nearby aircraft plus the paired one.
|
|
///
|
|
/// Other operators' broadcasts are public but are still personal data, so they
|
|
/// only ever live in memory on this phone; nothing but the paired aircraft is
|
|
/// handed to the uplink or the flight session.
|
|
class RidService extends ChangeNotifier {
|
|
RidService() {
|
|
_events.receiveBroadcastStream().listen(_onEvent, onError: (Object _) {});
|
|
_pruneTimer = Timer.periodic(const Duration(seconds: 5), (_) => _prune());
|
|
}
|
|
|
|
static const MethodChannel _methods = MethodChannel('pilotvault/rid');
|
|
static const EventChannel _events = EventChannel('pilotvault/rid/events');
|
|
|
|
/// Aircraft not heard for this long leave the nearby list.
|
|
static const Duration staleAfter = Duration(minutes: 2);
|
|
|
|
final RidRegistry _registry = RidRegistry();
|
|
final Map<String, TransportStatus> transports = <String, TransportStatus>{};
|
|
Map<String, bool> capabilities = <String, bool>{};
|
|
bool running = false;
|
|
int framesReceived = 0;
|
|
Timer? _pruneTimer;
|
|
DateTime _lastNotify = DateTime.fromMillisecondsSinceEpoch(0);
|
|
bool _notifyQueued = false;
|
|
|
|
RidPairing? _pairing;
|
|
RidPairing? get pairing => _pairing;
|
|
|
|
final StreamController<RidAircraft> _pairedUpdates = StreamController<RidAircraft>.broadcast();
|
|
|
|
/// Every frame that updated the paired aircraft's position.
|
|
Stream<RidAircraft> get pairedLocations => _pairedUpdates.stream;
|
|
|
|
RidAircraft? get paired {
|
|
final String? s = _pairing?.serial;
|
|
return s == null ? null : _registry.bySerial(s);
|
|
}
|
|
|
|
List<RidAircraft> get aircraft {
|
|
final List<RidAircraft> list = _registry.aircraft.toList()
|
|
..sort((RidAircraft a, RidAircraft b) => b.lastSeen.compareTo(a.lastSeen));
|
|
return list;
|
|
}
|
|
|
|
Future<void> init() async {
|
|
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
|
final String? raw = prefs.getString('rid_pairing');
|
|
if (raw != null) {
|
|
try {
|
|
_pairing = RidPairing.fromJson(jsonDecode(raw));
|
|
} catch (_) {}
|
|
}
|
|
try {
|
|
capabilities = ((await _methods.invokeMethod<Map<Object?, Object?>>('capabilities')) ?? <Object?, Object?>{})
|
|
.map((Object? k, Object? v) => MapEntry<String, bool>(k.toString(), v == true));
|
|
running = await _methods.invokeMethod<bool>('isRunning') ?? false;
|
|
} on MissingPluginException {
|
|
// Widget tests and non-Android hosts have no native side.
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Requests the runtime grants the scanners need. Returns the ones refused.
|
|
Future<List<String>> requestPermissions() async {
|
|
final Map<Permission, PermissionStatus> result = await <Permission>[
|
|
Permission.locationWhenInUse,
|
|
Permission.bluetoothScan,
|
|
Permission.nearbyWifiDevices,
|
|
Permission.notification,
|
|
].request();
|
|
return result.entries
|
|
.where((MapEntry<Permission, PermissionStatus> e) =>
|
|
e.value.isDenied || e.value.isPermanentlyDenied)
|
|
.map((MapEntry<Permission, PermissionStatus> e) => _permissionLabel(e.key))
|
|
.toList();
|
|
}
|
|
|
|
Future<void> start() async {
|
|
await requestPermissions();
|
|
await _methods.invokeMethod<bool>('start');
|
|
running = true;
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> stop() async {
|
|
await _methods.invokeMethod<bool>('stop');
|
|
running = false;
|
|
transports.clear();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<bool> isIgnoringBatteryOptimizations() async =>
|
|
await _methods.invokeMethod<bool>('isIgnoringBatteryOptimizations') ?? false;
|
|
|
|
Future<void> requestIgnoreBatteryOptimizations() => _methods.invokeMethod<void>('requestIgnoreBatteryOptimizations');
|
|
|
|
/// Brings DJI Fly to the front. False when it is not installed.
|
|
Future<bool> openDjiFly() async => await _methods.invokeMethod<bool>('openDjiFly') ?? false;
|
|
|
|
Future<void> pair(RidPairing p) async {
|
|
_pairing = p;
|
|
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString('rid_pairing', jsonEncode(p.toJson()));
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> unpair() async {
|
|
_pairing = null;
|
|
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
|
await prefs.remove('rid_pairing');
|
|
notifyListeners();
|
|
}
|
|
|
|
void _onEvent(dynamic event) {
|
|
if (event is! Map) return;
|
|
switch (event['kind']) {
|
|
case 'status':
|
|
transports[event['transport'].toString()] =
|
|
TransportStatus(event['state'].toString(), (event['detail'] ?? '').toString());
|
|
_notifySoon();
|
|
case 'frame':
|
|
final Object? payload = event['payload'];
|
|
if (payload is! Uint8List) return;
|
|
framesReceived++;
|
|
final List<OdidMessage> messages = decodeOdid(payload);
|
|
if (messages.isEmpty) return;
|
|
final DateTime at = DateTime.fromMillisecondsSinceEpoch((event['ts'] as num?)?.toInt() ?? 0);
|
|
final RidAircraft a = _registry.ingest(
|
|
transport: event['transport'].toString(),
|
|
source: event['source'].toString(),
|
|
rssi: (event['rssi'] as num?)?.toInt() ?? 0,
|
|
at: at,
|
|
messages: messages,
|
|
);
|
|
if (a.serial != null && a.serial == _pairing?.serial && messages.any((OdidMessage m) => m is OdidLocation)) {
|
|
_pairedUpdates.add(a);
|
|
}
|
|
_notifySoon();
|
|
}
|
|
}
|
|
|
|
/// Several transports can deliver tens of frames a second; the UI only needs
|
|
/// a few repaints.
|
|
void _notifySoon() {
|
|
if (_notifyQueued) return;
|
|
final Duration since = DateTime.now().difference(_lastNotify);
|
|
const Duration interval = Duration(milliseconds: 250);
|
|
if (since >= interval) {
|
|
_lastNotify = DateTime.now();
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
_notifyQueued = true;
|
|
Timer(interval - since, () {
|
|
_notifyQueued = false;
|
|
_lastNotify = DateTime.now();
|
|
notifyListeners();
|
|
});
|
|
}
|
|
|
|
void _prune() {
|
|
_registry.prune(DateTime.now(), staleAfter, keep: _pairing?.serial);
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Test hook: feed a frame as if it came from the native side.
|
|
@visibleForTesting
|
|
void debugIngest(Map<String, Object> event) => _onEvent(event);
|
|
|
|
@override
|
|
void dispose() {
|
|
_pruneTimer?.cancel();
|
|
_pairedUpdates.close();
|
|
super.dispose();
|
|
}
|
|
|
|
static String _permissionLabel(Permission p) {
|
|
if (p == Permission.bluetoothScan) return 'Nearby devices (Bluetooth)';
|
|
if (p == Permission.nearbyWifiDevices) return 'Nearby Wi-Fi devices';
|
|
if (p == Permission.notification) return 'Notifications';
|
|
return 'Location';
|
|
}
|
|
}
|