Files
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

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';
}
}