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>
451 lines
18 KiB
Rust
451 lines
18 KiB
Rust
// 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 (0–6), 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 (0–6).
|
||
pub message_id: u8,
|
||
/// Repeat indicator (0–3).
|
||
pub repeat: u8,
|
||
/// Session ID (0–63).
|
||
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());
|
||
}
|
||
}
|