diff --git a/Cargo.toml b/Cargo.toml index 7bebc44..8194264 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,8 +11,9 @@ edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [features] -default = ["lx", "xcsoar"] +default = ["lx", "tdb", "xcsoar"] lx = ["minidom", "quick-xml"] +tdb = [] xcsoar = ["encoding_rs"] [dependencies] diff --git a/docs/tdb-file-format.md b/docs/tdb-file-format.md new file mode 100644 index 0000000..9125638 --- /dev/null +++ b/docs/tdb-file-format.md @@ -0,0 +1,124 @@ +# TDB File Format + +Binary format used by Air Avionics devices (AT-1, ATD-57, ATD-80, ATD-11) for +FlarmNet database lookups. Available for download at + as `flarmnet.tdb`. + +This format was reverse-engineered by cross-referencing a `flarmnet.tdb` file +(14867 records) against the equivalent `data.fln` (XCSoar format) downloaded at +the same time. All flarm IDs, frequencies, and string fields were verified to +match (accounting for field width truncation in the XCSoar format). + +## Byte Order + +All integers are unsigned, little-endian. + +## File Layout + +``` +Offset Size Description +────── ──── ────────────────────────────────── +0 4 bytes Magic number: 0x08 0xd5 0x19 0x87 +4 4 bytes Version (u32) +8 4 bytes Record count N (u32) +12 N × 4 bytes Flarm ID index +12 + N×4 8 bytes Padding (zero bytes) +20 + N×4 N × 96 bytes Record data +``` + +### Magic Number + +The first 4 bytes (`0x08d51987`) are a static format identifier. They do not +change when the file content changes (verified by modifying a record and +re-downloading). + +### Version + +A `u32` that increments on each file regeneration. + +### Flarm ID Index + +A sorted array of `u32` flarm IDs. Enables binary search to find a record's +position without scanning the record data. Index entry `i` corresponds to +record `i` in the data section. + +### Padding + +8 zero bytes separate the index from the record data. + +## Record Layout (96 bytes) + +``` +Offset Size Type Field +────── ────── ────────────────── ────────────────── +0 4 u32 flarm_id +4 4 u32 frequency +8 8 reserved (always zero) +16 16 null-terminated call_sign +32 16 null-terminated pilot_name ⚠ see note below +48 16 null-terminated airfield +64 16 null-terminated plane_type +80 16 null-terminated registration +``` + +### ⚠ Uncertainty: `pilot_name` Field Offset + +The `pilot_name` field is currently mapped to offset 32. However, it is unclear +whether this is correct. The field might actually be at offset 8 (overlapping +what we currently treat as reserved + call_sign). In all observed FlarmNet files +the pilot_name field is empty (zeroed) for privacy reasons, which makes it +impossible to determine the correct offset from the data alone. The current +assignment at offset 32 was chosen based on the uniform 16-byte field size used +by all other string fields, but **this still needs to be verified**, ideally +with a file that contains actual pilot name data. + +### Field Details + +**flarm_id** — 24-bit FLARM radio ID stored in the low 3 bytes of a `u32` +(max value `0xFFFFFF`). Matches the corresponding entry in the index section. + +**frequency** — Radio frequency in kHz as a `u32`. Divide by 1000 to get MHz. +Example: `123500` → `123.500 MHz`. Zero means no frequency set. + +**reserved** — 8 bytes, always zero in all observed files. Purpose unknown. + +**call_sign** — Up to 15 characters + null terminator, zero-padded. Competition +sign or identifier. Note: the XCSoar `.fln` format truncates this to 3 +characters, so the TDB format preserves longer call signs that XCSoar discards. + +**pilot_name** — Up to 15 characters + null terminator, zero-padded. Always +empty (zeroed) in FlarmNet-distributed files for privacy reasons. + +**airfield** — Up to 15 characters + null terminator, zero-padded. In practice, +FlarmNet stores the registration here instead of an actual airfield name, again +for privacy reasons. + +**plane_type** — Up to 15 characters + null terminator, zero-padded. Aircraft +type designation (e.g. "ASK 16", "Discus 2C FES", "HPH 304C Wasp"). + +**registration** — Up to 15 characters + null terminator, zero-padded. Nearly +always identical to the airfield field in FlarmNet files (14865 of 14867 +records matched; the 2 mismatches had U+2013 EN DASH in airfield replaced with +`?` in registration, suggesting a lossy encoding conversion). + +### String Encoding + +Strings are UTF-8, null-terminated and zero-padded to fill the 16-byte field. +Multi-byte UTF-8 characters have been observed. When encoding, strings longer +than 15 bytes must be truncated at a valid UTF-8 character boundary to avoid +producing invalid output. + +## Field Width Comparison + +| Field | TDB | XCSoar `.fln` | LX `.fln` | +|--------------|---------------|--------------------|---------------| +| flarm_id | u32 (4 bytes) | 6 ASCII hex chars | XML attribute | +| call_sign | 16 bytes | 3 bytes | XML attribute | +| pilot_name | 16 bytes | 21 bytes | XML attribute | +| airfield | 16 bytes | 21 bytes (hex-enc) | XML attribute | +| plane_type | 16 bytes | 21 bytes (hex-enc) | XML attribute | +| registration | 16 bytes | 7 bytes | XML attribute | +| frequency | u32 kHz | 7 ASCII chars | XML attribute | + +The TDB format has wider call_sign (16 vs 3) and registration (16 vs 7) fields +compared to XCSoar, preserving data that the XCSoar format truncates. diff --git a/examples/decode-tdb-file.rs b/examples/decode-tdb-file.rs new file mode 100644 index 0000000..4d768da --- /dev/null +++ b/examples/decode-tdb-file.rs @@ -0,0 +1,47 @@ +use clap::Parser; +use std::path::PathBuf; + +#[derive(Debug, Parser)] +struct Options { + /// Path to the TDB file + input: PathBuf, +} + +fn main() -> anyhow::Result<()> { + let options = Options::parse(); + + let data = std::fs::read(&options.input)?; + let decoded = flarmnet::tdb::decode_file(&data)?; + + println!("Version: {}", decoded.version); + println!("Records: {}", decoded.records.len()); + + let ok_count = decoded.records.iter().filter(|r| r.is_ok()).count(); + let err_count = decoded.records.iter().filter(|r| r.is_err()).count(); + println!(" OK: {}", ok_count); + println!(" Errors: {}", err_count); + + println!(); + println!("First 5 records:"); + for (i, result) in decoded.records.iter().take(5).enumerate() { + match result { + Ok(record) => { + println!( + " [{}] {} call_sign={:?} airfield={:?} plane_type={:?} reg={:?} freq={:?}", + i, + record.flarm_id, + record.call_sign, + record.airfield, + record.plane_type, + record.registration, + record.frequency + ); + } + Err(e) => { + println!(" [{}] ERROR: {}", i, e); + } + } + } + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 39ee1fc..1a75d24 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,7 @@ #[cfg(feature = "lx")] pub mod lx; +#[cfg(feature = "tdb")] +pub mod tdb; #[cfg(feature = "xcsoar")] pub mod xcsoar; diff --git a/src/tdb/consts.rs b/src/tdb/consts.rs new file mode 100644 index 0000000..d0dd28f --- /dev/null +++ b/src/tdb/consts.rs @@ -0,0 +1,14 @@ +pub const MAGIC: [u8; 4] = [0x08, 0xd5, 0x19, 0x87]; +pub const HEADER_SIZE: usize = 12; +pub const INDEX_ENTRY_SIZE: usize = 4; +pub const PADDING_SIZE: usize = 8; +pub const RECORD_SIZE: usize = 96; + +pub const FLARM_ID_OFFSET: usize = 0; +pub const FREQUENCY_OFFSET: usize = 4; +pub const CALL_SIGN_OFFSET: usize = 16; +pub const PILOT_NAME_OFFSET: usize = 32; +pub const AIRFIELD_OFFSET: usize = 48; +pub const PLANE_TYPE_OFFSET: usize = 64; +pub const REGISTRATION_OFFSET: usize = 80; +pub const STRING_FIELD_SIZE: usize = 16; diff --git a/src/tdb/decode.rs b/src/tdb/decode.rs new file mode 100644 index 0000000..27facae --- /dev/null +++ b/src/tdb/decode.rs @@ -0,0 +1,283 @@ +use super::consts::*; +use crate::Record; +use std::convert::TryInto; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum DecodeError { + #[error("unexpected end of file")] + UnexpectedEof, + #[error("invalid magic number: {0:02x?}")] + InvalidMagic([u8; 4]), + #[error("invalid FLARM id: {0}")] + InvalidFlarmId(u32), + #[error("invalid UTF-8 in {field} field at record offset {offset}")] + InvalidUtf8 { field: &'static str, offset: usize }, +} + +#[derive(Debug)] +pub struct DecodedFile { + pub version: u32, + pub records: Vec>, +} + +pub fn decode_file(data: &[u8]) -> Result { + if data.len() < HEADER_SIZE { + return Err(DecodeError::UnexpectedEof); + } + + let magic: [u8; 4] = data[0..4].try_into().unwrap(); + if magic != MAGIC { + return Err(DecodeError::InvalidMagic(magic)); + } + + let version = u32::from_le_bytes(data[4..8].try_into().unwrap()); + let record_count = u32::from_le_bytes(data[8..12].try_into().unwrap()) as usize; + + let expected_size = + HEADER_SIZE + record_count * INDEX_ENTRY_SIZE + PADDING_SIZE + record_count * RECORD_SIZE; + if data.len() < expected_size { + return Err(DecodeError::UnexpectedEof); + } + + let records_offset = HEADER_SIZE + record_count * INDEX_ENTRY_SIZE + PADDING_SIZE; + + let records = (0..record_count) + .map(|i| { + let offset = records_offset + i * RECORD_SIZE; + let record_data: &[u8; 96] = data[offset..offset + RECORD_SIZE].try_into().unwrap(); + decode_record(record_data) + }) + .collect(); + + Ok(DecodedFile { version, records }) +} + +fn decode_record(data: &[u8; 96]) -> Result { + let flarm_id = u32::from_le_bytes( + data[FLARM_ID_OFFSET..FLARM_ID_OFFSET + 4] + .try_into() + .unwrap(), + ); + if flarm_id > 0xFFFFFF { + return Err(DecodeError::InvalidFlarmId(flarm_id)); + } + let flarm_id = format!("{:06X}", flarm_id); + + let frequency = u32::from_le_bytes( + data[FREQUENCY_OFFSET..FREQUENCY_OFFSET + 4] + .try_into() + .unwrap(), + ); + let frequency = if frequency == 0 { + String::new() + } else { + format!("{}.{:03}", frequency / 1000, frequency % 1000) + }; + + let call_sign = decode_string(data, CALL_SIGN_OFFSET, "call_sign")?; + let pilot_name = decode_string(data, PILOT_NAME_OFFSET, "pilot_name")?; + let airfield = decode_string(data, AIRFIELD_OFFSET, "airfield")?; + let plane_type = decode_string(data, PLANE_TYPE_OFFSET, "plane_type")?; + let registration = decode_string(data, REGISTRATION_OFFSET, "registration")?; + + Ok(Record { + flarm_id, + pilot_name, + airfield, + plane_type, + registration, + call_sign, + frequency, + }) +} + +fn decode_string( + data: &[u8; 96], + offset: usize, + field: &'static str, +) -> Result { + let field_bytes = &data[offset..offset + STRING_FIELD_SIZE]; + + let end = field_bytes + .iter() + .position(|&b| b == 0) + .unwrap_or(STRING_FIELD_SIZE); + let content = &field_bytes[..end]; + + std::str::from_utf8(content) + .map(|s| s.to_string()) + .map_err(|_| DecodeError::InvalidUtf8 { field, offset }) +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_debug_snapshot; + + #[test] + fn decoding_fails_for_empty_file() { + assert_debug_snapshot!(decode_file(b"").unwrap_err(), @"UnexpectedEof"); + } + + #[test] + fn decoding_fails_for_truncated_header() { + assert_debug_snapshot!( + decode_file(&[0x08, 0xd5, 0x19]).unwrap_err(), + @"UnexpectedEof" + ); + } + + #[test] + fn decoding_fails_for_invalid_magic() { + let data = [ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]; + assert_debug_snapshot!( + decode_file(&data).unwrap_err(), + @r###" + InvalidMagic( + [ + 0, + 0, + 0, + 0, + ], + ) + "### + ); + } + + #[test] + fn decoding_fails_for_truncated_records() { + // valid header claiming 1 record, but no index/padding/record data + let mut data = vec![0x08, 0xd5, 0x19, 0x87]; // magic + data.extend_from_slice(&1u32.to_le_bytes()); // version + data.extend_from_slice(&1u32.to_le_bytes()); // record count = 1 + assert_debug_snapshot!(decode_file(&data).unwrap_err(), @"UnexpectedEof"); + } + + fn make_valid_file(records: &[[u8; RECORD_SIZE]]) -> Vec { + let n = records.len() as u32; + let mut data = Vec::new(); + + // header + data.extend_from_slice(&MAGIC); + data.extend_from_slice(&1u32.to_le_bytes()); // version = 1 + data.extend_from_slice(&n.to_le_bytes()); + + // index (dummy sorted flarm_ids) + for record in records { + let flarm_id = u32::from_le_bytes(record[0..4].try_into().unwrap()); + data.extend_from_slice(&flarm_id.to_le_bytes()); + } + + // padding + data.extend_from_slice(&[0u8; PADDING_SIZE]); + + // records + for record in records { + data.extend_from_slice(record); + } + + data + } + + fn make_record( + flarm_id: u32, + frequency: u32, + call_sign: &[u8], + airfield: &[u8], + plane_type: &[u8], + registration: &[u8], + ) -> [u8; RECORD_SIZE] { + let mut record = [0u8; RECORD_SIZE]; + record[FLARM_ID_OFFSET..FLARM_ID_OFFSET + 4].copy_from_slice(&flarm_id.to_le_bytes()); + record[FREQUENCY_OFFSET..FREQUENCY_OFFSET + 4].copy_from_slice(&frequency.to_le_bytes()); + + let fields: [(&[u8], usize); 4] = [ + (call_sign, CALL_SIGN_OFFSET), + (airfield, AIRFIELD_OFFSET), + (plane_type, PLANE_TYPE_OFFSET), + (registration, REGISTRATION_OFFSET), + ]; + for (value, offset) in &fields { + let len = value.len().min(STRING_FIELD_SIZE - 1); + record[*offset..*offset + len].copy_from_slice(&value[..len]); + } + + record + } + + #[test] + fn decoding_works_for_empty_database() { + let data = make_valid_file(&[]); + let result = decode_file(&data).unwrap(); + assert_eq!(result.version, 1); + assert_eq!(result.records.len(), 0); + } + + #[test] + fn decoding_works_for_single_record() { + let record = make_record(0x3EE3C7, 123500, b"SG", b"EDKA", b"LS6a", b"D-0816"); + let data = make_valid_file(&[record]); + let result = decode_file(&data).unwrap(); + assert_eq!(result.version, 1); + assert_eq!(result.records.len(), 1); + assert_debug_snapshot!(result.records[0].as_ref().unwrap(), @r###" + Record { + flarm_id: "3EE3C7", + pilot_name: "", + airfield: "EDKA", + plane_type: "LS6a", + registration: "D-0816", + call_sign: "SG", + frequency: "123.500", + } + "###); + } + + #[test] + fn decoding_works_with_zero_frequency() { + let record = make_record(0x000001, 0, b"", b"", b"Paraglider", b""); + let data = make_valid_file(&[record]); + let result = decode_file(&data).unwrap(); + let record = result.records[0].as_ref().unwrap(); + assert_eq!(record.frequency, ""); + } + + #[test] + fn decoding_reports_invalid_flarm_id() { + let mut record = [0u8; RECORD_SIZE]; + record[0..4].copy_from_slice(&0x01000000u32.to_le_bytes()); // > 0xFFFFFF + let data = make_valid_file(&[record]); + let result = decode_file(&data).unwrap(); + assert_debug_snapshot!( + result.records[0].as_ref().unwrap_err(), + @r###" + InvalidFlarmId( + 16777216, + ) + "### + ); + } + + #[test] + fn decoding_reports_invalid_utf8() { + let mut record = make_record(0x000001, 0, b"", b"", b"", b""); + // put invalid UTF-8 in the call_sign field + record[CALL_SIGN_OFFSET] = 0xFF; + record[CALL_SIGN_OFFSET + 1] = 0xFE; + let data = make_valid_file(&[record]); + let result = decode_file(&data).unwrap(); + assert_debug_snapshot!( + result.records[0].as_ref().unwrap_err(), + @r###" + InvalidUtf8 { + field: "call_sign", + offset: 16, + } + "### + ); + } +} diff --git a/src/tdb/encode.rs b/src/tdb/encode.rs new file mode 100644 index 0000000..8a4822d --- /dev/null +++ b/src/tdb/encode.rs @@ -0,0 +1,281 @@ +use super::consts::*; +use crate::{File, Record}; +use std::io::{Cursor, Write}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum EncodeError { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error("invalid FLARM id: {0}")] + InvalidFlarmId(String), + #[error("invalid frequency: {0}")] + InvalidFrequency(String), +} + +pub fn encode_file(file: &File) -> Result, EncodeError> { + let mut writer = Writer::new(Cursor::new(Vec::new())); + writer.write(file)?; + Ok(writer.into_inner().into_inner()) +} + +pub struct Writer { + writer: W, +} + +impl Writer { + pub fn new(inner: W) -> Self { + Self { writer: inner } + } + + pub fn write(&mut self, file: &File) -> Result<(), EncodeError> { + let mut entries: Vec<(u32, &Record)> = file + .records + .iter() + .map(|record| Ok((parse_flarm_id(&record.flarm_id)?, record))) + .collect::>()?; + + entries.sort_by_key(|(id, _)| *id); + + let count = entries.len() as u32; + + // header + self.writer.write_all(&MAGIC)?; + self.writer.write_all(&file.version.to_le_bytes())?; + self.writer.write_all(&count.to_le_bytes())?; + + // index + for (id, _) in &entries { + self.writer.write_all(&id.to_le_bytes())?; + } + + // padding + self.writer.write_all(&[0u8; PADDING_SIZE])?; + + // records + for (id, record) in &entries { + self.write_record(*id, record)?; + } + + Ok(()) + } + + fn write_record(&mut self, flarm_id: u32, record: &Record) -> Result<(), EncodeError> { + let frequency = parse_frequency(&record.frequency)?; + + let mut buf = [0u8; RECORD_SIZE]; + buf[FLARM_ID_OFFSET..FLARM_ID_OFFSET + 4].copy_from_slice(&flarm_id.to_le_bytes()); + buf[FREQUENCY_OFFSET..FREQUENCY_OFFSET + 4].copy_from_slice(&frequency.to_le_bytes()); + // reserved at offset 8..16 stays zero + write_string(&mut buf, CALL_SIGN_OFFSET, &record.call_sign); + write_string(&mut buf, PILOT_NAME_OFFSET, &record.pilot_name); + write_string(&mut buf, AIRFIELD_OFFSET, &record.airfield); + write_string(&mut buf, PLANE_TYPE_OFFSET, &record.plane_type); + write_string(&mut buf, REGISTRATION_OFFSET, &record.registration); + + self.writer.write_all(&buf)?; + Ok(()) + } + + pub fn into_inner(self) -> W { + self.writer + } +} + +fn write_string(buf: &mut [u8; RECORD_SIZE], offset: usize, value: &str) { + let max_content = STRING_FIELD_SIZE - 1; + let truncated = if value.len() > max_content { + &value[..floor_char_boundary(value, max_content)] + } else { + value + }; + buf[offset..offset + truncated.len()].copy_from_slice(truncated.as_bytes()); + // remaining bytes are already zero from initialization +} + +fn floor_char_boundary(s: &str, index: usize) -> usize { + let mut i = index; + while !s.is_char_boundary(i) { + i -= 1; + } + i +} + +fn parse_flarm_id(s: &str) -> Result { + let id = u32::from_str_radix(s, 16).map_err(|_| EncodeError::InvalidFlarmId(s.to_string()))?; + if id > 0xFFFFFF { + return Err(EncodeError::InvalidFlarmId(s.to_string())); + } + Ok(id) +} + +fn parse_frequency(s: &str) -> Result { + if s.is_empty() { + return Ok(0); + } + let mhz: f64 = s + .parse() + .map_err(|_| EncodeError::InvalidFrequency(s.to_string()))?; + Ok((mhz * 1000.0).round() as u32) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tdb::decode_file; + use insta::assert_debug_snapshot; + + fn make_file(records: Vec) -> File { + File { + version: 1, + records, + } + } + + fn make_record( + flarm_id: &str, + frequency: &str, + call_sign: &str, + pilot_name: &str, + airfield: &str, + plane_type: &str, + registration: &str, + ) -> Record { + Record { + flarm_id: flarm_id.to_string(), + frequency: frequency.to_string(), + call_sign: call_sign.to_string(), + pilot_name: pilot_name.to_string(), + airfield: airfield.to_string(), + plane_type: plane_type.to_string(), + registration: registration.to_string(), + } + } + + #[test] + fn encoding_round_trips() { + let file = make_file(vec![make_record( + "3EE3C7", "123.500", "SG", "John Doe", "EDKA", "LS6a", "D-0816", + )]); + let encoded = encode_file(&file).unwrap(); + let decoded = decode_file(&encoded).unwrap(); + assert_eq!(decoded.version, 1); + assert_eq!(decoded.records.len(), 1); + let record = decoded.records[0].as_ref().unwrap(); + assert_eq!(record.flarm_id, "3EE3C7"); + assert_eq!(record.frequency, "123.500"); + assert_eq!(record.call_sign, "SG"); + assert_eq!(record.pilot_name, "John Doe"); + assert_eq!(record.airfield, "EDKA"); + assert_eq!(record.plane_type, "LS6a"); + assert_eq!(record.registration, "D-0816"); + } + + #[test] + fn encoding_handles_empty_frequency() { + let file = make_file(vec![make_record( + "000001", + "", + "", + "", + "", + "Paraglider", + "", + )]); + let encoded = encode_file(&file).unwrap(); + let decoded = decode_file(&encoded).unwrap(); + let record = decoded.records[0].as_ref().unwrap(); + assert_eq!(record.frequency, ""); + } + + #[test] + fn encoding_sorts_records_by_flarm_id() { + let file = make_file(vec![ + make_record("00000F", "", "X27", "", "D-9527", "ASW 27", "D-9527"), + make_record("000001", "", "", "", "", "Paraglider", ""), + make_record("000000", "123.150", "", "", "D-2188", "ASK-13", "D-2188"), + ]); + let encoded = encode_file(&file).unwrap(); + let decoded = decode_file(&encoded).unwrap(); + let ids: Vec<&str> = decoded + .records + .iter() + .map(|r| r.as_ref().unwrap().flarm_id.as_str()) + .collect(); + assert_eq!(ids, vec!["000000", "000001", "00000F"]); + } + + #[test] + fn encoding_truncates_long_strings() { + let file = make_file(vec![make_record( + "000001", + "", + "0123456789ABCDEF", + "", + "", + "", + "", + )]); + let encoded = encode_file(&file).unwrap(); + let decoded = decode_file(&encoded).unwrap(); + let record = decoded.records[0].as_ref().unwrap(); + assert_eq!(record.call_sign, "0123456789ABCDE"); + } + + #[test] + fn encoding_truncates_at_char_boundary() { + // "Ä" is 2 bytes in UTF-8, so 14 ASCII + "Ä" = 16 bytes, exceeds 15 + let file = make_file(vec![make_record( + "000001", + "", + "01234567890123Ä", + "", + "", + "", + "", + )]); + let encoded = encode_file(&file).unwrap(); + let decoded = decode_file(&encoded).unwrap(); + let record = decoded.records[0].as_ref().unwrap(); + assert_eq!(record.call_sign, "01234567890123"); + } + + #[test] + fn encoding_fails_for_invalid_flarm_id() { + let file = make_file(vec![make_record("ZZZZZZ", "", "", "", "", "", "")]); + assert_debug_snapshot!( + encode_file(&file).unwrap_err(), + @r###" + InvalidFlarmId( + "ZZZZZZ", + ) + "### + ); + } + + #[test] + fn encoding_fails_for_flarm_id_too_large() { + let file = make_file(vec![make_record("1000000", "", "", "", "", "", "")]); + assert_debug_snapshot!( + encode_file(&file).unwrap_err(), + @r###" + InvalidFlarmId( + "1000000", + ) + "### + ); + } + + #[test] + fn encoding_fails_for_invalid_frequency() { + let file = make_file(vec![make_record("000001", "abc", "", "", "", "", "")]); + assert_debug_snapshot!( + encode_file(&file).unwrap_err(), + @r###" + InvalidFrequency( + "abc", + ) + "### + ); + } +} diff --git a/src/tdb/mod.rs b/src/tdb/mod.rs new file mode 100644 index 0000000..57a6ffe --- /dev/null +++ b/src/tdb/mod.rs @@ -0,0 +1,11 @@ +//! Decoder/Encoder for Air Avionics TDB file format. +//! +//! The [decode_file] function can be used to decode FlarmNet files in +//! Air Avionics TDB format. + +mod consts; +mod decode; +mod encode; + +pub use decode::*; +pub use encode::*; diff --git a/tests/fixtures/flarmnet.tdb b/tests/fixtures/flarmnet.tdb new file mode 100644 index 0000000..725f971 Binary files /dev/null and b/tests/fixtures/flarmnet.tdb differ diff --git a/tests/snapshots/tdb_decode_test__it_works.snap b/tests/snapshots/tdb_decode_test__it_works.snap new file mode 100644 index 0000000..f56b21c --- /dev/null +++ b/tests/snapshots/tdb_decode_test__it_works.snap @@ -0,0 +1,44 @@ +--- +source: tests/tdb_decode_test.rs +expression: decode_file(fixture) +--- +Ok( + DecodedFile { + version: 28592, + records: [ + Ok( + Record { + flarm_id: "000000", + pilot_name: "Müller", + airfield: "D-2188", + plane_type: "ASK-13", + registration: "D-2188", + call_sign: "", + frequency: "123.150", + }, + ), + Ok( + Record { + flarm_id: "000001", + pilot_name: "", + airfield: "000000", + plane_type: "Paraglider", + registration: "000000", + call_sign: "", + frequency: "", + }, + ), + Ok( + Record { + flarm_id: "00000F", + pilot_name: "", + airfield: "D-9527", + plane_type: "ASW 27", + registration: "D-9527", + call_sign: "X27", + frequency: "", + }, + ), + ], + }, +) diff --git a/tests/tdb_decode_test.rs b/tests/tdb_decode_test.rs new file mode 100644 index 0000000..0fe3002 --- /dev/null +++ b/tests/tdb_decode_test.rs @@ -0,0 +1,8 @@ +use flarmnet::tdb::decode_file; +use insta::assert_debug_snapshot; + +#[test] +fn it_works() { + let fixture = include_bytes!("fixtures/flarmnet.tdb"); + assert_debug_snapshot!(decode_file(fixture)); +} diff --git a/tests/tdb_encode_test.rs b/tests/tdb_encode_test.rs new file mode 100644 index 0000000..7be3337 --- /dev/null +++ b/tests/tdb_encode_test.rs @@ -0,0 +1,16 @@ +use flarmnet::tdb::{decode_file, encode_file}; + +#[test] +fn it_works() { + let fixture = include_bytes!("fixtures/flarmnet.tdb"); + let decoded = decode_file(fixture).unwrap(); + let file = flarmnet::File { + version: decoded.version, + records: decoded + .records + .into_iter() + .filter_map(|it| it.ok()) + .collect(), + }; + assert_eq!(encode_file(&file).unwrap(), fixture.as_ref()); +}