From 7ceebbc7b9a5994f9ff77a5c26d2b17708b15374 Mon Sep 17 00:00:00 2001 From: Mikhail Paulyshka Date: Thu, 14 May 2026 19:57:07 +0300 Subject: [PATCH 1/4] gps: Validate NMEA sentence checksum format NMEA sentences use a two-digit hexadecimal checksum. --- host/lib/usrp/gps_ctrl.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/host/lib/usrp/gps_ctrl.cpp b/host/lib/usrp/gps_ctrl.cpp index af8cd1a3c1..45ed5661c7 100644 --- a/host/lib/usrp/gps_ctrl.cpp +++ b/host/lib/usrp/gps_ctrl.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -111,8 +112,12 @@ class gps_ctrl_impl : public gps_ctrl if (nmea.length() < 5 || nmea[0] != '$' || nmea[nmea.length() - 3] != '*') return false; + if (!std::isxdigit(static_cast(nmea[nmea.length() - 2])) + || !std::isxdigit(static_cast(nmea[nmea.length() - 1]))) + return false; + std::stringstream ss; - uint32_t string_crc; + uint32_t string_crc = 0; uint32_t calculated_crc = 0; // get crc from string From 616bafe3a5ac3b18770eab1f303e2a634c7fe020 Mon Sep 17 00:00:00 2001 From: Mikhail Paulyshka Date: Thu, 14 May 2026 20:00:44 +0300 Subject: [PATCH 2/4] gps: Prepare NMEA replies by stripping line endings The `_recv()` function may return NMEA sentences with trailing carriage return and newline characters. --- host/lib/usrp/gps_ctrl.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/host/lib/usrp/gps_ctrl.cpp b/host/lib/usrp/gps_ctrl.cpp index 45ed5661c7..718320c686 100644 --- a/host/lib/usrp/gps_ctrl.cpp +++ b/host/lib/usrp/gps_ctrl.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -223,7 +224,10 @@ class gps_ctrl_impl : public gps_ctrl const boost::system_time comm_timeout = boost::get_system_time() + milliseconds(650); while (boost::get_system_time() < comm_timeout) { - reply = _recv(); + reply = _recv(); + std::string nmea_reply = reply; + erase_all(nmea_reply, "\r"); + erase_all(nmea_reply, "\n"); // known devices are JL "FireFly", "GPSTCXO", and "LC_XO" if (reply.find("FireFly") != std::string::npos or reply.find("LC_XO") != std::string::npos From ab30b1bc3ae4185597ae1220a5531bac4242d5e0 Mon Sep 17 00:00:00 2001 From: Mikhail Paulyshka Date: Thu, 14 May 2026 20:01:31 +0300 Subject: [PATCH 3/4] gps: Make NMEA parsing talker ID agnostic Modern GNSS receivers emit standard NMEA sentences (RMC, GGA, etc.) under multiple talker IDs (GP, GL, GA, etc.). Normalizes these sentences to their three-letter format, allowing the GPS logic to remain compatible with various multi-constellation receivers. --- host/lib/usrp/gps_ctrl.cpp | 64 +++++++++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 11 deletions(-) diff --git a/host/lib/usrp/gps_ctrl.cpp b/host/lib/usrp/gps_ctrl.cpp index 718320c686..f454a30234 100644 --- a/host/lib/usrp/gps_ctrl.cpp +++ b/host/lib/usrp/gps_ctrl.cpp @@ -133,6 +133,41 @@ class gps_ctrl_impl : public gps_ctrl return (string_crc == calculated_crc); } + // Modern GNSS receivers emit standard NMEA sentence formatters + // (RMC, GGA, etc.) under multiple talker IDs (GP, GL, GA, etc). + // Normalize these sentences to the three-letter formatter so + // the rest of the GPS logic remains talker-agnostic + static std::string get_nmea_sentence_type(const std::string& nmea) + { + static constexpr size_t nmea_sentence_id_len = 5; + static constexpr size_t nmea_formatter_len = 3; + + if (nmea.length() < 10 || nmea[0] != '$' || nmea[1] == 'P') { + return ""; + } + + const size_t checksum_pos = nmea.rfind('*'); + if (checksum_pos == std::string::npos || checksum_pos + 3 != nmea.length()) { + return ""; + } + + if (nmea[1 + nmea_sentence_id_len] != ',' + || !std::isxdigit(static_cast(nmea[checksum_pos + 1])) + || !std::isxdigit(static_cast(nmea[checksum_pos + 2]))) { + return ""; + } + + const std::string sentence_id = + boost::to_upper_copy(nmea.substr(1, nmea_sentence_id_len)); + if (!std::all_of(sentence_id.begin(), sentence_id.end(), [](const char c) { + return std::isalnum(static_cast(c)) != 0; + })) { + return ""; + } + + return sentence_id.substr(nmea_sentence_id_len - nmea_formatter_len); + } + // Read all outstanding messages and put them in the cache // // Outside of the ctor, this is the only function that actually reads @@ -145,7 +180,7 @@ class gps_ctrl_impl : public gps_ctrl // \param msg_key_hint If not empty, this will be used as the key for the // message in the cache. This is useful when we know that // a message is arriving that does not conform to the - // message patterns for a SERVO or GP* message. + // message patterns for a SERVO or standard NMEA message. void update_cache(const std::string& msg_key_hint = "") { if (not gps_detected()) { @@ -153,7 +188,6 @@ class gps_ctrl_impl : public gps_ctrl } static const std::regex servo_regex("^\\d\\d-\\d\\d-\\d\\d.*$"); - static const std::regex gp_msg_regex("^\\$GP.*,\\*[0-9A-F]{2}$"); std::map msgs; // Get all GPSDO messages available @@ -179,12 +213,19 @@ class gps_ctrl_impl : public gps_ctrl msg, servo_regex, std::regex_constants::match_continuous)) { UHD_LOG_TRACE("GPS", "Received new SERVO message: " << msg); msgs["SERVO"] = msg; - } else if (std::regex_match(msg, gp_msg_regex) and is_nmea_checksum_ok(msg)) { - UHD_LOG_TRACE( - "GPS", "Received new " << msg.substr(1, 5) << " message: " << msg); - msgs[msg.substr(1, 5)] = msg; } else { - if (!msg_key_hint.empty()) { + const std::string sentence_type = get_nmea_sentence_type(msg); + if (!sentence_type.empty()) { + if (!is_nmea_checksum_ok(msg)) { + UHD_LOGGER_WARNING("GPS") << "Invalid NMEA string: " << msg; + } else { + UHD_LOG_TRACE("GPS", + "Received new " << sentence_type << " message: " << msg); + if (sentence_type == "GGA" || sentence_type == "RMC") { + msgs[sentence_type] = msg; + } + } + } else if (!msg_key_hint.empty()) { UHD_LOG_DEBUG( "GPS", "Received " << msg_key_hint << " message: " << msg); msgs[msg_key_hint] = msg; @@ -234,7 +275,8 @@ class gps_ctrl_impl : public gps_ctrl or reply.find("GPSTCXO") != std::string::npos) { _gps_type = GPS_TYPE_INTERNAL_GPSDO; break; - } else if (reply.substr(0, 3) == "$GP") { + } else if (!get_nmea_sentence_type(nmea_reply).empty() + && is_nmea_checksum_ok(nmea_reply)) { i_heard_some_nmea = true; // but keep looking } else if (not reply.empty()) { // wrong baud rate or firmware still initializing @@ -292,7 +334,7 @@ class gps_ctrl_impl : public gps_ctrl { if (key == "gps_gpgga" or key == "gps_gprmc") { return sensor_value_t(boost::to_upper_copy(key), - get_sentence(boost::to_upper_copy(key.substr(4, 8)), + get_sentence(key == "gps_gpgga" ? "GGA" : "RMC", GPS_NMEA_NORMAL_FRESHNESS, GPS_TIMEOUT_DELAY_MS), ""); @@ -381,7 +423,7 @@ class gps_ctrl_impl : public gps_ctrl try { // wait for next GPRMC string std::string reply = get_sentence( - "GPRMC", GPS_NMEA_NORMAL_FRESHNESS, GPS_COMM_TIMEOUT_MS, true); + "RMC", GPS_NMEA_NORMAL_FRESHNESS, GPS_COMM_TIMEOUT_MS, true); std::string datestr = get_token(reply, 9); std::string timestr = get_token(reply, 1); @@ -432,7 +474,7 @@ class gps_ctrl_impl : public gps_ctrl while (error_cnt < 3) { try { std::string reply = - get_sentence("GPGGA", GPS_LOCK_FRESHNESS, GPS_COMM_TIMEOUT_MS); + get_sentence("GGA", GPS_LOCK_FRESHNESS, GPS_COMM_TIMEOUT_MS); if (reply.empty()) error_cnt++; else From a4ec558d7cc76567e9d7f721e813ae50b283217f Mon Sep 17 00:00:00 2001 From: Mikhail Paulyshka Date: Thu, 14 May 2026 20:01:58 +0300 Subject: [PATCH 4/4] gps: Add NMEA parsing unit tests --- host/tests/CMakeLists.txt | 6 ++ host/tests/gps_ctrl_test.cpp | 201 +++++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 host/tests/gps_ctrl_test.cpp diff --git a/host/tests/CMakeLists.txt b/host/tests/CMakeLists.txt index fc0a81427c..c0a499b674 100644 --- a/host/tests/CMakeLists.txt +++ b/host/tests/CMakeLists.txt @@ -297,6 +297,12 @@ if(ENABLE_DPDK) target_compile_options(dpdk_port_test PRIVATE ${DPDK_CFLAGS}) ENDIF(ENABLE_DPDK) +UHD_ADD_NONAPI_TEST( + TARGET "gps_ctrl_test.cpp" + EXTRA_SOURCES + ${UHD_SOURCE_DIR}/lib/usrp/gps_ctrl.cpp +) + UHD_ADD_NONAPI_TEST( TARGET "system_time_test.cpp" EXTRA_SOURCES diff --git a/host/tests/gps_ctrl_test.cpp b/host/tests/gps_ctrl_test.cpp new file mode 100644 index 0000000000..8e46c519df --- /dev/null +++ b/host/tests/gps_ctrl_test.cpp @@ -0,0 +1,201 @@ +// +// Copyright 2026 Ettus Research, a National Instruments Brand +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +const std::string GGA_MULTI = + "$GNGGA,125740.00,4852.60000,S,12323.60000,W,2,12,1.28,237.1,M,25.1,M,,*48"; +const std::string GGA_GPS = + "$GPGGA,125740.00,4852.60000,S,12323.60000,W,2,12,1.28,237.1,M,25.1,M,,*56"; + +const std::string GSV_GPS = + "$GPGSV,3,1,12,02,23,273,24,10,70,166,46,13,04,014,25,16,23,211,40,1*67"; +const std::string GSV_GLONASS = + "$GLGSV,1,1,04,65,45,123,40,66,30,234,35,67,20,345,30,68,10,056,25*6E"; +const std::string GSV_GALILEO = + "$GAGSV,2,1,05,02,42,279,22,04,22,112,36,06,12,135,33,25,30,211,35,7*7C"; +const std::string GSV_BEIDOU = + "$GBGSV,2,1,05,02,13,116,42,19,52,119,45,29,18,114,43,35,75,126,45,1*76"; + +const std::string RMC_GPS = + "$GPRMC,125740.00,A,4852.60000,S,12323.60000,W,0.091,,100426,,,D,V*06"; +const std::string RMC_MULTI = + "$GNRMC,125740.00,A,4852.60000,S,12323.60000,W,0.091,,100426,,,D,V*18"; + +const std::string BAD_NOISE = "noise from another protocol"; +const std::string BAD_CRCBAD = + "$GNRMC,125740.00,A,4852.60000,S,12323.60000,W,0.091,,100426,,,D,V*FF"; +const std::string BAD_CRCMISSING = + "$GNGGA,125740.00,4852.60000,S,12323.60000,W,2,12,1.28,237.1,M,25.1,M,,"; +const std::string BAD_TRUNCATED = "$GN"; + +class mock_uart_iface : public uhd::uart_iface +{ +public: + mock_uart_iface( + std::string detection_sentence, std::vector buffered_sentences) + : _detection_sentence(std::move(detection_sentence)) + , _buffered_sentences(std::move(buffered_sentences)) + { + } + + void write_uart(const std::string& buf) override + { + _writes.push_back(buf); + } + + std::string read_uart(double timeout) override + { + if (timeout <= 0.0) { + if (_writes.empty()) { + return {}; + } + + if (_buffered_index < _buffered_sentences.size()) { + return _buffered_sentences.at(_buffered_index++); + } + + return {}; + } + + if (!_returned_detection_sentence) { + _returned_detection_sentence = true; + return _detection_sentence; + } + + return {}; + } + +private: + std::string _detection_sentence; + std::vector _buffered_sentences; + std::vector _writes; + size_t _buffered_index = 0; + bool _returned_detection_sentence = false; +}; + +struct captured_log_state +{ + std::mutex mutex; + std::vector entries; +}; + +captured_log_state& get_captured_log_state() +{ + static captured_log_state state; + return state; +} + +void ensure_warning_logger() +{ + static bool logger_installed = false; + + if (!logger_installed) { + uhd::log::add_logger( + "gps_ctrl_test_logger", [](const uhd::log::logging_info& info) { + auto& state = get_captured_log_state(); + std::lock_guard lock(state.mutex); + state.entries.push_back(info); + }); + uhd::log::set_logger_level("gps_ctrl_test_logger", uhd::log::warning); + logger_installed = true; + } +} + +void clear_captured_logs() +{ + auto& state = get_captured_log_state(); + std::lock_guard lock(state.mutex); + state.entries.clear(); +} + +std::vector get_gps_warning_messages() +{ + auto& state = get_captured_log_state(); + std::lock_guard lock(state.mutex); + std::vector messages; + for (const auto& entry : state.entries) { + if (entry.component == "GPS" && entry.verbosity == uhd::log::warning) { + messages.push_back(entry.message); + } + } + return messages; +} + +} // namespace + + +BOOST_AUTO_TEST_CASE(test_nmea_legacy) +{ + auto uart = std::make_shared(RMC_GPS, + std::vector{ + GGA_GPS, + RMC_GPS, + }); + + auto gps = uhd::gps_ctrl::make(uart); + + BOOST_CHECK(gps->gps_detected()); + BOOST_CHECK_EQUAL(gps->get_sensor("gps_gprmc").value, RMC_GPS); + BOOST_CHECK_EQUAL(gps->get_sensor("gps_gpgga").value, GGA_GPS); + BOOST_CHECK(gps->get_sensor("gps_locked").to_bool()); +} + +BOOST_AUTO_TEST_CASE(test_nmea_malformed_messages) +{ + auto uart = std::make_shared(RMC_MULTI, + std::vector{ + BAD_NOISE, + BAD_CRCBAD, + BAD_CRCMISSING, + BAD_TRUNCATED, + GGA_MULTI, + RMC_MULTI, + }); + + auto gps = uhd::gps_ctrl::make(uart); + + BOOST_CHECK(gps->gps_detected()); + + BOOST_CHECK_EQUAL(gps->get_sensor("gps_gprmc").value, RMC_MULTI); + BOOST_CHECK_EQUAL(gps->get_sensor("gps_gpgga").value, GGA_MULTI); +} + +BOOST_AUTO_TEST_CASE(test_nmea_multi_messages) +{ + ensure_warning_logger(); + clear_captured_logs(); + + auto uart = std::make_shared(RMC_MULTI, + std::vector{ + GSV_GPS, + GSV_GLONASS, + GSV_GALILEO, + GSV_BEIDOU, + GGA_MULTI, + RMC_MULTI, + }); + + auto gps = uhd::gps_ctrl::make(uart); + + BOOST_CHECK(gps->gps_detected()); + + BOOST_CHECK_EQUAL(gps->get_sensor("gps_gprmc").value, RMC_MULTI); + BOOST_CHECK_EQUAL(gps->get_sensor("gps_gpgga").value, GGA_MULTI); + BOOST_CHECK(gps->get_sensor("gps_locked").to_bool()); + BOOST_CHECK(get_gps_warning_messages().empty()); +}