Files
PilotVault/Fly Agent App/test/rid_test.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

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