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>
285 lines
9.0 KiB
Dart
285 lines
9.0 KiB
Dart
import 'dart:typed_data';
|
||
|
||
/// Decoder for OpenDroneID broadcast messages (ASTM F3411-22a, which
|
||
/// ASD-STAN EN 4709-002 adopts for the EU).
|
||
///
|
||
/// Every message is 25 bytes: a header byte (message type in the high nibble,
|
||
/// protocol version in the low nibble) and 24 bytes of body. The Kotlin
|
||
/// scanners strip the transport framing, so [decodeOdid] always receives
|
||
/// either one message or a message pack (type 0xF) carrying several.
|
||
///
|
||
/// Fields an aircraft reports as "unknown" decode to null rather than to the
|
||
/// sentinel value, so callers never plot a fix at 0°/0° or a height of -1000 m.
|
||
|
||
const int odidMessageSize = 25;
|
||
|
||
enum UaStatus { undeclared, ground, airborne, emergency, remoteIdFailure }
|
||
|
||
enum HeightReference { takeoff, ground }
|
||
|
||
sealed class OdidMessage {
|
||
const OdidMessage();
|
||
}
|
||
|
||
class OdidBasicId extends OdidMessage {
|
||
const OdidBasicId({required this.idType, required this.uaType, required this.uasId});
|
||
|
||
/// 1 serial (ANSI/CTA-2063-A), 2 CAA registration, 3 UTM UUID, 4 session id.
|
||
final int idType;
|
||
|
||
/// 0 none, 1 aeroplane, 2 multirotor, … (F3411 table 4).
|
||
final int uaType;
|
||
final String uasId;
|
||
|
||
bool get isSerial => idType == 1;
|
||
}
|
||
|
||
class OdidLocation extends OdidMessage {
|
||
const OdidLocation({
|
||
required this.status,
|
||
required this.heightReference,
|
||
this.track,
|
||
this.speedHorizontal,
|
||
this.speedVertical,
|
||
this.latitude,
|
||
this.longitude,
|
||
this.altitudePressure,
|
||
this.altitudeGeodetic,
|
||
this.height,
|
||
this.horizontalAccuracy,
|
||
this.verticalAccuracy,
|
||
this.secondsIntoHour,
|
||
});
|
||
|
||
final UaStatus status;
|
||
final HeightReference heightReference;
|
||
|
||
/// Direction of travel, degrees clockwise from true north.
|
||
final double? track;
|
||
|
||
/// Metres per second.
|
||
final double? speedHorizontal;
|
||
|
||
/// Metres per second, up positive.
|
||
final double? speedVertical;
|
||
final double? latitude;
|
||
final double? longitude;
|
||
|
||
/// Metres, WGS-84 / barometric.
|
||
final double? altitudePressure;
|
||
final double? altitudeGeodetic;
|
||
|
||
/// Metres above [heightReference].
|
||
final double? height;
|
||
|
||
/// Upper bound in metres, from the accuracy category.
|
||
final double? horizontalAccuracy;
|
||
final double? verticalAccuracy;
|
||
|
||
/// Tenths of a second resolution, counted from the top of the UTC hour.
|
||
final double? secondsIntoHour;
|
||
|
||
bool get hasFix => latitude != null && longitude != null;
|
||
}
|
||
|
||
class OdidSelfId extends OdidMessage {
|
||
const OdidSelfId({required this.descriptionType, required this.description});
|
||
final int descriptionType;
|
||
final String description;
|
||
}
|
||
|
||
class OdidSystem extends OdidMessage {
|
||
const OdidSystem({
|
||
required this.operatorLocationType,
|
||
required this.classificationType,
|
||
this.operatorLatitude,
|
||
this.operatorLongitude,
|
||
this.operatorAltitude,
|
||
required this.areaCount,
|
||
required this.areaRadius,
|
||
this.areaCeiling,
|
||
this.areaFloor,
|
||
this.euCategory,
|
||
this.euClass,
|
||
this.timestamp,
|
||
});
|
||
|
||
/// 0 take-off position, 1 live GNSS, 2 fixed.
|
||
final int operatorLocationType;
|
||
|
||
/// 0 undeclared, 1 EU.
|
||
final int classificationType;
|
||
final double? operatorLatitude;
|
||
final double? operatorLongitude;
|
||
final double? operatorAltitude;
|
||
final int areaCount;
|
||
final double areaRadius;
|
||
final double? areaCeiling;
|
||
final double? areaFloor;
|
||
|
||
/// "open" / "specific" / "certified" when the classification is EU.
|
||
final String? euCategory;
|
||
|
||
/// "C0".."C6" when the classification is EU.
|
||
final String? euClass;
|
||
final DateTime? timestamp;
|
||
}
|
||
|
||
class OdidOperatorId extends OdidMessage {
|
||
const OdidOperatorId({required this.idType, required this.operatorId});
|
||
final int idType;
|
||
final String operatorId;
|
||
}
|
||
|
||
/// Authentication pages are acknowledged but not verified.
|
||
class OdidAuth extends OdidMessage {
|
||
const OdidAuth();
|
||
}
|
||
|
||
/// Decodes one message or a message pack. Malformed input yields what could be
|
||
/// read (possibly nothing) rather than throwing: a damaged broadcast must never
|
||
/// stop the receiver.
|
||
List<OdidMessage> decodeOdid(Uint8List payload) {
|
||
if (payload.isEmpty) return const <OdidMessage>[];
|
||
final int type = payload[0] >> 4;
|
||
if (type != 0xF) {
|
||
final OdidMessage? m = _decodeOne(payload, 0);
|
||
return m == null ? const <OdidMessage>[] : <OdidMessage>[m];
|
||
}
|
||
// Pack: [header][message size][count][count × message size]
|
||
if (payload.length < 3) return const <OdidMessage>[];
|
||
final int size = payload[1];
|
||
final int count = payload[2];
|
||
if (size != odidMessageSize) return const <OdidMessage>[];
|
||
final List<OdidMessage> out = <OdidMessage>[];
|
||
for (int i = 0; i < count; i++) {
|
||
final int offset = 3 + i * size;
|
||
if (offset + size > payload.length) break;
|
||
final OdidMessage? m = _decodeOne(payload, offset);
|
||
if (m != null) out.add(m);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
OdidMessage? _decodeOne(Uint8List b, int o) {
|
||
if (o + odidMessageSize > b.length) return null;
|
||
final ByteData d = ByteData.sublistView(b, o, o + odidMessageSize);
|
||
switch (b[o] >> 4) {
|
||
case 0:
|
||
return OdidBasicId(
|
||
idType: b[o + 1] >> 4,
|
||
uaType: b[o + 1] & 0x0F,
|
||
uasId: _ascii(b, o + 2, 20),
|
||
);
|
||
case 1:
|
||
return _location(d);
|
||
case 2:
|
||
return const OdidAuth();
|
||
case 3:
|
||
return OdidSelfId(descriptionType: b[o + 1], description: _ascii(b, o + 2, 23));
|
||
case 4:
|
||
return _system(d);
|
||
case 5:
|
||
return OdidOperatorId(idType: b[o + 1], operatorId: _ascii(b, o + 2, 20));
|
||
}
|
||
return null;
|
||
}
|
||
|
||
OdidLocation _location(ByteData d) {
|
||
final int flags = d.getUint8(1);
|
||
final int statusRaw = flags >> 4;
|
||
final bool eastSegment = flags & 0x02 != 0;
|
||
final bool speedMultiplied = flags & 0x01 != 0;
|
||
|
||
final int dirRaw = d.getUint8(2) + (eastSegment ? 180 : 0);
|
||
final int speedRaw = d.getUint8(3);
|
||
final int vSpeedRaw = d.getInt8(4);
|
||
final int tsRaw = d.getUint16(21, Endian.little);
|
||
final ({double? latitude, double? longitude}) fix = _latLon(d, 5, 9);
|
||
|
||
return OdidLocation(
|
||
status: statusRaw < UaStatus.values.length ? UaStatus.values[statusRaw] : UaStatus.undeclared,
|
||
heightReference: flags & 0x04 != 0 ? HeightReference.ground : HeightReference.takeoff,
|
||
track: dirRaw > 360 ? null : dirRaw.toDouble(),
|
||
speedHorizontal: speedRaw == 255
|
||
? null
|
||
: speedMultiplied
|
||
? speedRaw * 0.75 + 255 * 0.25
|
||
: speedRaw * 0.25,
|
||
speedVertical: vSpeedRaw == 63 ? null : vSpeedRaw * 0.5,
|
||
latitude: fix.latitude,
|
||
longitude: fix.longitude,
|
||
altitudePressure: _altitude(d, 13),
|
||
altitudeGeodetic: _altitude(d, 15),
|
||
height: _altitude(d, 17),
|
||
verticalAccuracy: _verticalAccuracy[d.getUint8(19) >> 4],
|
||
horizontalAccuracy: _horizontalAccuracy[d.getUint8(19) & 0x0F],
|
||
secondsIntoHour: tsRaw == 0xFFFF || tsRaw > 36000 ? null : tsRaw / 10.0,
|
||
);
|
||
}
|
||
|
||
OdidSystem _system(ByteData d) {
|
||
final int flags = d.getUint8(1);
|
||
final int classification = (flags >> 2) & 0x07;
|
||
final int catClass = d.getUint8(17);
|
||
final ({double? latitude, double? longitude}) op = _latLon(d, 2, 6);
|
||
final int ts = d.getUint32(20, Endian.little);
|
||
String? category;
|
||
String? cls;
|
||
if (classification == 1) {
|
||
category = switch (catClass >> 4) { 1 => 'open', 2 => 'specific', 3 => 'certified', _ => null };
|
||
final int c = catClass & 0x0F;
|
||
cls = c >= 1 && c <= 7 ? 'C${c - 1}' : null;
|
||
}
|
||
return OdidSystem(
|
||
operatorLocationType: flags & 0x03,
|
||
classificationType: classification,
|
||
operatorLatitude: op.latitude,
|
||
operatorLongitude: op.longitude,
|
||
areaCount: d.getUint16(10, Endian.little),
|
||
areaRadius: d.getUint8(12) * 10.0,
|
||
areaCeiling: _altitude(d, 13),
|
||
areaFloor: _altitude(d, 15),
|
||
euCategory: category,
|
||
euClass: cls,
|
||
operatorAltitude: _altitude(d, 18),
|
||
// Seconds since 2019-01-01T00:00:00Z; 0 = unknown.
|
||
timestamp: ts == 0 ? null : DateTime.utc(2019).add(Duration(seconds: ts)),
|
||
);
|
||
}
|
||
|
||
({double? latitude, double? longitude}) _latLon(ByteData d, int latAt, int lonAt) {
|
||
final int latRaw = d.getInt32(latAt, Endian.little);
|
||
final int lonRaw = d.getInt32(lonAt, Endian.little);
|
||
final double lat = latRaw * 1e-7;
|
||
final double lon = lonRaw * 1e-7;
|
||
if ((latRaw == 0 && lonRaw == 0) || lat.abs() > 90 || lon.abs() > 180) {
|
||
return (latitude: null, longitude: null);
|
||
}
|
||
return (latitude: lat, longitude: lon);
|
||
}
|
||
|
||
/// Altitudes are uint16 in 0.5 m steps offset by -1000 m; 0 (= -1000 m) is unknown.
|
||
double? _altitude(ByteData d, int at) {
|
||
final int raw = d.getUint16(at, Endian.little);
|
||
return raw == 0 ? null : raw * 0.5 - 1000;
|
||
}
|
||
|
||
String _ascii(Uint8List b, int start, int length) {
|
||
final StringBuffer s = StringBuffer();
|
||
for (int i = start; i < start + length && i < b.length; i++) {
|
||
final int c = b[i];
|
||
if (c == 0) break;
|
||
if (c >= 0x20 && c < 0x7F) s.writeCharCode(c);
|
||
}
|
||
return s.toString().trim();
|
||
}
|
||
|
||
// Accuracy categories → upper bound in metres (F3411 tables 9 and 10).
|
||
const List<double?> _horizontalAccuracy = <double?>[
|
||
null, 18520, 7408, 3704, 1852, 926, 555.6, 185.2, 92.6, 30, 10, 3, 1, null, null, null,
|
||
];
|
||
const List<double?> _verticalAccuracy = <double?>[
|
||
null, 150, 45, 25, 10, 3, 1, null, null, null, null, null, null, null, null, null,
|
||
];
|