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 decodeOdid(Uint8List payload) { if (payload.isEmpty) return const []; final int type = payload[0] >> 4; if (type != 0xF) { final OdidMessage? m = _decodeOne(payload, 0); return m == null ? const [] : [m]; } // Pack: [header][message size][count][count × message size] if (payload.length < 3) return const []; final int size = payload[1]; final int count = payload[2]; if (size != odidMessageSize) return const []; final List out = []; 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 _horizontalAccuracy = [ null, 18520, 7408, 3704, 1852, 926, 555.6, 185.2, 92.6, 30, 10, 3, 1, null, null, null, ]; const List _verticalAccuracy = [ null, 150, 45, 25, 10, 3, 1, null, null, null, null, null, null, null, null, null, ];