Background
GPS (Global Positioning System) is a global navigation satellite system that provides location and time information. GPS receivers typically output data in the form of NMEA (National Marine Electronics Association) strings, which are ASCII-encoded strings that contain information such as latitude, longitude, altitude, speed, and heading.
In this tutorial, we’ll be creating a Rust library that can parse GPS NMEA strings using the Nom parser combinator library.
Getting Started
To get started, make sure you have Rust and Cargo installed on your system. You can install Rust and Cargo by following the instructions on the official Rust website: https://www.rust-lang.org/tools/install
Once you have Rust and Cargo installed, create a new Rust project by running the following command:
cargo new nmea_parser
This will create a new Rust project named nmea-parser
with the following directory structure:
nmea-parser/
├── Cargo.toml
└── src
└── main.rs
Adding Dependencies
Next, we need to add the nom
and serde
dependencies to our project. nom
is a parser combinator library for Rust, and serde
is a serialization and deserialization library that we'll use to convert our parsed data into Rust data structures.
To add these dependencies, open the Cargo.toml
file in your project directory and add the following lines under the [dependencies]
section:
nom = "6.2.1"
serde = { version = "1.0.130", features = ["derive"] }
This tells Cargo to download and use version 6.2.1 of nom
and version 1.0.130 of serde
with the derive
feature enabled.
Step 3: Implement NMEA sentence parsers
Let’s start by implementing parsers for the most common NMEA sentence types. We’ll start with the $GPGGA
sentence type, which provides fix data. Create a new module named nmea
in your src/main.rs
file and implement the following functions:
#[derive(Debug, PartialEq)]
struct GpggaData {
time: String,
latitude: f64,
longitude: f64,
fix_quality: u8,
num_satellites: u8,
altitude: f64,
geoid_height: f64,
}
This struct has fields for the GPS time, latitude, longitude, fix quality, number of satellites, altitude, and geoid height.
Next, let’s define a Nom parser for the GPGGA sentence. We’ll use the nom::bytes::complete
module to parse ASCII strings and the nom::character::complete
module to parse characters:
use nom::branch::alt;
use nom::bytes::complete::{tag, take_until};
use nom::character::complete::{char, digit1};
use nom::combinator::{map_res, opt};
use nom::sequence::{delimited, pair, preceded, terminated};
fn parse_gpgga(input: &str) -> IResult<&str, GpggaData> {
let (input, _) = tag("$GPGGA,")(input)?;
let (input, time) = map_res(digit1, |s: &str| s.parse::<u32>())(input)?;
let (input, _) = char('.')(input)?;
let (input, time_frac) = map_res(digit1, |s: &str| s.parse::<u32>())(input)?;
let (input, _) = char(',')(input)?;
let (input, latitude) = parse_latitude(input)?;
let (input, _) = char(',')(input)?;
let (input, longitude) = parse_longitude(input)?;
let (input, _) = char(',')(input)?;
let (input, fix_quality) = parse_fix_quality(input)?;
let (input, _) = char(',')(input)?;
let (input, num_satellites) = parse_num_satellites(input)?;
let (input, _) = char(',')(input)?;
let (input, altitude) = parse_altitude(input)?;
let (input, _) = char(',')(input)?;
let (input, geoid_height) = parse_geoid_height(input)?;
let (input, _) = alt((tag(",,"), tag(",")))(input)?;
let (input, _) = take_until("*")(input)?;
let (input, _) = char('*')(input)?;
let (input, _) = take_until("\r\n")(input)?;
let data = GpggaData {
time: format!("{:02}:{:02}:{:02}.{:03}", time / 10000, (time / 100) % 100, time % 100, time_frac),
latitude,
longitude,
fix_quality,
num_satellites,
altitude,
geoid_height,
};
Ok((input, data))
}
fn parse_latitude(input: &str) -> IResult<&str, f64> {
let (input, degrees) = map_res(pair(digit1, opt(preceded(char('.'), digit1))), |(a, b): (char, Option<&str>)| format!("{}{}", a, b.unwrap_or("0")).parse::<f64>())(input)?;
let (input, _) = alt((tag(",N"), tag(",S")))(input)?;
let sign = if input.starts_with(",N") { 1.0 } else { -1.0 };
Ok((input, sign * degrees))
}
fn parse_longitude(input: &str) -> IResult<&str, f64> {
let (input, degrees) = map_res(pair(take_until(",N"), opt(preceded(char('.'), digit1))), |(a, b): (&str, Option<&str>)| format!("{}{}", a, b.unwrap_or("0")).parse::<f64>())(input)?;
let (input, _) = alt((tag(",E"), tag(",W")))(input)?;
let sign = if input.starts_with(",E") { 1.0 } else { -1.0 };
Ok((input, sign * degrees))
}
fn parse_fix_quality(input: &str) -> IResult<&str, u8> {
let (input, quality) = map_res(digit1, |s: &str| s.parse::<u8>())(input)?;
Ok((input, quality))
}
fn parse_num_satellites(input: &str) -> IResult<&str, u8> {
let (input, num) = map_res(digit1, |s: &str| s.parse::<u8>())(input)?;
Ok((input, num))
}
fn parse_altitude(input: &str) -> IResult<&str, f64> {
let (input, altitude) = map_res(digit1, |s: &str| s.parse::<f64>())(input)?;
let (input, _) = tag("M")(input)?;
Ok((input, altitude))
}
fn parse_geoid_height(input: &str) -> IResult<&str, f64> {
let (input, geoid_height) = map_res(digit1, |s: &str| s.parse::<f64>())(input)?;
let (input, _) = tag("M")(input)?;
Ok((input, geoid_height))
}
Here, we define the GpggaData
struct to represent the data contained in the $GPGGA
sentence. We then define parsers for each of the fields in the sentence, using the map_res
combinator to parse the fields into their appropriate data types.
Note that the parse_latitude
and parse_longitude
functions are a bit more complex, as they involve parsing the degree-minute-second format used in NMEA sentences. We use the pair
and opt
combinators to parse the degrees and optional minutes/seconds, then combine them into a single floating-point value. We also parse the latitude/longitude hemisphere indicator to determine the sign of the final value.
Step 4: Parse NMEA sentences
Now that we have parsers for individual NMEA sentence types, let’s combine them to parse full NMEA sentences.
fn parse_gpgga_sentence(input: &str) -> IResult<&str, GpggaData> {
let (input, _) = tag("$GPGGA,")(input)?;
let (input, time) = map_res(take_until(","), str::parse::<String>)(input)?;
let (input, _) = tag(",")(input)?;
let (input, latitude) = parse_latitude(input)?;
let (input, _) = tag(",")(input)?;
let (input, longitude) = parse_longitude(input)?;
let (input, _) = tag(",")(input)?;
let (input, fix_quality) = parse_fix_quality(input)?;
let (input, _) = tag(",")(input)?;
let (input, num_satellites) = parse_num_satellites(input)?;
let (input, _) = tag(",")(input)?;
let (input, altitude) = parse_altitude(input)?;
let (input, _) = tag(",")(input)?;
let (input, geoid_height) = parse_geoid_height(input)?;
let (input, _) = tag(",")(input)?;
let _ = take_until("*")(input)?;
let (input, _) = tag("*")(input)?;
let (input, _) = take_until("\r\n")(input)?;
let (input, _) = tag("\r\n")(input)?;
Ok((input, GpggaData { time, latitude, longitude, fix_quality, num_satellites, altitude, geoid_height }))
}
Here, we define the parse_gpgga_sentence
function to parse a $GPGGA
sentence. We use the tag
combinator to match the initial "$GPGGA," characters, then call the individual parsers for each field in the sentence. We use take_until
to match any characters up to the asterisk that separates the sentence data from the checksum, then match the asterisk and checksum with tag("*")
and take_until("\r\n")
. Finally, we match the carriage return/newline characters that terminate the sentence with tag("\r\n")
.
Step 5: Putting it all together
Now that we have our parsers defined, let’s create a simple program to use them. Here’s an example program that reads NMEA sentences from a file and prints the parsed data to the console:
use std::fs::File;
use std::io::{BufRead, BufReader};
fn main() -> std::io::Result<()> {
let file = File::open("gpsdata.txt")?;
let reader = BufReader::new(file);
for line in reader.lines() {
let sentence = line.unwrap();
if sentence.starts_with("$GPGGA") {
match parse_gpgga_sentence(&sentence) {
Ok((_, data)) => println!("{:?}", data),
Err(err) => println!("Error parsing {}: {:?}", sentence, err),
}
}
}
Ok(())
}
Here, we open a file containing NMEA sentences and read each line using a BufReader
. We check if each line starts with the "$GPGGA" sentence identifier, and if so, we attempt to parse it using the parse_gpgga_sentence
function we defined earlier. If parsing is successful, we print the parsed data to the console; otherwise, we print an error message.
Conclusion
In this tutorial, we’ve explored how to use the Nom parsing library to implement a parser for NMEA sentences used in GPS communication. We’ve defined parsers for individual sentence fields, combined them to parse full NMEA sentences, and used our parsers to extract data from a file containing GPS data.
Nom provides a powerful and flexible way to define parsers in Rust, allowing you to quickly and easily parse complex data structures. With Nom, you can create robust and efficient parsers for any data format or protocol, making it a valuable tool in your Rust toolbox.
While this tutorial focused on parsing NMEA sentences, the techniques and concepts we’ve covered can be applied to any parsing problem in Rust. By breaking down your data into smaller, more manageable pieces and defining parsers for each of them, you can quickly and easily parse complex data structures and extract the information you need.
Happy parsing!