Files
trx-rs/src/decoders/trx-vdes/src/link_layer.rs
T
sjg c6cd661676
CI / lint (pull_request) Failing after 4m14s
CI / test (pull_request) Successful in 15m22s
CI / reuse (pull_request) Successful in 7s
[style](trx-rs): fix clippy warnings for -D warnings CI
The CI lint job runs clippy with -D warnings, which surfaced a set of
existing warnings across decoders, the client, and the soapysdr backend.
Resolve them so the workspace is clean under the enforced lint level:

- collapsible_match / identity_op / needless_range_loop / same_item_push
  in trx-rds, trx-wspr, trx-vdes, trx-wefax, trx-aprs (mostly tests)
- field_reassign_with_default -> struct-update syntax in trx-client config
  tests
- assign_op_pattern, useless vec!, and test-module ordering picked up by
  cargo clippy --fix in trx-client and the soapysdr WFM tests

No behaviour changes; all affected crates' tests pass.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-17 23:49:12 +02:00

451 lines
18 KiB
Rust
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.
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! VDES link-layer frame parsing per ITU-R M.2092-1.
//!
//! After FEC decoding and CRC validation, the decoded information bits
//! contain a link-layer frame with the following structure:
//!
//! ```text
//! ┌────────┬──────────┬──────────┬──────────┬─────────┬─────────┐
//! │ MsgID │ Repeat │ SessionID│ SourceID │ Payload │ CRC-16 │
//! │ 4 bits │ 2 bits │ 6 bits │ 32 bits │ variable│ 16 bits │
//! └────────┴──────────┴──────────┴──────────┴─────────┴─────────┘
//! ```
//!
//! This module provides structured parsing of the link-layer header and
//! payload fields for each VDES message type (06), including:
//! - Station addressing (source/destination MMSIs)
//! - ASM (Application Specific Message) identification
//! - Geographic bounding box parsing (Message 6)
//! - ACK/NACK channel quality reporting (Message 5)
use crate::crc;
/// Parsed link-layer frame result.
#[derive(Debug, Clone)]
pub struct LinkLayerFrame {
/// Message type ID (06).
pub message_id: u8,
/// Repeat indicator (03).
pub repeat: u8,
/// Session ID (063).
pub session_id: u8,
/// Source station ID (MMSI-like, 32 bits).
pub source_id: u32,
/// Destination station ID for addressed messages.
pub destination_id: Option<u32>,
/// Data bit count from the header.
pub data_count: Option<u16>,
/// ASM (Application Specific Message) identifier.
pub asm_identifier: Option<u16>,
/// ACK/NACK bitmask (Message 5).
pub ack_nack_mask: Option<u16>,
/// Channel quality indicator (Message 5).
pub channel_quality: Option<u8>,
/// Geographic bounding box: (sw_lat, sw_lon, ne_lat, ne_lon) in degrees.
pub geo_box: Option<GeoBox>,
/// Application payload bits (after header, before CRC).
pub payload_bits: Vec<u8>,
/// Whether the CRC-16 validated successfully.
pub crc_ok: bool,
/// Human-readable message type label.
pub label: &'static str,
}
/// Geographic bounding box for Message 6.
#[derive(Debug, Clone)]
pub struct GeoBox {
pub ne_lat: f64,
pub ne_lon: f64,
pub sw_lat: f64,
pub sw_lon: f64,
}
impl GeoBox {
/// Center latitude of the bounding box.
pub fn center_lat(&self) -> f64 {
(self.ne_lat + self.sw_lat) * 0.5
}
/// Center longitude of the bounding box.
pub fn center_lon(&self) -> f64 {
(self.ne_lon + self.sw_lon) * 0.5
}
}
/// Minimum bit length for a valid link-layer frame (header + CRC).
const MIN_FRAME_BITS: usize = 4 + 2 + 6 + 32 + 16; // 60 bits
/// Parse a decoded bit stream into a link-layer frame.
///
/// `bits` should be the FEC-decoded information bits including the trailing
/// 16-bit CRC. Returns `None` if the frame is too short or the message ID
/// is invalid.
pub fn parse_link_layer(bits: &[u8]) -> Option<LinkLayerFrame> {
if bits.len() < MIN_FRAME_BITS {
return None;
}
let crc_ok = crc::check_crc16(bits);
// Strip CRC for payload parsing
let data_bits = &bits[..bits.len() - 16];
let message_id = read_bits_u8(data_bits, 0, 4)?;
if message_id > 6 {
return None;
}
let repeat = read_bits_u8(data_bits, 4, 2).unwrap_or(0);
let session_id = read_bits_u8(data_bits, 6, 6).unwrap_or(0);
let source_id = read_bits_u32(data_bits, 12, 32).unwrap_or(0);
let mut frame = LinkLayerFrame {
message_id,
repeat,
session_id,
source_id,
destination_id: None,
data_count: None,
asm_identifier: None,
ack_nack_mask: None,
channel_quality: None,
geo_box: None,
payload_bits: Vec::new(),
crc_ok,
label: message_label(message_id),
};
match message_id {
0 => parse_msg0(data_bits, &mut frame),
1 => parse_msg1(data_bits, &mut frame),
2 => parse_msg2(data_bits, &mut frame),
3 => parse_msg3(data_bits, &mut frame),
4 => parse_msg4(data_bits, &mut frame),
5 => parse_msg5(data_bits, &mut frame),
6 => parse_msg6(data_bits, &mut frame),
_ => {}
}
Some(frame)
}
/// Message 0: Broadcast (unaddressed data)
///
/// ```text
/// ┌──────┬────────┬─────────┬──────────┬───────────┬─────────┐
/// │MsgID │Repeat │SessionID│SourceID │ DataCount │ Payload │
/// │4 │2 │6 │32 │ 11 │variable │
/// └──────┴────────┴─────────┴──────────┴───────────┴─────────┘
/// ```
fn parse_msg0(bits: &[u8], frame: &mut LinkLayerFrame) {
frame.data_count = read_bits_u16(bits, 44, 11);
let start = 55;
frame.payload_bits = extract_payload(bits, start, frame.data_count);
}
/// Message 1: Scheduled (standard TDMA)
///
/// ```text
/// ┌──────┬────────┬─────────┬──────────┬───────────┬────────────┬─────────┐
/// │MsgID │Repeat │SessionID│SourceID │ DataCount │ ASM Ident │ Payload │
/// │4 │2 │6 │32 │ 11 │ 16 │variable │
/// └──────┴────────┴─────────┴──────────┴───────────┴────────────┴─────────┘
/// ```
fn parse_msg1(bits: &[u8], frame: &mut LinkLayerFrame) {
frame.data_count = read_bits_u16(bits, 44, 11);
frame.asm_identifier = read_bits_u16(bits, 55, 16);
let start = 71;
frame.payload_bits = extract_payload(bits, start, frame.data_count);
}
/// Message 2: Scheduled (ITDMA)
fn parse_msg2(bits: &[u8], frame: &mut LinkLayerFrame) {
frame.data_count = read_bits_u16(bits, 44, 11);
frame.asm_identifier = read_bits_u16(bits, 55, 16);
let start = 71;
frame.payload_bits = extract_payload(bits, start, frame.data_count);
}
/// Message 3: Addressed (standard TDMA)
///
/// ```text
/// ┌──────┬────────┬─────────┬──────────┬─────────────┬───────────┬────────────┬─────────┐
/// │MsgID │Repeat │SessionID│ SourceID │DestinationID│ DataCount │ ASM Ident │ Payload │
/// │4 │2 │6 │32 │32 │ 11 │ 16 │variable │
/// └──────┴────────┴─────────┴──────────┴─────────────┴───────────┴────────────┴─────────┘
/// ```
fn parse_msg3(bits: &[u8], frame: &mut LinkLayerFrame) {
frame.destination_id = read_bits_u32(bits, 44, 32);
frame.data_count = read_bits_u16(bits, 76, 11);
frame.asm_identifier = read_bits_u16(bits, 87, 16);
let start = 103;
frame.payload_bits = extract_payload(bits, start, frame.data_count);
}
/// Message 4: Addressed (ITDMA)
fn parse_msg4(bits: &[u8], frame: &mut LinkLayerFrame) {
frame.destination_id = read_bits_u32(bits, 44, 32);
frame.data_count = read_bits_u16(bits, 76, 11);
frame.asm_identifier = read_bits_u16(bits, 87, 16);
let start = 103;
frame.payload_bits = extract_payload(bits, start, frame.data_count);
}
/// Message 5: Acknowledge (ACK/NACK)
///
/// ```text
/// ┌──────┬────────┬─────────┬──────────┬─────────────┬────────────┬─────────────┐
/// │MsgID │Repeat │SessionID│ SourceID │DestinationID│ ACK/NACK │ ChQuality │
/// │4 │2 │6 │32 │32 │ 16 │ 8 │
/// └──────┴────────┴─────────┴──────────┴─────────────┴────────────┴─────────────┘
/// ```
fn parse_msg5(bits: &[u8], frame: &mut LinkLayerFrame) {
frame.destination_id = read_bits_u32(bits, 44, 32);
frame.ack_nack_mask = read_bits_u16(bits, 76, 16);
frame.channel_quality = read_bits_u8(bits, 92, 8);
}
/// Message 6: Geo-referenced data
///
/// ```text
/// ┌──────┬────────┬─────────┬──────────┬────────┬────────┬────────┬────────┬───────────┬────────────┬─────────┐
/// │MsgID │Repeat │SessionID│ SourceID │NE Lon │NE Lat │SW Lon │SW Lat │ DataCount │ ASM Ident │ Payload │
/// │4 │2 │6 │32 │18 │17 │18 │17 │ 11 │ 16 │variable │
/// └──────┴────────┴─────────┴──────────┴────────┴────────┴────────┴────────┴───────────┴────────────┴─────────┘
/// ```
fn parse_msg6(bits: &[u8], frame: &mut LinkLayerFrame) {
let ne_lon = read_signed_bits(bits, 44, 18);
let ne_lat = read_signed_bits(bits, 62, 17);
let sw_lon = read_signed_bits(bits, 79, 18);
let sw_lat = read_signed_bits(bits, 97, 17);
if let (Some(ne_lon), Some(ne_lat), Some(sw_lon), Some(sw_lat)) =
(ne_lon, ne_lat, sw_lon, sw_lat)
{
let ne_lon_deg = ne_lon as f64 / 600.0;
let ne_lat_deg = ne_lat as f64 / 600.0;
let sw_lon_deg = sw_lon as f64 / 600.0;
let sw_lat_deg = sw_lat as f64 / 600.0;
if valid_geo_coord(ne_lat_deg, ne_lon_deg) && valid_geo_coord(sw_lat_deg, sw_lon_deg) {
frame.geo_box = Some(GeoBox {
ne_lat: ne_lat_deg,
ne_lon: ne_lon_deg,
sw_lat: sw_lat_deg,
sw_lon: sw_lon_deg,
});
}
}
frame.data_count = read_bits_u16(bits, 114, 11);
frame.asm_identifier = read_bits_u16(bits, 125, 16);
let start = 141;
frame.payload_bits = extract_payload(bits, start, frame.data_count);
}
fn message_label(id: u8) -> &'static str {
match id {
0 => "Broadcast",
1 => "Scheduled",
2 => "Scheduled ITDMA",
3 => "Addressed",
4 => "Addressed ITDMA",
5 => "Acknowledge",
6 => "Geo-referenced",
_ => "Unknown",
}
}
fn extract_payload(bits: &[u8], start: usize, count: Option<u16>) -> Vec<u8> {
let count = match count {
Some(c) => c as usize,
None => return Vec::new(),
};
let end = start.saturating_add(count).min(bits.len());
if start >= end {
return Vec::new();
}
bits[start..end].to_vec()
}
fn valid_geo_coord(lat: f64, lon: f64) -> bool {
(-90.0..=90.0).contains(&lat) && (-180.0..=180.0).contains(&lon)
}
fn read_bits_u8(bits: &[u8], start: usize, len: usize) -> Option<u8> {
read_bits_u32(bits, start, len).and_then(|v| u8::try_from(v).ok())
}
fn read_bits_u16(bits: &[u8], start: usize, len: usize) -> Option<u16> {
read_bits_u32(bits, start, len).and_then(|v| u16::try_from(v).ok())
}
fn read_bits_u32(bits: &[u8], start: usize, len: usize) -> Option<u32> {
if len == 0 || len > 32 {
return None;
}
let end = start.checked_add(len)?;
let slice = bits.get(start..end)?;
let mut value = 0u32;
for &bit in slice {
value = (value << 1) | u32::from(bit & 1);
}
Some(value)
}
fn read_signed_bits(bits: &[u8], start: usize, len: usize) -> Option<i32> {
let raw = read_bits_u32(bits, start, len)?;
if len == 0 || len > 31 {
return None;
}
let sign_mask = 1u32 << (len - 1);
if raw & sign_mask == 0 {
Some(raw as i32)
} else {
let extended = raw | (!0u32 << len);
Some(extended as i32)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crc;
fn write_bits(bits: &mut [u8], start: usize, len: usize, value: u32) {
for idx in 0..len {
let shift = len - idx - 1;
bits[start + idx] = ((value >> shift) & 1) as u8;
}
}
fn write_signed_bits(bits: &mut [u8], start: usize, len: usize, value: i32) {
let mask = if len >= 32 {
u32::MAX
} else {
(1u32 << len) - 1
};
write_bits(bits, start, len, (value as u32) & mask);
}
fn append_crc(bits: &mut Vec<u8>) {
let crc = crc::crc16_ccitt_bits(&bits[..]);
for i in (0..16).rev() {
bits.push(((crc >> i) & 1) as u8);
}
}
#[test]
fn parse_msg0_broadcast() {
let mut bits = vec![0u8; 100];
write_bits(&mut bits, 0, 4, 0); // message_id = 0
write_bits(&mut bits, 4, 2, 1); // repeat = 1
write_bits(&mut bits, 6, 6, 5); // session_id = 5
write_bits(&mut bits, 12, 32, 123456); // source_id
write_bits(&mut bits, 44, 11, 20); // data_count = 20
// Fill some payload
for (i, bit) in bits.iter_mut().enumerate().take(75).skip(55) {
*bit = (i % 2) as u8;
}
append_crc(&mut bits);
let frame = parse_link_layer(&bits).expect("should parse");
assert_eq!(frame.message_id, 0);
assert_eq!(frame.repeat, 1);
assert_eq!(frame.session_id, 5);
assert_eq!(frame.source_id, 123456);
assert_eq!(frame.data_count, Some(20));
assert_eq!(frame.payload_bits.len(), 20);
assert!(frame.crc_ok);
assert_eq!(frame.label, "Broadcast");
}
#[test]
fn parse_msg3_addressed() {
let mut bits = vec![0u8; 150];
write_bits(&mut bits, 0, 4, 3); // message_id = 3
write_bits(&mut bits, 4, 2, 0); // repeat
write_bits(&mut bits, 6, 6, 10); // session_id
write_bits(&mut bits, 12, 32, 111111); // source_id
write_bits(&mut bits, 44, 32, 222222); // destination_id
write_bits(&mut bits, 76, 11, 15); // data_count
write_bits(&mut bits, 87, 16, 0x1234); // asm_identifier
append_crc(&mut bits);
let frame = parse_link_layer(&bits).expect("should parse");
assert_eq!(frame.message_id, 3);
assert_eq!(frame.source_id, 111111);
assert_eq!(frame.destination_id, Some(222222));
assert_eq!(frame.asm_identifier, Some(0x1234));
assert!(frame.crc_ok);
assert_eq!(frame.label, "Addressed");
}
#[test]
fn parse_msg5_acknowledge() {
let mut bits = vec![0u8; 120];
write_bits(&mut bits, 0, 4, 5); // message_id = 5
write_bits(&mut bits, 4, 2, 0);
write_bits(&mut bits, 6, 6, 0);
write_bits(&mut bits, 12, 32, 999999);
write_bits(&mut bits, 44, 32, 888888);
write_bits(&mut bits, 76, 16, 0xABCD); // ack_nack
write_bits(&mut bits, 92, 8, 42); // channel_quality
append_crc(&mut bits);
let frame = parse_link_layer(&bits).expect("should parse");
assert_eq!(frame.message_id, 5);
assert_eq!(frame.ack_nack_mask, Some(0xABCD));
assert_eq!(frame.channel_quality, Some(42));
assert!(frame.crc_ok);
}
#[test]
fn parse_msg6_geo_box() {
let mut bits = vec![0u8; 200];
write_bits(&mut bits, 0, 4, 6);
write_bits(&mut bits, 4, 2, 0);
write_bits(&mut bits, 6, 6, 0);
write_bits(&mut bits, 12, 32, 54321);
// NE corner: lon=10.0°, lat=20.0°
write_signed_bits(&mut bits, 44, 18, (10.0_f64 * 600.0) as i32);
write_signed_bits(&mut bits, 62, 17, (20.0_f64 * 600.0) as i32);
// SW corner: lon=-5.0°, lat=15.0°
write_signed_bits(&mut bits, 79, 18, (-5.0_f64 * 600.0) as i32);
write_signed_bits(&mut bits, 97, 17, (15.0_f64 * 600.0) as i32);
write_bits(&mut bits, 114, 11, 10); // data_count
write_bits(&mut bits, 125, 16, 0x5678); // asm_identifier
append_crc(&mut bits);
let frame = parse_link_layer(&bits).expect("should parse");
assert_eq!(frame.message_id, 6);
let geo = frame.geo_box.expect("geo_box should be present");
assert!((geo.ne_lon - 10.0).abs() < 0.01);
assert!((geo.ne_lat - 20.0).abs() < 0.01);
assert!((geo.sw_lon - (-5.0)).abs() < 0.01);
assert!((geo.sw_lat - 15.0).abs() < 0.01);
assert!(frame.crc_ok);
}
#[test]
fn bad_crc_detected() {
let mut bits = vec![0u8; 80];
write_bits(&mut bits, 0, 4, 0);
write_bits(&mut bits, 12, 32, 1);
write_bits(&mut bits, 44, 11, 0);
// Append wrong CRC
bits.extend_from_slice(&[0; 16]);
let frame = parse_link_layer(&bits).expect("should parse");
assert!(!frame.crc_ok);
}
#[test]
fn too_short_returns_none() {
let bits = vec![0u8; 10];
assert!(parse_link_layer(&bits).is_none());
}
}