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());
+}