use crate::RefClockError;
use rtime_core::clock::LeapIndicator;
use rtime_core::source::{SourceId, SourceMeasurement};
use rtime_core::timestamp::{NtpDuration, NtpTimestamp};
#[derive(Debug, Clone)]
pub struct GpsFix {
pub hours: u8,
pub minutes: u8,
pub seconds: u8,
pub subsec_ms: u16,
pub day: u8,
pub month: u8,
pub year: u16,
pub valid: bool,
}
impl GpsFix {
pub fn to_ntp_timestamp(&self) -> Option<NtpTimestamp> {
if !self.valid {
return None;
}
let days = days_from_ntp_epoch(self.year, self.month, self.day)?;
let seconds_in_day =
self.hours as u64 * 3600 + self.minutes as u64 * 60 + self.seconds as u64;
let total_seconds = days * 86400 + seconds_in_day;
let ntp_seconds = total_seconds as u32; let fraction = (self.subsec_ms as u64 * (1u64 << 32)) / 1000;
Some(NtpTimestamp::new(ntp_seconds, fraction as u32))
}
}
fn days_from_ntp_epoch(year: u16, month: u8, day: u8) -> Option<u64> {
if !(1..=12).contains(&month) || !(1..=31).contains(&day) || year < 1900 {
return None;
}
let mut total_days: u64 = 0;
for y in 1900..year {
total_days += if is_leap_year(y) { 366 } else { 365 };
}
let days_in_months: [u8; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
for m in 1..month {
let mut d = days_in_months[(m - 1) as usize] as u64;
if m == 2 && is_leap_year(year) {
d = 29;
}
total_days += d;
}
total_days += (day - 1) as u64;
Some(total_days)
}
fn is_leap_year(y: u16) -> bool {
(y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400)
}
fn validate_checksum(sentence: &str) -> bool {
let sentence = sentence.trim();
let start = match sentence.find('$') {
Some(i) => i + 1,
None => return false,
};
let star = match sentence.find('*') {
Some(i) => i,
None => return false,
};
if star <= start || star + 3 > sentence.len() {
return false;
}
let payload = &sentence[start..star];
let expected_hex = &sentence[star + 1..star + 3];
let computed: u8 = payload.bytes().fold(0u8, |acc, b| acc ^ b);
let Ok(expected) = u8::from_str_radix(expected_hex, 16) else {
return false;
};
computed == expected
}
pub fn parse_gprmc(sentence: &str) -> Result<GpsFix, RefClockError> {
if !validate_checksum(sentence) {
return Err(RefClockError::ParseError(
"GPRMC checksum mismatch".to_string(),
));
}
let data = sentence.trim().strip_prefix('$').unwrap_or(sentence.trim());
let data = data.split('*').next().unwrap_or(data);
let fields: Vec<&str> = data.split(',').collect();
if fields.len() < 10 {
return Err(RefClockError::ParseError(format!(
"GPRMC: expected at least 10 fields, got {}",
fields.len()
)));
}
let (hours, minutes, seconds, subsec_ms) = parse_nmea_time(fields[1])?;
let valid = fields[2] == "A";
let (day, month, year) = parse_rmc_date(fields[9])?;
Ok(GpsFix {
hours,
minutes,
seconds,
subsec_ms,
day,
month,
year,
valid,
})
}
pub fn parse_gpzda(sentence: &str) -> Result<GpsFix, RefClockError> {
if !validate_checksum(sentence) {
return Err(RefClockError::ParseError(
"GPZDA checksum mismatch".to_string(),
));
}
let data = sentence.trim().strip_prefix('$').unwrap_or(sentence.trim());
let data = data.split('*').next().unwrap_or(data);
let fields: Vec<&str> = data.split(',').collect();
if fields.len() < 5 {
return Err(RefClockError::ParseError(format!(
"GPZDA: expected at least 5 fields, got {}",
fields.len()
)));
}
let (hours, minutes, seconds, subsec_ms) = parse_nmea_time(fields[1])?;
let day: u8 = fields[2]
.parse()
.map_err(|_| RefClockError::ParseError(format!("GPZDA: invalid day '{}'", fields[2])))?;
let month: u8 = fields[3]
.parse()
.map_err(|_| RefClockError::ParseError(format!("GPZDA: invalid month '{}'", fields[3])))?;
let year: u16 = fields[4]
.parse()
.map_err(|_| RefClockError::ParseError(format!("GPZDA: invalid year '{}'", fields[4])))?;
Ok(GpsFix {
hours,
minutes,
seconds,
subsec_ms,
day,
month,
year,
valid: true, })
}
fn parse_nmea_time(field: &str) -> Result<(u8, u8, u8, u16), RefClockError> {
if field.len() < 6 {
return Err(RefClockError::ParseError(format!(
"NMEA time too short: '{field}'"
)));
}
let hours: u8 = field[0..2]
.parse()
.map_err(|_| RefClockError::ParseError(format!("invalid hours in '{field}'")))?;
let minutes: u8 = field[2..4]
.parse()
.map_err(|_| RefClockError::ParseError(format!("invalid minutes in '{field}'")))?;
let sec_str = &field[4..];
let (sec_whole, subsec_ms) = if let Some(dot_pos) = sec_str.find('.') {
let whole: u8 = sec_str[..dot_pos]
.parse()
.map_err(|_| RefClockError::ParseError(format!("invalid seconds in '{field}'")))?;
let frac_str = &sec_str[dot_pos + 1..];
let ms: u16 = if frac_str.is_empty() {
0
} else {
let padded = format!("{frac_str:0<3}");
padded[..3]
.parse()
.map_err(|_| RefClockError::ParseError(format!("invalid subsec in '{field}'")))?
};
(whole, ms)
} else {
let whole: u8 = sec_str
.parse()
.map_err(|_| RefClockError::ParseError(format!("invalid seconds in '{field}'")))?;
(whole, 0u16)
};
Ok((hours, minutes, sec_whole, subsec_ms))
}
fn parse_rmc_date(field: &str) -> Result<(u8, u8, u16), RefClockError> {
if field.len() < 6 {
return Err(RefClockError::ParseError(format!(
"RMC date too short: '{field}'"
)));
}
let day: u8 = field[0..2]
.parse()
.map_err(|_| RefClockError::ParseError(format!("invalid day in date '{field}'")))?;
let month: u8 = field[2..4]
.parse()
.map_err(|_| RefClockError::ParseError(format!("invalid month in date '{field}'")))?;
let yy: u16 = field[4..6]
.parse()
.map_err(|_| RefClockError::ParseError(format!("invalid year in date '{field}'")))?;
let year = if yy >= 80 { 1900 + yy } else { 2000 + yy };
Ok((day, month, year))
}
pub struct GpsDriver {
device: String,
source_id: SourceId,
}
impl GpsDriver {
pub fn new(device: &str) -> Self {
Self {
device: device.to_string(),
source_id: SourceId::RefClock {
driver: "GPS".to_string(),
unit: 0,
},
}
}
pub fn device(&self) -> &str {
&self.device
}
pub fn process_line(&self, line: &str, recv_time: NtpTimestamp) -> Option<SourceMeasurement> {
let trimmed = line.trim();
let fix = if trimmed.starts_with("$GPRMC") || trimmed.starts_with("$GNRMC") {
parse_gprmc(trimmed).ok()?
} else if trimmed.starts_with("$GPZDA") || trimmed.starts_with("$GNZDA") {
parse_gpzda(trimmed).ok()?
} else {
return None;
};
let gps_time = fix.to_ntp_timestamp()?;
let offset = NtpDuration::between(recv_time, gps_time);
let delay = NtpDuration::from_millis(50);
let dispersion = NtpDuration::from_millis(10);
Some(SourceMeasurement {
id: self.source_id.clone(),
offset,
delay,
dispersion,
jitter: 0.005, stratum: 1, leap_indicator: LeapIndicator::NoWarning,
root_delay: NtpDuration::ZERO,
root_dispersion: NtpDuration::ZERO,
time: recv_time,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_nmea(payload: &str) -> String {
let cksum: u8 = payload.bytes().fold(0u8, |acc, b| acc ^ b);
format!("${payload}*{cksum:02X}")
}
#[test]
fn test_validate_checksum_valid() {
let sentence =
make_nmea("GPRMC,123519.00,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W");
assert!(validate_checksum(&sentence));
}
#[test]
fn test_validate_checksum_invalid() {
let sentence =
make_nmea("GPRMC,123519.00,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W");
let corrupted = format!("{}FF", &sentence[..sentence.len() - 2]);
assert!(!validate_checksum(&corrupted));
}
#[test]
fn test_validate_checksum_no_star() {
assert!(!validate_checksum("$GPRMC,123519.00,A"));
}
#[test]
fn test_parse_gprmc() {
let sentence =
make_nmea("GPRMC,123519.00,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W");
let fix = parse_gprmc(&sentence).unwrap();
assert_eq!(fix.hours, 12);
assert_eq!(fix.minutes, 35);
assert_eq!(fix.seconds, 19);
assert_eq!(fix.subsec_ms, 0);
assert_eq!(fix.day, 23);
assert_eq!(fix.month, 3);
assert_eq!(fix.year, 1994);
assert!(fix.valid);
}
#[test]
fn test_parse_gprmc_void() {
let payload = "GPRMC,123519.00,V,,,,,,,230394,,";
let cksum: u8 = payload.bytes().fold(0u8, |acc, b| acc ^ b);
let sentence = format!("${payload}*{cksum:02X}");
let fix = parse_gprmc(&sentence).unwrap();
assert!(!fix.valid);
}
#[test]
fn test_parse_gpzda() {
let payload = "GPZDA,082710.00,16,09,2026,00,00";
let cksum: u8 = payload.bytes().fold(0u8, |acc, b| acc ^ b);
let sentence = format!("${payload}*{cksum:02X}");
let fix = parse_gpzda(&sentence).unwrap();
assert_eq!(fix.hours, 8);
assert_eq!(fix.minutes, 27);
assert_eq!(fix.seconds, 10);
assert_eq!(fix.subsec_ms, 0);
assert_eq!(fix.day, 16);
assert_eq!(fix.month, 9);
assert_eq!(fix.year, 2026);
assert!(fix.valid);
}
#[test]
fn test_gps_fix_to_ntp_timestamp() {
let fix = GpsFix {
hours: 0,
minutes: 0,
seconds: 0,
subsec_ms: 0,
day: 1,
month: 1,
year: 1970,
valid: true,
};
let ts = fix.to_ntp_timestamp().unwrap();
assert_eq!(ts.seconds(), 2_208_988_800u32);
assert_eq!(ts.fraction(), 0);
}
#[test]
fn test_gps_fix_to_ntp_timestamp_invalid() {
let fix = GpsFix {
hours: 0,
minutes: 0,
seconds: 0,
subsec_ms: 0,
day: 1,
month: 1,
year: 2024,
valid: false,
};
assert!(fix.to_ntp_timestamp().is_none());
}
#[test]
fn test_gps_fix_subsec() {
let fix = GpsFix {
hours: 12,
minutes: 0,
seconds: 0,
subsec_ms: 500, day: 1,
month: 1,
year: 2000,
valid: true,
};
let ts = fix.to_ntp_timestamp().unwrap();
assert_eq!(ts.fraction(), 2_147_483_648);
}
#[test]
fn test_is_leap_year() {
assert!(is_leap_year(2000)); assert!(!is_leap_year(1900)); assert!(is_leap_year(2024)); assert!(!is_leap_year(2023)); }
#[test]
fn test_days_from_ntp_epoch_unix_epoch() {
let days = days_from_ntp_epoch(1970, 1, 1).unwrap();
assert_eq!(days, 25567);
}
#[test]
fn test_parse_nmea_time_with_frac() {
let (h, m, s, ms) = parse_nmea_time("123519.50").unwrap();
assert_eq!(h, 12);
assert_eq!(m, 35);
assert_eq!(s, 19);
assert_eq!(ms, 500);
}
#[test]
fn test_parse_nmea_time_no_frac() {
let (h, m, s, ms) = parse_nmea_time("000000").unwrap();
assert_eq!(h, 0);
assert_eq!(m, 0);
assert_eq!(s, 0);
assert_eq!(ms, 0);
}
#[test]
fn test_parse_rmc_date() {
let (d, m, y) = parse_rmc_date("230394").unwrap();
assert_eq!(d, 23);
assert_eq!(m, 3);
assert_eq!(y, 1994);
}
#[test]
fn test_parse_rmc_date_2000s() {
let (d, m, y) = parse_rmc_date("010126").unwrap();
assert_eq!(d, 1);
assert_eq!(m, 1);
assert_eq!(y, 2026);
}
#[test]
fn test_gps_driver_process_line_rmc() {
let driver = GpsDriver::new("/dev/ttyUSB0");
let sentence =
make_nmea("GPRMC,123519.00,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W");
let recv_time = NtpTimestamp::now();
let measurement = driver.process_line(&sentence, recv_time);
assert!(measurement.is_some());
let m = measurement.unwrap();
assert_eq!(m.stratum, 1);
assert_eq!(m.leap_indicator, LeapIndicator::NoWarning);
}
#[test]
fn test_gps_driver_process_line_unknown() {
let driver = GpsDriver::new("/dev/ttyUSB0");
let measurement = driver.process_line("$GPGGA,123519,...", NtpTimestamp::now());
assert!(measurement.is_none());
}
#[test]
fn test_gps_driver_process_line_invalid_checksum() {
let driver = GpsDriver::new("/dev/ttyUSB0");
let good = make_nmea("GPRMC,123519.00,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W");
let sentence = format!("{}FF", &good[..good.len() - 2]);
let measurement = driver.process_line(&sentence, NtpTimestamp::now());
assert!(measurement.is_none());
}
}