Files
PilotVault/Fly Agent App/lib/rid/odid_decoder.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

285 lines
9.0 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
];