This commit is contained in:
geoffsee
2025-07-20 11:05:22 -04:00
commit f4272d9c1b
5 changed files with 802 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/gnss-reader.iml
/.idea/
/target/

217
Cargo.lock generated Normal file
View File

@@ -0,0 +1,217 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
[[package]]
name = "cfg-if"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "gnss-reader"
version = "0.1.0"
dependencies = [
"serialport",
]
[[package]]
name = "io-kit-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
dependencies = [
"core-foundation-sys",
"mach2",
]
[[package]]
name = "libc"
version = "0.2.174"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
[[package]]
name = "libudev"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0"
dependencies = [
"libc",
"libudev-sys",
]
[[package]]
name = "libudev-sys"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]]
name = "nix"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
dependencies = [
"bitflags 1.3.2",
"cfg-if",
"libc",
]
[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "proc-macro2"
version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serialport"
version = "4.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb0bc984f6af6ef8bab54e6cf2071579ee75b9286aa9f2319a0d220c28b0a2b"
dependencies = [
"bitflags 2.9.1",
"cfg-if",
"core-foundation",
"core-foundation-sys",
"io-kit-sys",
"libudev",
"mach2",
"nix",
"scopeguard",
"unescaper",
"winapi",
]
[[package]]
name = "syn"
version = "2.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "unescaper"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c01d12e3a56a4432a8b436f293c25f4808bdf9e9f9f98f9260bba1f1bc5a1f26"
dependencies = [
"thiserror",
]
[[package]]
name = "unicode-ident"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"

7
Cargo.toml Normal file
View File

@@ -0,0 +1,7 @@
[package]
name = "gnss-reader"
version = "0.1.0"
edition = "2024"
[dependencies]
serialport = "4.2"

115
README.md Normal file
View File

@@ -0,0 +1,115 @@
# GNSS Reader
A Rust-based GNSS (Global Navigation Satellite System) data parser that reads GPS location data from serial devices and converts NMEA sentences into structured, usable location information.
## Features
- **NMEA Sentence Parsing**: Supports GPGGA and GPRMC sentence formats
- **Live GPS Reading**: Real-time data streaming from USB GPS devices
- **Demo Mode**: Built-in demonstration with sample GPS data
- **Cross-Platform**: Works on macOS, Linux, and Windows
- **Structured Output**: Converts raw NMEA data into organized LocationData structures
- **Comprehensive Testing**: Includes extensive test coverage for various GPS scenarios
## Requirements
- Rust 2024 edition or later
- USB GPS device (tested with Stratux GPYes 2.0 u-blox 8)
- Serial port access permissions
## Installation
1. Clone the repository:
```bash
git clone <repository-url>
cd gpyes-stream
```
2. Build the project:
```bash
cargo build --release
```
3. Run the application:
```bash
cargo run
```
## Usage
The application automatically detects and connects to your GPS device. On startup, it will:
1. Attempt to connect to the GPS device via serial port
2. Parse incoming NMEA sentences (GPGGA and GPRMC formats)
3. Display structured location data including:
- Latitude and Longitude coordinates
- Altitude information
- GPS fix quality and satellite count
- Speed and course data
- UTC timestamp
### Demo Mode
To run the application with sample data without a physical GPS device:
```bash
cargo run -- --demo
```
## Supported Hardware
### Tested Devices
- **Stratux GPYes 2.0 u-blox 8 USB GPS Unit** (~$20)
- Amazon: https://www.amazon.com/Stratux-GPYes-2-0-u-blox-unit/dp/B0716BK5NT
- Device paths on macOS: `/dev/tty.usbmodem2101` or `/dev/cu.usbmodem2101`
### Compatibility
The application should work with any USB GPS device that outputs standard NMEA sentences via serial communication.
## NMEA Sentence Support
Currently supports the following NMEA sentence types:
- **GPGGA**: Global Positioning System Fix Data
- **GPRMC**: Recommended Minimum Course
- **GNGGA**: GNSS Fix Data (multi-constellation)
- **GNRMC**: GNSS Recommended Minimum Course
## Development
### Running Tests
```bash
cargo test
```
### Building for Release
```bash
cargo build --release
```
The compiled binary will be available in `target/release/gnss-reader`.
## Contributing
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## License
This project is open source. Please refer to the LICENSE file for details.
## Troubleshooting
### Device Not Found
- Ensure your GPS device is properly connected
- Check device permissions (you may need to add your user to the `dialout` group on Linux)
- Verify the device path matches your system configuration
### No GPS Signal
- Ensure you have a clear view of the sky
- Wait for the GPS device to acquire satellite lock (may take several minutes on first use)
- Check that your GPS device is functioning properly

460
src/main.rs Normal file
View File

@@ -0,0 +1,460 @@
#[derive(Debug, Clone, PartialEq)]
pub struct LocationData {
pub latitude: Option<f64>,
pub longitude: Option<f64>,
pub altitude: Option<f64>,
pub speed: Option<f64>,
pub timestamp: Option<String>,
pub fix_quality: Option<u8>,
pub satellites: Option<u8>,
}
impl Default for LocationData {
fn default() -> Self {
LocationData {
latitude: None,
longitude: None,
altitude: None,
speed: None,
timestamp: None,
fix_quality: None,
satellites: None,
}
}
}
pub struct GnssParser;
impl GnssParser {
pub fn new() -> Self {
GnssParser
}
pub fn parse_sentence(&self, sentence: &str) -> Option<LocationData> {
if sentence.is_empty() || !sentence.starts_with('$') {
return None;
}
let parts: Vec<&str> = sentence.split(',').collect();
if parts.is_empty() {
return None;
}
let sentence_type = parts[0];
match sentence_type {
"$GPGGA" | "$GNGGA" => self.parse_gpgga(&parts),
"$GPRMC" | "$GNRMC" => self.parse_gprmc(&parts),
_ => None,
}
}
fn parse_gpgga(&self, parts: &[&str]) -> Option<LocationData> {
if parts.len() < 15 {
return None;
}
let mut location = LocationData::default();
// Parse timestamp (field 1)
if !parts[1].is_empty() {
location.timestamp = Some(parts[1].to_string());
}
// Parse latitude (fields 2 and 3)
if !parts[2].is_empty() && !parts[3].is_empty() {
if let Ok(lat_raw) = parts[2].parse::<f64>() {
let degrees = (lat_raw / 100.0).floor();
let minutes = lat_raw - (degrees * 100.0);
let mut latitude = degrees + (minutes / 60.0);
if parts[3] == "S" {
latitude = -latitude;
}
location.latitude = Some(latitude);
}
}
// Parse longitude (fields 4 and 5)
if !parts[4].is_empty() && !parts[5].is_empty() {
if let Ok(lon_raw) = parts[4].parse::<f64>() {
let degrees = (lon_raw / 100.0).floor();
let minutes = lon_raw - (degrees * 100.0);
let mut longitude = degrees + (minutes / 60.0);
if parts[5] == "W" {
longitude = -longitude;
}
location.longitude = Some(longitude);
}
}
// Parse fix quality (field 6)
if !parts[6].is_empty() {
if let Ok(quality) = parts[6].parse::<u8>() {
location.fix_quality = Some(quality);
}
}
// Parse number of satellites (field 7)
if !parts[7].is_empty() {
if let Ok(sats) = parts[7].parse::<u8>() {
location.satellites = Some(sats);
}
}
// Parse altitude (field 9)
if !parts[9].is_empty() {
if let Ok(alt) = parts[9].parse::<f64>() {
location.altitude = Some(alt);
}
}
Some(location)
}
fn parse_gprmc(&self, parts: &[&str]) -> Option<LocationData> {
if parts.len() < 12 {
return None;
}
let mut location = LocationData::default();
// Parse timestamp (field 1)
if !parts[1].is_empty() {
location.timestamp = Some(parts[1].to_string());
}
// Check if data is valid (field 2)
if parts[2] != "A" {
return None; // Invalid data
}
// Parse latitude (fields 3 and 4)
if !parts[3].is_empty() && !parts[4].is_empty() {
if let Ok(lat_raw) = parts[3].parse::<f64>() {
let degrees = (lat_raw / 100.0).floor();
let minutes = lat_raw - (degrees * 100.0);
let mut latitude = degrees + (minutes / 60.0);
if parts[4] == "S" {
latitude = -latitude;
}
location.latitude = Some(latitude);
}
}
// Parse longitude (fields 5 and 6)
if !parts[5].is_empty() && !parts[6].is_empty() {
if let Ok(lon_raw) = parts[5].parse::<f64>() {
let degrees = (lon_raw / 100.0).floor();
let minutes = lon_raw - (degrees * 100.0);
let mut longitude = degrees + (minutes / 60.0);
if parts[6] == "W" {
longitude = -longitude;
}
location.longitude = Some(longitude);
}
}
// Parse speed (field 7) - in knots
if !parts[7].is_empty() {
if let Ok(speed_knots) = parts[7].parse::<f64>() {
location.speed = Some(speed_knots);
}
}
Some(location)
}
}
use std::env;
use std::io::{BufRead, BufReader, ErrorKind};
use std::time::Duration;
fn main() {
println!("GNSS Reader - Parsing GNSS sentences into usable location data");
let args: Vec<String> = env::args().collect();
let parser = GnssParser::new();
if args.len() > 1 && args[1] == "--live" {
// Try to connect to live GPS device
run_live_gps(&parser);
} else {
// Run demonstration mode
run_demo(&parser);
}
}
fn run_live_gps(parser: &GnssParser) {
println!("Attempting to connect to GPS device...");
let device_paths = ["/dev/tty.usbmodem2101", "/dev/cu.usbmodem2101"];
for device_path in &device_paths {
println!("Trying to connect to: {}", device_path);
match serialport::new(*device_path, 9600)
.timeout(Duration::from_millis(1000))
.open()
{
Ok(mut port) => {
println!("Successfully connected to GPS device at {}", device_path);
println!("Reading NMEA sentences... (Press Ctrl+C to stop)");
println!();
let mut reader = BufReader::new(port.as_mut());
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line) {
Ok(0) => break, // EOF
Ok(_) => {
let sentence = line.trim();
if !sentence.is_empty() {
println!("Raw: {}", sentence);
if let Some(location) = parser.parse_sentence(sentence) {
print_location_data(&location);
println!();
}
}
}
Err(e) => {
match e.kind() {
ErrorKind::TimedOut => {
// Handle timeout gracefully - just continue reading
println!("Read timeout - continuing to listen for GPS data...");
continue;
}
ErrorKind::Interrupted => {
// Handle interrupted system call - continue reading
continue;
}
_ => {
eprintln!("Error reading from GPS device: {}", e);
break;
}
}
}
}
}
return;
}
Err(e) => {
println!("Failed to connect to {}: {}", device_path, e);
}
}
}
println!("Could not connect to any GPS device.");
println!("Make sure the GPS device is connected and available at:");
for path in &device_paths {
println!(" {}", path);
}
println!();
println!("Running demonstration mode instead...");
println!();
let demo_parser = GnssParser::new();
run_demo(&demo_parser);
}
fn run_demo(parser: &GnssParser) {
println!("GPS sensor should be available at:");
println!(" tty.usbmodem2101");
println!(" cu.usbmodem2101");
println!();
println!("Run with --live flag to connect to actual GPS device");
println!();
// Demonstrate parsing with example GNSS sentences
println!("Demonstrating GNSS sentence parsing:");
println!();
// Example GPGGA sentence
let gpgga_sentence = "$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47";
println!("Parsing GPGGA sentence:");
println!(" {}", gpgga_sentence);
if let Some(location) = parser.parse_sentence(gpgga_sentence) {
print_location_data(&location);
}
println!();
// Example GPRMC sentence
let gprmc_sentence = "$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A";
println!("Parsing GPRMC sentence:");
println!(" {}", gprmc_sentence);
if let Some(location) = parser.parse_sentence(gprmc_sentence) {
print_location_data(&location);
}
println!();
println!("To connect to a real GPS device, run:");
println!(" cargo run -- --live");
}
fn print_location_data(location: &LocationData) {
println!(" Parsed location data:");
if let Some(lat) = location.latitude {
println!(" Latitude: {:.6}°", lat);
}
if let Some(lon) = location.longitude {
println!(" Longitude: {:.6}°", lon);
}
if let Some(alt) = location.altitude {
println!(" Altitude: {:.1} m", alt);
}
if let Some(speed) = location.speed {
println!(" Speed: {:.1} knots", speed);
}
if let Some(quality) = location.fix_quality {
println!(" Fix Quality: {}", quality);
}
if let Some(sats) = location.satellites {
println!(" Satellites: {}", sats);
}
if let Some(time) = &location.timestamp {
println!(" Timestamp: {}", time);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parser_creation() {
let parser = GnssParser::new();
// Parser should be created successfully
}
#[test]
fn test_parse_gpgga_sentence() {
let parser = GnssParser::new();
// Example GPGGA sentence: Global Positioning System Fix Data
let sentence = "$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47";
let result = parser.parse_sentence(sentence);
assert!(result.is_some());
let location = result.unwrap();
assert!(location.latitude.is_some());
assert!(location.longitude.is_some());
assert!(location.altitude.is_some());
assert!(location.fix_quality.is_some());
assert!(location.satellites.is_some());
// Check specific values
assert!((location.latitude.unwrap() - 48.1173).abs() < 0.001); // 4807.038N
assert!((location.longitude.unwrap() - 11.5167).abs() < 0.001); // 01131.000E
assert!((location.altitude.unwrap() - 545.4).abs() < 0.1);
assert_eq!(location.fix_quality.unwrap(), 1);
assert_eq!(location.satellites.unwrap(), 8);
}
#[test]
fn test_parse_gprmc_sentence() {
let parser = GnssParser::new();
// Example GPRMC sentence: Recommended Minimum Navigation Information
let sentence = "$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A";
let result = parser.parse_sentence(sentence);
assert!(result.is_some());
let location = result.unwrap();
assert!(location.latitude.is_some());
assert!(location.longitude.is_some());
assert!(location.speed.is_some());
assert!(location.timestamp.is_some());
// Check specific values
assert!((location.latitude.unwrap() - 48.1173).abs() < 0.001);
assert!((location.longitude.unwrap() - 11.5167).abs() < 0.001);
assert!((location.speed.unwrap() - 22.4).abs() < 0.1);
}
#[test]
fn test_parse_invalid_sentence() {
let parser = GnssParser::new();
let invalid_sentence = "invalid sentence";
let result = parser.parse_sentence(invalid_sentence);
assert!(result.is_none());
}
#[test]
fn test_parse_empty_sentence() {
let parser = GnssParser::new();
let result = parser.parse_sentence("");
assert!(result.is_none());
}
#[test]
fn test_parse_gngga_sentence() {
let parser = GnssParser::new();
// Example GNGGA sentence (modern GNSS format)
let sentence = "$GNGGA,144751.00,3708.15162,N,07621.52868,W,1,06,1.39,-14.3,M,-35.8,M,,*69";
let result = parser.parse_sentence(sentence);
assert!(result.is_some());
let location = result.unwrap();
assert!(location.latitude.is_some());
assert!(location.longitude.is_some());
assert!(location.altitude.is_some());
assert!(location.fix_quality.is_some());
assert!(location.satellites.is_some());
// Check specific values
assert!((location.latitude.unwrap() - 37.1359).abs() < 0.001); // 3708.15162N
assert!((location.longitude.unwrap() - (-76.3588)).abs() < 0.001); // 07621.52868W
assert!((location.altitude.unwrap() - (-14.3)).abs() < 0.1);
assert_eq!(location.fix_quality.unwrap(), 1);
assert_eq!(location.satellites.unwrap(), 6);
}
#[test]
fn test_parse_gnrmc_sentence() {
let parser = GnssParser::new();
// Example GNRMC sentence (modern GNSS format)
let sentence = "$GNRMC,144751.00,A,3708.15162,N,07621.52868,W,0.009,,200725,,,A,V*01";
let result = parser.parse_sentence(sentence);
assert!(result.is_some());
let location = result.unwrap();
assert!(location.latitude.is_some());
assert!(location.longitude.is_some());
assert!(location.speed.is_some());
assert!(location.timestamp.is_some());
// Check specific values
assert!((location.latitude.unwrap() - 37.1359).abs() < 0.001);
assert!((location.longitude.unwrap() - (-76.3588)).abs() < 0.001);
assert!((location.speed.unwrap() - 0.009).abs() < 0.001);
}
#[test]
fn test_location_data_default() {
let location = LocationData::default();
assert!(location.latitude.is_none());
assert!(location.longitude.is_none());
assert!(location.altitude.is_none());
assert!(location.speed.is_none());
assert!(location.timestamp.is_none());
assert!(location.fix_quality.is_none());
assert!(location.satellites.is_none());
}
}