Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ water = ["DBK", "DBS", "DPT", "MTW", "VHW"]
## Vendor-specific messages
vendor-specific = ["RMZ"]
## Other
other = ["HDT", "MDA", "MWV", "TXT", "ZDA"]
other = ["HDT", "MDA", "MWV", "TXT", "XDR", "ZDA"]

#! ### Supported sentences (alphabetically ordered)

Expand Down Expand Up @@ -204,6 +204,10 @@ VTG = []
## (feature: `waypoint`)
WNC = []

## Transducer Measurements
## (feature: `other`)
XDR = []

## Time & Date - UTC, day, month, year and local time zone
## (feature: `other`)
ZDA = []
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Supported sentences (alphabetically ordered):
- `VHW` - Water speed and heading (feature: `water`)
- `VTG` - * Track made good and Ground speed (feature: `GNSS`)
- `WNC` - Distance - Waypoint to waypoint (feature: `waypoint`)
- `XDR` - Transducer Measurements (feature: `other`)
- `ZDA` - Time & Date - UTC, day, month, year and local time zone (feature: `other`)
- `ZFO` - UTC & Time from origin Waypoint (feature: `waypoint`)
- `ZTG` - UTC & Time to Destination Waypoint (feature: `waypoint`)
Expand Down
11 changes: 11 additions & 0 deletions src/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ pub enum ParseResult {
ZDA(ZdaData),
ZFO(ZfoData),
ZTG(ZtgData),
XDR(XdrData),
PGRMZ(PgrmzData),
/// A message that is not supported by the crate and cannot be parsed.
Unsupported(SentenceType),
Expand Down Expand Up @@ -174,6 +175,7 @@ impl From<&ParseResult> for SentenceType {
ParseResult::PGRMZ(_) => SentenceType::RMZ,
ParseResult::ZDA(_) => SentenceType::ZDA,
ParseResult::DPT(_) => SentenceType::DPT,
ParseResult::XDR(_) => SentenceType::XDR,
ParseResult::Unsupported(sentence_type) => *sentence_type,
}
}
Expand Down Expand Up @@ -470,6 +472,15 @@ pub fn parse_str(sentence_input: &str) -> Result<ParseResult, Error<'_>> {
}
}
}
SentenceType::XDR => {
cfg_if! {
if #[cfg(feature = "XDR")] {
parse_xdr(nmea_sentence).map(ParseResult::XDR)
} else {
return Err(Error::DisabledSentence);
}
}
}
sentence_type => Ok(ParseResult::Unsupported(sentence_type)),
}
} else {
Expand Down
1 change: 1 addition & 0 deletions src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@ impl<'a> Nmea {
| ParseResult::ZDA(_)
| ParseResult::ZFO(_)
| ParseResult::WNC(_)
| ParseResult::XDR(_)
| ParseResult::ZTG(_) => return Ok(FixType::Invalid),

ParseResult::Unsupported(_) => {
Expand Down
2 changes: 2 additions & 0 deletions src/sentences.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub mod utils;
pub mod vhw;
pub mod vtg;
pub mod wnc;
pub mod xdr;
pub mod zda;
pub mod zfo;
pub mod ztg;
Expand Down Expand Up @@ -71,6 +72,7 @@ pub use {
vhw::{VhwData, parse_vhw},
vtg::{VtgData, parse_vtg},
wnc::{WncData, parse_wnc},
xdr::{XdrData, XdrMeasurement, parse_xdr},
zda::{ZdaData, parse_zda},
zfo::{ZfoData, parse_zfo},
ztg::{ZtgData, parse_ztg},
Expand Down
266 changes: 266 additions & 0 deletions src/sentences/xdr.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
use heapless::Vec;
use nom::{
IResult, Parser as _,
bytes::complete::take_while,
character::complete::{anychar, char},
combinator::opt,
number::complete::double,
};

#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};

use crate::{Error, SentenceType, parse::NmeaSentence};

/// Maximum number of transducer measurements in a single XDR sentence.
/// Real instruments send up to 5 measurements (e.g., B&G H5000, compass devices).
const MAX_MEASUREMENTS: usize = 8;

/// A single transducer measurement from an XDR sentence.
///
/// Each measurement consists of four fields:
/// - Transducer type (e.g., 'P' for pressure, 'C' for temperature)
/// - Measurement value
/// - Unit of measurement (e.g., 'B' for bars, 'C' for Celsius)
/// - Transducer name (e.g., "Barometer", "AirTemp")
///
/// Some instruments emit empty unit or name fields; these are represented
/// as empty strings / null chars rather than Options, matching the NMEA
/// convention of "field present but empty".
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[derive(Debug, Clone, PartialEq)]
pub struct XdrMeasurement {
/// Transducer type indicator:
/// - 'A' = Angular displacement (degrees)
/// - 'C' = Temperature
/// - 'D' = Depth
/// - 'E' = Fluid level (v4.11)
/// - 'G' = Generic (magnetic field, engine hours)
/// - 'H' = Humidity
/// - 'I' = Current (amperes)
/// - 'P' = Pressure
/// - 'T' = Tachometer (RPM)
/// - 'U' = Voltage
pub transducer_type: char,
/// Measurement value.
pub value: f64,
/// Unit of measurement indicator, or '\0' if the field is empty.
pub units: char,
/// Transducer name (instrument-defined). May be empty.
pub name: arrayvec::ArrayString<16>,
}

/// XDR - Transducer Measurements
///
/// Generic transducer measurement sentence used to convey data from various
/// on-board sensors: barometric pressure, air temperature, humidity, rudder
/// angle, battery voltage, engine data, and more.
///
/// Contains one or more measurement groups (typically 1-5).
///
/// <https://gpsd.gitlab.io/gpsd/NMEA.html#_xdr_transducer_measurement>
///
/// ```text
/// 1 2 3 4 1 2 3 4
/// | | | | | | | |
/// $--XDR,a,x.x,a,c--c[,a,x.x,a,c--c]*hh<CR><LF>
/// ```
///
/// Groups of 4 fields, repeatable:
/// 1. Transducer type
/// 2. Measurement value
/// 3. Unit of measurement (may be empty)
/// 4. Transducer name (may be empty)
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[derive(Debug, Clone, PartialEq)]
pub struct XdrData {
/// One or more transducer measurements.
pub measurements: Vec<XdrMeasurement, MAX_MEASUREMENTS>,
}

/// # Parse XDR message
///
/// Transducer Measurements — a generic container for sensor data.
///
/// <https://gpsd.gitlab.io/gpsd/NMEA.html#_xdr_transducer_measurement>
///
/// ## Examples:
/// ```text
/// $IIXDR,P,1.0154,B,Barometer*16
/// $IIXDR,P,1.015,B,Baro,C,22.0,C,AirTemp*21
/// $WIXDR,C,022.0,C,,*52
/// ```
pub fn parse_xdr(sentence: NmeaSentence<'_>) -> Result<XdrData, Error<'_>> {
if sentence.message_id != SentenceType::XDR {
Err(Error::WrongSentenceHeader {
expected: SentenceType::XDR,
found: sentence.message_id,
})
} else {
Ok(do_parse_xdr(sentence.data)?.1)
}
}

fn do_parse_xdr(i: &str) -> IResult<&str, XdrData> {
let mut measurements = Vec::new();
let mut remaining = i;

while !remaining.is_empty() {
// Transducer type (single char)
let (i, transducer_type) = anychar.parse(remaining)?;
let (i, _) = char(',').parse(i)?;

// Value (floating point)
let value_result: IResult<&str, f64> = double.parse(i);
let (i, value) = match value_result {
Ok((i, v)) => (i, v),
Err(_) => break, // incomplete quadruplet — stop parsing
};
let (i, _) = char(',').parse(i)?;

// Units — may be empty (e.g., engine hours: "G,200,,ENGINE#0")
let (i, units) = opt(|i| {
let (i, c) = anychar.parse(i)?;
if c == ',' {
Err(nom::Err::Error(nom::error::Error::new(
i,
nom::error::ErrorKind::Char,
)))
} else {
Ok((i, c))
}
})
.parse(i)?;
let units = units.unwrap_or('\0');
let (i, _) = char(',').parse(i)?;

// Name — may be empty (e.g., "$WIXDR,C,022.0,C,,*52")
let (i, name_str) = take_while(|c| c != ',').parse(i)?;
let mut name = arrayvec::ArrayString::<16>::new();
let truncated = if name_str.len() > 16 {
&name_str[..16]
} else {
name_str
};
let _ = name.try_push_str(truncated);

if measurements.is_full() {
break;
}
measurements
.push(XdrMeasurement {
transducer_type,
value,
units,
name,
})
.ok();

// Optional comma before next group
let (i, _) = opt(char(',')).parse(i)?;
remaining = i;
}

Ok(("", XdrData { measurements }))
}

#[cfg(test)]
mod tests {
use super::*;
use crate::parse::parse_nmea_sentence;

#[test]
fn test_parse_xdr_single_pressure() {
let s = parse_nmea_sentence("$IIXDR,P,1.0154,B,Barometer*16").unwrap();
assert_eq!(s.checksum, s.calc_checksum());
let xdr = parse_xdr(s).unwrap();
assert_eq!(xdr.measurements.len(), 1);
let m = &xdr.measurements[0];
assert_eq!(m.transducer_type, 'P');
assert!((m.value - 1.0154).abs() < 0.0001);
assert_eq!(m.units, 'B');
assert_eq!(m.name.as_str(), "Barometer");
}

#[test]
fn test_parse_xdr_single_temperature() {
let s = parse_nmea_sentence("$IIXDR,C,23.5,C,AirTemp*22").unwrap();
assert_eq!(s.checksum, s.calc_checksum());
let xdr = parse_xdr(s).unwrap();
assert_eq!(xdr.measurements.len(), 1);
assert_eq!(xdr.measurements[0].transducer_type, 'C');
assert!((xdr.measurements[0].value - 23.5).abs() < 0.1);
assert_eq!(xdr.measurements[0].name.as_str(), "AirTemp");
}

#[test]
fn test_parse_xdr_angular_displacement() {
let s = parse_nmea_sentence("$IIXDR,A,-2.3,D,RUDDER*59").unwrap();
assert_eq!(s.checksum, s.calc_checksum());
let xdr = parse_xdr(s).unwrap();
assert_eq!(xdr.measurements.len(), 1);
let m = &xdr.measurements[0];
assert_eq!(m.transducer_type, 'A');
assert!((m.value - (-2.3)).abs() < 0.1);
assert_eq!(m.units, 'D');
assert_eq!(m.name.as_str(), "RUDDER");
}

#[test]
fn test_parse_xdr_two_measurements() {
let s = parse_nmea_sentence("$IIXDR,P,1.015,B,Baro,C,22.0,C,AirTemp*21").unwrap();
assert_eq!(s.checksum, s.calc_checksum());
let xdr = parse_xdr(s).unwrap();
assert_eq!(xdr.measurements.len(), 2);
assert_eq!(xdr.measurements[0].transducer_type, 'P');
assert_eq!(xdr.measurements[0].name.as_str(), "Baro");
assert_eq!(xdr.measurements[1].transducer_type, 'C');
assert_eq!(xdr.measurements[1].name.as_str(), "AirTemp");
}

#[test]
fn test_parse_xdr_four_measurements() {
let s = parse_nmea_sentence(
"$IIXDR,P,1.013,B,Baro,C,19.5,C,TempAir,H,65.2,P,Humidity,A,-1.5,D,RUDDER*06",
)
.unwrap();
assert_eq!(s.checksum, s.calc_checksum());
let xdr = parse_xdr(s).unwrap();
assert_eq!(xdr.measurements.len(), 4);
assert_eq!(xdr.measurements[0].transducer_type, 'P');
assert_eq!(xdr.measurements[1].transducer_type, 'C');
assert_eq!(xdr.measurements[2].transducer_type, 'H');
assert_eq!(xdr.measurements[3].transducer_type, 'A');
}

#[test]
fn test_parse_xdr_empty_name_field() {
// Real-world: Calypso anemometer sends empty name
let s = parse_nmea_sentence("$WIXDR,C,022.0,C,,*52").unwrap();
assert_eq!(s.checksum, s.calc_checksum());
let xdr = parse_xdr(s).unwrap();
assert_eq!(xdr.measurements.len(), 1);
assert_eq!(xdr.measurements[0].name.as_str(), "");
assert!((xdr.measurements[0].value - 22.0).abs() < 0.1);
}

#[test]
fn test_parse_xdr_empty_units_field() {
// Real-world: engine hours often have empty unit field
let s = parse_nmea_sentence("$IIXDR,G,200,,ENGHRS*3E").unwrap();
assert_eq!(s.checksum, s.calc_checksum());
let xdr = parse_xdr(s).unwrap();
assert_eq!(xdr.measurements.len(), 1);
assert_eq!(xdr.measurements[0].units, '\0');
assert!((xdr.measurements[0].value - 200.0).abs() < 0.1);
assert_eq!(xdr.measurements[0].name.as_str(), "ENGHRS");
}

#[test]
fn test_parse_xdr_wrong_sentence_type() {
let s = parse_nmea_sentence("$INMTW,17.9,C*1B").unwrap();
assert!(parse_xdr(s).is_err());
}
}