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>
137 lines
6.0 KiB
Dart
137 lines
6.0 KiB
Dart
import 'dart:typed_data';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:pilotvault_fly_agent/rid/odid_decoder.dart';
|
|
import 'package:pilotvault_fly_agent/rid/rid_aircraft.dart';
|
|
import 'package:pilotvault_fly_agent/uplink/uplink.dart';
|
|
|
|
import 'odid_fixtures.dart';
|
|
|
|
void main() {
|
|
group('decodeOdid', () {
|
|
test('Basic ID: serial and UA type', () {
|
|
final List<OdidMessage> m = decodeOdid(basicId('1581F5FKD229400B0123'));
|
|
expect(m.single, isA<OdidBasicId>());
|
|
final OdidBasicId b = m.single as OdidBasicId;
|
|
expect(b.uasId, '1581F5FKD229400B0123');
|
|
expect(b.isSerial, isTrue);
|
|
expect(b.uaType, 2);
|
|
});
|
|
|
|
test('Location: position, height, speed, direction past 180°', () {
|
|
final OdidLocation l = decodeOdid(location()).single as OdidLocation;
|
|
expect(l.status, UaStatus.airborne);
|
|
expect(l.heightReference, HeightReference.takeoff);
|
|
expect(l.latitude, closeTo(55.6761, 1e-7));
|
|
expect(l.longitude, closeTo(12.5683, 1e-7));
|
|
expect(l.height, 42.5);
|
|
expect(l.altitudeGeodetic, 80);
|
|
expect(l.altitudePressure, isNull);
|
|
expect(l.speedHorizontal, 5.25);
|
|
expect(l.speedVertical, 1.5);
|
|
expect(l.track, 270);
|
|
expect(l.horizontalAccuracy, 10);
|
|
expect(l.verticalAccuracy, 10);
|
|
expect(l.secondsIntoHour, 1234.5);
|
|
});
|
|
|
|
test('Location: high speeds use the 0.75 multiplier', () {
|
|
final OdidLocation l = decodeOdid(location(speed: 100)).single as OdidLocation;
|
|
expect(l.speedHorizontal, closeTo(100, 0.75));
|
|
});
|
|
|
|
test('Location: unknowns decode to null, not sentinels', () {
|
|
final OdidLocation l = decodeOdid(location(lat: 0, lon: 0, height: null, speed: null)).single as OdidLocation;
|
|
expect(l.hasFix, isFalse);
|
|
expect(l.height, isNull);
|
|
expect(l.speedHorizontal, isNull);
|
|
});
|
|
|
|
test('System: operator position and EU category/class', () {
|
|
final OdidSystem s = decodeOdid(system()).single as OdidSystem;
|
|
expect(s.classificationType, 1);
|
|
expect(s.operatorLatitude, closeTo(55.6760, 1e-7));
|
|
expect(s.euCategory, 'open');
|
|
expect(s.euClass, 'C1');
|
|
expect(s.timestamp, DateTime.utc(2019, 1, 1, 1));
|
|
});
|
|
|
|
test('Message pack carries several messages', () {
|
|
final List<OdidMessage> m = decodeOdid(pack(<Uint8List>[basicId('SN1'), location(), operatorId('DNK-OP-123')]));
|
|
expect(m.map((OdidMessage e) => e.runtimeType).toList(), <Type>[OdidBasicId, OdidLocation, OdidOperatorId]);
|
|
expect((m[2] as OdidOperatorId).operatorId, 'DNK-OP-123');
|
|
});
|
|
|
|
test('Truncated input never throws', () {
|
|
expect(decodeOdid(Uint8List(0)), isEmpty);
|
|
expect(decodeOdid(Uint8List.fromList(<int>[0x12, 0x20, 0x01])), isEmpty);
|
|
final Uint8List p = pack(<Uint8List>[basicId('SN1'), location()]);
|
|
expect(decodeOdid(Uint8List.sublistView(p, 0, p.length - 5)).length, 1);
|
|
});
|
|
});
|
|
|
|
group('RidRegistry', () {
|
|
final DateTime t0 = DateTime(2026, 10, 22, 12);
|
|
|
|
test('BLE legacy: location before Basic ID merges under the serial', () {
|
|
final RidRegistry r = RidRegistry();
|
|
r.ingest(transport: 'ble', source: 'AA', rssi: -60, at: t0, messages: decodeOdid(location()));
|
|
expect(r.aircraft.single.serial, isNull);
|
|
r.ingest(transport: 'ble', source: 'AA', rssi: -61, at: t0, messages: decodeOdid(basicId('SN1')));
|
|
expect(r.aircraft.length, 1);
|
|
final RidAircraft a = r.bySerial('SN1')!;
|
|
expect(a.hasFix, isTrue);
|
|
expect(a.height, 42.5);
|
|
// Later location-only frames from the same radio land on the same aircraft.
|
|
r.ingest(transport: 'ble', source: 'AA', rssi: -62, at: t0.add(const Duration(seconds: 1)),
|
|
messages: decodeOdid(location(height: 50)));
|
|
expect(r.aircraft.length, 1);
|
|
expect(r.bySerial('SN1')!.height, 50);
|
|
});
|
|
|
|
test('Same aircraft on two transports is one entry', () {
|
|
final RidRegistry r = RidRegistry();
|
|
r.ingest(transport: 'ble', source: 'AA', rssi: -60, at: t0, messages: decodeOdid(pack(<Uint8List>[basicId('SN1'), location()])));
|
|
r.ingest(transport: 'beacon', source: 'BB', rssi: -70, at: t0, messages: decodeOdid(pack(<Uint8List>[basicId('SN1'), location()])));
|
|
expect(r.aircraft.length, 1);
|
|
expect(r.bySerial('SN1')!.transports, <String>{'ble', 'beacon'});
|
|
});
|
|
|
|
test('prune keeps the paired aircraft', () {
|
|
final RidRegistry r = RidRegistry();
|
|
r.ingest(transport: 'ble', source: 'AA', rssi: -60, at: t0, messages: decodeOdid(basicId('SN1')));
|
|
r.ingest(transport: 'ble', source: 'BB', rssi: -60, at: t0, messages: decodeOdid(basicId('SN2')));
|
|
r.prune(t0.add(const Duration(minutes: 5)), const Duration(minutes: 2), keep: 'SN1');
|
|
expect(r.aircraft.map((RidAircraft a) => a.serial), <String>['SN1']);
|
|
});
|
|
});
|
|
|
|
test('ridTelemetryEvent uses the hub\'s keys and north/east/down velocity', () {
|
|
final RidRegistry r = RidRegistry();
|
|
final RidAircraft a = r.ingest(
|
|
transport: 'ble',
|
|
source: 'AA',
|
|
rssi: -60,
|
|
at: DateTime(2026),
|
|
messages: decodeOdid(pack(<Uint8List>[basicId('SN1'), location(track: 90, speed: 4, vSpeed: 2), system()])),
|
|
);
|
|
final Map<String, dynamic> e = ridTelemetryEvent(a, phoneLat: 1, phoneLon: 2, now: DateTime(2026));
|
|
expect(e['type'], 'telemetry');
|
|
expect(e['latitude'], closeTo(55.6761, 1e-7));
|
|
expect(e['altitude'], 42.5);
|
|
expect(e['isFlying'], isTrue);
|
|
expect(e['velocityX'] as double, closeTo(0, 1e-9)); // heading east: no north component
|
|
expect(e['velocityY'] as double, closeTo(4, 1e-9));
|
|
expect(e['velocityZ'], -2.0);
|
|
expect(e['pilotLatitude'], closeTo(55.6760, 1e-7));
|
|
expect(e['uaClass'], 'C1');
|
|
expect(e['phoneLatitude'], 1);
|
|
expect(e.containsKey('batteryPercent'), isFalse);
|
|
});
|
|
|
|
test('deviceSocketUri', () {
|
|
expect(deviceSocketUri('http://10.2.1.101:8080', 'agent-SN1').toString(), 'ws://10.2.1.101:8080/ws/device?id=agent-SN1');
|
|
expect(deviceSocketUri('https://pv.example.com', 'agent-SN1').toString(), 'wss://pv.example.com/ws/device?id=agent-SN1');
|
|
});
|
|
}
|