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
77 changes: 64 additions & 13 deletions host/lib/usrp/gps_ctrl.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
#include <boost/date_time/posix_time/posix_time_types.hpp>
#include <boost/thread/thread_time.hpp>
#include <boost/tokenizer.hpp>
#include <algorithm>
#include <cctype>
#include <chrono>
#include <ctime>
#include <mutex>
Expand Down Expand Up @@ -111,8 +113,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<unsigned char>(nmea[nmea.length() - 2]))
|| !std::isxdigit(static_cast<unsigned char>(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
Expand All @@ -127,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<unsigned char>(nmea[checksum_pos + 1]))
|| !std::isxdigit(static_cast<unsigned char>(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<unsigned char>(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
Expand All @@ -139,15 +180,14 @@ 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()) {
return;
}

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<std::string, std::string> msgs;

// Get all GPSDO messages available
Expand All @@ -173,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;
Expand Down Expand Up @@ -218,14 +265,18 @@ 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
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
Expand Down Expand Up @@ -283,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),
"");
Expand Down Expand Up @@ -372,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);
Expand Down Expand Up @@ -423,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
Expand Down
6 changes: 6 additions & 0 deletions host/tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
201 changes: 201 additions & 0 deletions host/tests/gps_ctrl_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
//
// Copyright 2026 Ettus Research, a National Instruments Brand
//
// SPDX-License-Identifier: GPL-3.0-or-later
//

#include <uhd/types/serial.hpp>
#include <uhd/utils/log.hpp>
#include <uhd/utils/log_add.hpp>
#include <uhdlib/usrp/gps_ctrl.hpp>
#include <boost/test/unit_test.hpp>
#include <memory>
#include <mutex>
#include <string>
#include <utility>
#include <vector>

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<std::string> 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<std::string> _buffered_sentences;
std::vector<std::string> _writes;
size_t _buffered_index = 0;
bool _returned_detection_sentence = false;
};

struct captured_log_state
{
std::mutex mutex;
std::vector<uhd::log::logging_info> 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<std::mutex> 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<std::mutex> lock(state.mutex);
state.entries.clear();
}

std::vector<std::string> get_gps_warning_messages()
{
auto& state = get_captured_log_state();
std::lock_guard<std::mutex> lock(state.mutex);
std::vector<std::string> 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<mock_uart_iface>(RMC_GPS,
std::vector<std::string>{
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<mock_uart_iface>(RMC_MULTI,
std::vector<std::string>{
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<mock_uart_iface>(RMC_MULTI,
std::vector<std::string>{
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());
}
Loading