mirror of
https://github.com/gnss-sdr/gnss-sdr
synced 2026-08-27 20:48:53 +00:00
Fix defects found in a deep review of the RTK and NTRIP code
This commit is contained in:
@@ -26,6 +26,7 @@
|
||||
#include "gps_almanac.h" // for Gps_Almanac
|
||||
#include "gps_ephemeris.h" // for Gps_Ephemeris
|
||||
#include "gps_week_rollover.h" // for gps_ref_week_from_config
|
||||
#include "ntrip_rtcm_client.h" // for make_ntrip_rtcm_client_config, secure_clear_string
|
||||
#include "pvt_conf.h" // for Pvt_Conf
|
||||
#include "rtklib_rtkpos.h" // for rtkfree, rtkinit
|
||||
#include "signal_enabled_flags.h" // for signal_enabled_flags
|
||||
@@ -57,21 +58,6 @@ using namespace std::string_literals;
|
||||
|
||||
namespace
|
||||
{
|
||||
void secure_clear_string(std::string* value) noexcept
|
||||
{
|
||||
if (value->empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
volatile char* data = &(*value)[0];
|
||||
for (std::size_t index = 0; index < value->size(); ++index)
|
||||
{
|
||||
data[index] = 0;
|
||||
}
|
||||
value->clear();
|
||||
}
|
||||
|
||||
|
||||
class Ntrip_Credential_Guard
|
||||
{
|
||||
public:
|
||||
@@ -245,10 +231,6 @@ Rtklib_Pvt::Rtklib_Pvt(const ConfigurationInterface* configuration,
|
||||
pvt_output_parameters.ntrip_send_gga = configuration->property(role + ".ntrip_send_gga", pvt_output_parameters.ntrip_send_gga);
|
||||
pvt_output_parameters.ntrip_gga_period_ms = configuration->property(role + ".ntrip_gga_period_ms", pvt_output_parameters.ntrip_gga_period_ms);
|
||||
|
||||
if (pvt_output_parameters.ntrip_version != 1 && pvt_output_parameters.ntrip_version != 2)
|
||||
{
|
||||
throw std::invalid_argument(role + ".ntrip_version must be 1 or 2");
|
||||
}
|
||||
if (ntrip_port < 1 || ntrip_port > 65535)
|
||||
{
|
||||
throw std::invalid_argument(role + ".ntrip_caster_port must be in the range 1..65535");
|
||||
@@ -266,44 +248,6 @@ Rtklib_Pvt::Rtklib_Pvt(const ConfigurationInterface* configuration,
|
||||
return std::isspace(byte) != 0 || std::iscntrl(byte) != 0;
|
||||
});
|
||||
};
|
||||
const auto has_non_ascii_graphic = [](const std::string& value) {
|
||||
return std::any_of(value.cbegin(), value.cend(), [](char character) {
|
||||
const auto byte = static_cast<unsigned char>(character);
|
||||
return byte <= 0x20U || byte >= 0x7FU;
|
||||
});
|
||||
};
|
||||
if (pvt_output_parameters.ntrip_caster_address.empty() ||
|
||||
pvt_output_parameters.ntrip_caster_address.find("://") != std::string::npos ||
|
||||
pvt_output_parameters.ntrip_caster_address.find('@') != std::string::npos ||
|
||||
pvt_output_parameters.ntrip_caster_address.find('/') != std::string::npos ||
|
||||
pvt_output_parameters.ntrip_caster_address.find(':') != std::string::npos ||
|
||||
pvt_output_parameters.ntrip_caster_address.find('[') != std::string::npos ||
|
||||
pvt_output_parameters.ntrip_caster_address.find(']') != std::string::npos ||
|
||||
has_non_ascii_graphic(pvt_output_parameters.ntrip_caster_address))
|
||||
{
|
||||
throw std::invalid_argument(role + ".ntrip_caster_address must be a hostname or IPv4 address without a scheme, credentials, port, or path");
|
||||
}
|
||||
while (!pvt_output_parameters.ntrip_mountpoint.empty() && pvt_output_parameters.ntrip_mountpoint.front() == '/')
|
||||
{
|
||||
pvt_output_parameters.ntrip_mountpoint.erase(0, 1);
|
||||
}
|
||||
if (pvt_output_parameters.ntrip_mountpoint.empty() ||
|
||||
pvt_output_parameters.ntrip_mountpoint.size() >= 256 ||
|
||||
pvt_output_parameters.ntrip_mountpoint.find('@') != std::string::npos ||
|
||||
pvt_output_parameters.ntrip_mountpoint.find('/') != std::string::npos ||
|
||||
pvt_output_parameters.ntrip_mountpoint.find(':') != std::string::npos ||
|
||||
has_non_ascii_graphic(pvt_output_parameters.ntrip_mountpoint))
|
||||
{
|
||||
throw std::invalid_argument(role + ".ntrip_mountpoint must name one caster mountpoint without a path separator");
|
||||
}
|
||||
if (pvt_output_parameters.ntrip_username.size() >= 256 ||
|
||||
pvt_output_parameters.ntrip_password.size() >= 256 ||
|
||||
pvt_output_parameters.ntrip_username.find(':') != std::string::npos ||
|
||||
has_space_or_control(pvt_output_parameters.ntrip_username) ||
|
||||
has_space_or_control(pvt_output_parameters.ntrip_password))
|
||||
{
|
||||
throw std::invalid_argument(role + ".ntrip_username or .ntrip_password contains characters unsupported by the NTRIP client");
|
||||
}
|
||||
if (!pvt_output_parameters.ntrip_password_env.empty())
|
||||
{
|
||||
if (!pvt_output_parameters.ntrip_password.empty())
|
||||
@@ -320,35 +264,16 @@ Rtklib_Pvt::Rtklib_Pvt(const ConfigurationInterface* configuration,
|
||||
throw std::invalid_argument(role + ".ntrip_password_env names an environment variable that is not set");
|
||||
}
|
||||
pvt_output_parameters.ntrip_password = password;
|
||||
if (pvt_output_parameters.ntrip_password.size() >= 256 ||
|
||||
has_space_or_control(pvt_output_parameters.ntrip_password))
|
||||
{
|
||||
throw std::invalid_argument("The NTRIP password environment variable is too long or contains unsupported control or whitespace characters");
|
||||
}
|
||||
}
|
||||
if (!pvt_output_parameters.ntrip_password.empty() && pvt_output_parameters.ntrip_username.empty())
|
||||
// the client library owns the configuration rules; the adapter
|
||||
// only turns the verdict into a constructor-time throw
|
||||
Ntrip_Rtcm_Client_Config ntrip_probe = make_ntrip_rtcm_client_config(pvt_output_parameters);
|
||||
const std::string ntrip_error = ntrip_probe.validate();
|
||||
secure_clear_string(&ntrip_probe.username);
|
||||
secure_clear_string(&ntrip_probe.password);
|
||||
if (!ntrip_error.empty())
|
||||
{
|
||||
throw std::invalid_argument(role + ".ntrip_username is required when a password is configured");
|
||||
}
|
||||
if (pvt_output_parameters.ntrip_caster_address.size() + pvt_output_parameters.ntrip_mountpoint.size() + 8 >= MAXSTRPATH)
|
||||
{
|
||||
throw std::invalid_argument("The configured NTRIP endpoint is too long");
|
||||
}
|
||||
constexpr int max_ntrip_interval_ms = 24 * 60 * 60 * 1000;
|
||||
if (pvt_output_parameters.ntrip_timeout_ms < 1000 || pvt_output_parameters.ntrip_timeout_ms > max_ntrip_interval_ms ||
|
||||
(pvt_output_parameters.ntrip_reconnect_interval_ms != 0 && pvt_output_parameters.ntrip_reconnect_interval_ms < 1000) ||
|
||||
pvt_output_parameters.ntrip_reconnect_interval_ms > max_ntrip_interval_ms)
|
||||
{
|
||||
throw std::invalid_argument("NTRIP timeout and reconnect intervals must be within 1000 ms and 24 hours; reconnect may also be zero");
|
||||
}
|
||||
if (pvt_output_parameters.ntrip_send_gga &&
|
||||
(pvt_output_parameters.ntrip_gga_period_ms < 1000 || pvt_output_parameters.ntrip_gga_period_ms > max_ntrip_interval_ms))
|
||||
{
|
||||
throw std::invalid_argument(role + ".ntrip_gga_period_ms must be within 1000 ms and 24 hours");
|
||||
}
|
||||
if (!std::isfinite(pvt_output_parameters.ntrip_max_correction_age_s) || pvt_output_parameters.ntrip_max_correction_age_s <= 0.0)
|
||||
{
|
||||
throw std::invalid_argument(role + ".ntrip_max_correction_age_s must be a finite positive value");
|
||||
throw std::invalid_argument(role + " NTRIP configuration: " + ntrip_error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ class Gps_Ephemeris;
|
||||
* .rtcm_MT1087_rate_ms - (.rtcm_MSM_rate_ms)
|
||||
* .rtcm_MT1097_rate_ms - (.rtcm_MSM_rate_ms)
|
||||
*
|
||||
* Fixed-base GPS L1/L2 RTK input through NTRIP (disabled by default):
|
||||
* Fixed-base RTK input through NTRIP (disabled by default):
|
||||
* .ntrip_client_enabled - (false)
|
||||
* .ntrip_caster_address - caster hostname or IPv4 address, without a scheme or port ("")
|
||||
* .ntrip_caster_port - (2101)
|
||||
@@ -99,11 +99,17 @@ class Gps_Ephemeris;
|
||||
* .ntrip_inactivity_timeout_ms - (10000)
|
||||
* .ntrip_reconnect_interval_ms - (10000)
|
||||
* .ntrip_max_correction_age_s - (5.0)
|
||||
* .ntrip_send_gga - upload the rover position as NMEA GGA, required by VRS/nearest-station casters (true)
|
||||
* .ntrip_gga_period_ms - period of the GGA upload (10000)
|
||||
* .ntrip_station_id - expected RTCM station ID, or zero to accept any stream station (0)
|
||||
* .ntrip_fallback_to_single - report SOLQ_SINGLE while fixed-base data is unavailable (true)
|
||||
* This milestone requires exactly GPS 1C and 2S channels, num_bands=2,
|
||||
* navigation_system=1, positioning_mode Static or Kinematic, RTCM 1005/1006,
|
||||
* and compatible GPS L1/L2 observation messages. VRS/GGA is not supported.
|
||||
* Supported rover channel sets, per system: GPS 1C alone, 1C+2S, or 1C+L5;
|
||||
* Galileo 1B alone or 1B+5X; BeiDou B1C alone; in any combination across
|
||||
* systems. num_bands is derived from the channel set, navigation_system must
|
||||
* match the enabled constellations, and positioning_mode must be Static or
|
||||
* Kinematic. The base must provide its position through RTCM 1005/1006 and
|
||||
* observations through legacy 1002/1004 or MSM messages; VRS/nearest-station
|
||||
* casters are supported through the periodic GGA upload.
|
||||
*
|
||||
* .kml_rate_ms - (1000)
|
||||
* .gpx_rate_ms - (1000)
|
||||
|
||||
@@ -672,36 +672,10 @@ rtklib_pvt_gs::rtklib_pvt_gs(uint32_t nchannels,
|
||||
|
||||
if (d_ntrip_client_enabled)
|
||||
{
|
||||
Ntrip_Rtcm_Client_Config ntrip_config;
|
||||
ntrip_config.enabled = true;
|
||||
ntrip_config.host = conf_.ntrip_caster_address;
|
||||
ntrip_config.port = conf_.ntrip_port;
|
||||
ntrip_config.mountpoint = conf_.ntrip_mountpoint;
|
||||
ntrip_config.username = conf_.ntrip_username;
|
||||
ntrip_config.password = conf_.ntrip_password;
|
||||
ntrip_config.reconnect_interval_ms = conf_.ntrip_reconnect_interval_ms;
|
||||
ntrip_config.timeout_ms = conf_.ntrip_timeout_ms;
|
||||
ntrip_config.max_age_s = conf_.ntrip_max_correction_age_s;
|
||||
ntrip_config.station_id = conf_.ntrip_station_id;
|
||||
ntrip_config.version = conf_.ntrip_version;
|
||||
ntrip_config.tls_enabled = conf_.ntrip_tls_enabled;
|
||||
ntrip_config.send_gga = conf_.ntrip_send_gga;
|
||||
ntrip_config.gga_period_ms = conf_.ntrip_gga_period_ms;
|
||||
Ntrip_Rtcm_Client_Config ntrip_config = make_ntrip_rtcm_client_config(conf_);
|
||||
const auto clear_local_credentials = [&ntrip_config]() {
|
||||
const auto clear_string = [](std::string* value) {
|
||||
if (value->empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
volatile char* data = &(*value)[0];
|
||||
for (std::size_t index = 0; index < value->size(); ++index)
|
||||
{
|
||||
data[index] = 0;
|
||||
}
|
||||
value->clear();
|
||||
};
|
||||
clear_string(&ntrip_config.username);
|
||||
clear_string(&ntrip_config.password);
|
||||
secure_clear_string(&ntrip_config.username);
|
||||
secure_clear_string(&ntrip_config.password);
|
||||
};
|
||||
bool ntrip_started = false;
|
||||
try
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#include <arpa/inet.h>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cctype>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <condition_variable>
|
||||
@@ -42,21 +43,9 @@ namespace
|
||||
constexpr std::size_t READ_BUFFER_SIZE = 8192;
|
||||
constexpr int WORKER_POLL_INTERVAL_MS = 10;
|
||||
constexpr int MAX_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
|
||||
void secure_clear(std::string* value)
|
||||
{
|
||||
if (value == nullptr || value->empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
volatile char* data = &(*value)[0];
|
||||
for (std::size_t i = 0; i < value->size(); ++i)
|
||||
{
|
||||
data[i] = 0;
|
||||
}
|
||||
value->clear();
|
||||
}
|
||||
// the interval floor rejects busy-loop configurations; zero keeps its
|
||||
// dedicated meaning (reconnects disabled)
|
||||
constexpr int MIN_INTERVAL_MS = 1000;
|
||||
|
||||
|
||||
bool valid_gtime(const gtime_t& time)
|
||||
@@ -403,6 +392,127 @@ void resolve_ipv4_hostname(const std::shared_ptr<Host_Resolution_State>& state,
|
||||
} // namespace
|
||||
|
||||
|
||||
void secure_clear_string(std::string* value) noexcept
|
||||
{
|
||||
if (value == nullptr || value->empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
volatile char* data = &(*value)[0];
|
||||
for (std::size_t i = 0; i < value->size(); ++i)
|
||||
{
|
||||
data[i] = 0;
|
||||
}
|
||||
value->clear();
|
||||
}
|
||||
|
||||
|
||||
std::string Ntrip_Rtcm_Client_Config::validate() const
|
||||
{
|
||||
const std::string clean_mountpoint = normalized_mountpoint(mountpoint);
|
||||
if (host.empty())
|
||||
{
|
||||
return "the caster address must be a non-empty hostname or IPv4 address";
|
||||
}
|
||||
for (const char character : host)
|
||||
{
|
||||
const auto byte = static_cast<unsigned char>(character);
|
||||
if (byte <= 0x20U || byte >= 0x7FU || byte == ':' || byte == '/' ||
|
||||
byte == '@' || byte == '[' || byte == ']')
|
||||
{
|
||||
return "the caster address must be a hostname or IPv4 address without a scheme, credentials, port, or path";
|
||||
}
|
||||
}
|
||||
if (port == 0)
|
||||
{
|
||||
return "the caster port must not be zero";
|
||||
}
|
||||
if (clean_mountpoint.empty() || clean_mountpoint.size() >= 256)
|
||||
{
|
||||
return "the mountpoint must name one caster mountpoint";
|
||||
}
|
||||
for (const char character : clean_mountpoint)
|
||||
{
|
||||
const auto byte = static_cast<unsigned char>(character);
|
||||
if (byte <= 0x20U || byte >= 0x7FU || byte == '@' || byte == ':' ||
|
||||
byte == '/')
|
||||
{
|
||||
return "the mountpoint must name one caster mountpoint without a path separator";
|
||||
}
|
||||
}
|
||||
const auto has_space_or_control = [](const std::string& value) {
|
||||
return std::any_of(value.cbegin(), value.cend(), [](char character) {
|
||||
const auto byte = static_cast<unsigned char>(character);
|
||||
return std::isspace(byte) != 0 || std::iscntrl(byte) != 0;
|
||||
});
|
||||
};
|
||||
if (username.size() >= 256 || password.size() >= 256 ||
|
||||
username.find(':') != std::string::npos ||
|
||||
has_space_or_control(username) || has_space_or_control(password))
|
||||
{
|
||||
return "the username or the password contains characters unsupported by the NTRIP client";
|
||||
}
|
||||
if (!password.empty() && username.empty())
|
||||
{
|
||||
return "a username is required when a password is configured";
|
||||
}
|
||||
if (timeout_ms < MIN_INTERVAL_MS || timeout_ms > MAX_INTERVAL_MS ||
|
||||
(reconnect_interval_ms != 0 && reconnect_interval_ms < MIN_INTERVAL_MS) ||
|
||||
reconnect_interval_ms > MAX_INTERVAL_MS)
|
||||
{
|
||||
return "timeout and reconnect intervals must be within 1000 ms and 24 hours; reconnect may also be zero";
|
||||
}
|
||||
if (send_gga && (gga_period_ms < MIN_INTERVAL_MS || gga_period_ms > MAX_INTERVAL_MS))
|
||||
{
|
||||
return "the GGA period must be within 1000 ms and 24 hours";
|
||||
}
|
||||
if (!std::isfinite(max_age_s) || max_age_s <= 0.0)
|
||||
{
|
||||
return "the maximum correction age must be a finite positive value";
|
||||
}
|
||||
if (station_id < 0 || station_id > 4095)
|
||||
{
|
||||
return "the station ID must be in the range 0..4095";
|
||||
}
|
||||
if (version != 1 && version != 2)
|
||||
{
|
||||
return "the NTRIP version must be 1 or 2";
|
||||
}
|
||||
// worst-case transport path: host (a resolved IPv4 literal can be longer
|
||||
// than a short hostname, hence the 15-character floor) + ":65535/" +
|
||||
// mountpoint + "::NTRIP=v::TLS"
|
||||
constexpr std::size_t transport_overhead = 21;
|
||||
if (std::max<std::size_t>(host.size(), 15U) + clean_mountpoint.size() +
|
||||
transport_overhead >=
|
||||
MAXSTRPATH)
|
||||
{
|
||||
return "the NTRIP endpoint is too long";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
Ntrip_Rtcm_Client_Config make_ntrip_rtcm_client_config(const Pvt_Conf& conf)
|
||||
{
|
||||
Ntrip_Rtcm_Client_Config config;
|
||||
config.enabled = conf.ntrip_client_enabled;
|
||||
config.host = conf.ntrip_caster_address;
|
||||
config.port = conf.ntrip_port;
|
||||
config.mountpoint = normalized_mountpoint(conf.ntrip_mountpoint);
|
||||
config.username = conf.ntrip_username;
|
||||
config.password = conf.ntrip_password;
|
||||
config.reconnect_interval_ms = conf.ntrip_reconnect_interval_ms;
|
||||
config.timeout_ms = conf.ntrip_timeout_ms;
|
||||
config.max_age_s = conf.ntrip_max_correction_age_s;
|
||||
config.send_gga = conf.ntrip_send_gga;
|
||||
config.gga_period_ms = conf.ntrip_gga_period_ms;
|
||||
config.version = conf.ntrip_version;
|
||||
config.tls_enabled = conf.ntrip_tls_enabled;
|
||||
config.station_id = conf.ntrip_station_id;
|
||||
return config;
|
||||
}
|
||||
|
||||
|
||||
// The layout follows mutex-protected state groups. Saving a few padding bytes
|
||||
// in this single-instance class is not worth separating each lock from its data.
|
||||
// NOLINTNEXTLINE(clang-analyzer-optin.performance.Padding)
|
||||
@@ -419,8 +529,8 @@ public:
|
||||
~Impl() noexcept
|
||||
{
|
||||
stop();
|
||||
secure_clear(&d_config.username);
|
||||
secure_clear(&d_config.password);
|
||||
secure_clear_string(&d_config.username);
|
||||
secure_clear_string(&d_config.password);
|
||||
}
|
||||
|
||||
Impl(const Impl&) = delete;
|
||||
@@ -602,93 +712,10 @@ public:
|
||||
private:
|
||||
bool validate_config(std::string* reason) const
|
||||
{
|
||||
const std::string mountpoint = normalized_mountpoint(d_config.mountpoint);
|
||||
if (d_config.host.empty())
|
||||
const std::string error = d_config.validate();
|
||||
if (!error.empty())
|
||||
{
|
||||
*reason = "host is empty";
|
||||
return false;
|
||||
}
|
||||
if (d_config.port == 0)
|
||||
{
|
||||
*reason = "port is zero";
|
||||
return false;
|
||||
}
|
||||
if (mountpoint.empty() || mountpoint.size() >= 256)
|
||||
{
|
||||
*reason = "mountpoint is empty or too long";
|
||||
return false;
|
||||
}
|
||||
for (char i : d_config.host)
|
||||
{
|
||||
const auto c = static_cast<unsigned char>(i);
|
||||
if (c <= 0x20U || c >= 0x7FU || c == ':' || c == '/' || c == '@' ||
|
||||
c == '[' || c == ']')
|
||||
{
|
||||
*reason = "host contains an unsupported character";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (char i : mountpoint)
|
||||
{
|
||||
const auto c = static_cast<unsigned char>(i);
|
||||
if (c <= 0x20U || c >= 0x7FU || c == '@' || c == ':')
|
||||
{
|
||||
*reason = "mountpoint contains an unsupported character";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (d_config.username.size() >= 256 || d_config.password.size() >= 256)
|
||||
{
|
||||
*reason = "credentials are too long";
|
||||
return false;
|
||||
}
|
||||
if (d_config.username.empty() && !d_config.password.empty())
|
||||
{
|
||||
*reason = "password requires a username";
|
||||
return false;
|
||||
}
|
||||
if (d_config.username.find(':') != std::string::npos ||
|
||||
d_config.username.find('\r') != std::string::npos ||
|
||||
d_config.username.find('\n') != std::string::npos)
|
||||
{
|
||||
*reason = "username contains an unsupported character";
|
||||
return false;
|
||||
}
|
||||
if (d_config.timeout_ms <= 0 || d_config.timeout_ms > MAX_INTERVAL_MS)
|
||||
{
|
||||
*reason = "timeout is outside the supported range";
|
||||
return false;
|
||||
}
|
||||
if (d_config.reconnect_interval_ms < 0 ||
|
||||
d_config.reconnect_interval_ms > MAX_INTERVAL_MS)
|
||||
{
|
||||
*reason = "reconnect interval is outside the supported range";
|
||||
return false;
|
||||
}
|
||||
if (!std::isfinite(d_config.max_age_s) || d_config.max_age_s <= 0.0)
|
||||
{
|
||||
*reason = "maximum correction age must be positive";
|
||||
return false;
|
||||
}
|
||||
if (d_config.send_gga &&
|
||||
(d_config.gga_period_ms <= 0 || d_config.gga_period_ms > MAX_INTERVAL_MS))
|
||||
{
|
||||
*reason = "GGA period is outside the supported range";
|
||||
return false;
|
||||
}
|
||||
if (d_config.station_id < 0 || d_config.station_id > 4095)
|
||||
{
|
||||
*reason = "station ID is outside the 12-bit RTCM range";
|
||||
return false;
|
||||
}
|
||||
if (d_config.version != 1 && d_config.version != 2)
|
||||
{
|
||||
*reason = "NTRIP version must be 1 or 2";
|
||||
return false;
|
||||
}
|
||||
if (transport_path().size() >= MAXSTRPATH)
|
||||
{
|
||||
*reason = "NTRIP endpoint is too long";
|
||||
*reason = error;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -859,7 +886,28 @@ private:
|
||||
std::uint64_t applied_rover_time_generation = 0;
|
||||
int active_version = d_config.version;
|
||||
bool fallback_from_v2 = false;
|
||||
bool v1_fallback_confirmed = false;
|
||||
std::string immediate_retry_host;
|
||||
// one decoder for the whole worker lifetime: its lock-time table is
|
||||
// the baseline lossoflock() uses to flag base-receiver cycle slips,
|
||||
// and a per-connection decoder would zero that baseline on every
|
||||
// reconnect — a base slip during a brief outage would then arrive
|
||||
// with no loss-of-lock indication and stale carrier ambiguities
|
||||
// would survive it (rtksvr keeps one rtcm_t across stream reconnects
|
||||
// for the same reason)
|
||||
Rtcm_Owner decoder;
|
||||
if (!decoder.initialized())
|
||||
{
|
||||
set_state(Ntrip_Rtcm_Client_State::ERROR,
|
||||
"could not initialize RTCM3 decoder", false);
|
||||
return;
|
||||
}
|
||||
if (d_config.station_id > 0)
|
||||
{
|
||||
std::snprintf(decoder.get()->opt,
|
||||
sizeof(decoder.get()->opt), "-STA=%d",
|
||||
d_config.station_id);
|
||||
}
|
||||
while (!d_stop_requested.load())
|
||||
{
|
||||
std::string failure_message = "correction stream disconnected";
|
||||
@@ -881,19 +929,12 @@ private:
|
||||
}
|
||||
if (resolve_result == Resolve_Result::SUCCESS)
|
||||
{
|
||||
Rtcm_Owner decoder;
|
||||
if (!decoder.initialized())
|
||||
{
|
||||
set_state(Ntrip_Rtcm_Client_State::ERROR,
|
||||
"could not initialize RTCM3 decoder", false);
|
||||
return;
|
||||
}
|
||||
if (d_config.station_id > 0)
|
||||
{
|
||||
std::snprintf(decoder.get()->opt,
|
||||
sizeof(decoder.get()->opt), "-STA=%d",
|
||||
d_config.station_id);
|
||||
}
|
||||
// a new connection starts at an arbitrary point of the
|
||||
// caster's byte stream: drop any partially assembled
|
||||
// frame left over from the previous connection, but
|
||||
// keep the per-satellite lock-time baseline
|
||||
decoder.get()->nbyte = 0;
|
||||
decoder.get()->len = 0;
|
||||
Stream_Owner stream;
|
||||
set_state(Ntrip_Rtcm_Client_State::CONNECTING,
|
||||
"connecting to NTRIP caster", false);
|
||||
@@ -917,6 +958,10 @@ private:
|
||||
active_version = NTRIP_VERSION_1;
|
||||
fallback_from_v2 = true;
|
||||
}
|
||||
if (active_version == NTRIP_VERSION_1 && fallback_from_v2)
|
||||
{
|
||||
v1_fallback_confirmed = true;
|
||||
}
|
||||
apply_decoder_time(decoder.get(), &applied_rover_time_generation, true);
|
||||
set_state(Ntrip_Rtcm_Client_State::STREAMING,
|
||||
streaming_message(active_version, fallback_from_v2), true);
|
||||
@@ -961,6 +1006,18 @@ private:
|
||||
"NTRIP v2 negotiation failed; retrying with v1", false);
|
||||
continue;
|
||||
}
|
||||
if (fallback_from_v2 && !v1_fallback_confirmed &&
|
||||
active_version == NTRIP_VERSION_1)
|
||||
{
|
||||
// the v1 fallback was inferred from a failed v2 handshake
|
||||
// (which a transient drop — e.g. a caster restart closing
|
||||
// the connection before any response byte — also produces)
|
||||
// and never carried a stream: start the next cycle from
|
||||
// the configured version instead of locking the whole
|
||||
// session to v1 on that one-shot evidence
|
||||
active_version = d_config.version;
|
||||
fallback_from_v2 = false;
|
||||
}
|
||||
if (d_config.reconnect_interval_ms == 0)
|
||||
{
|
||||
set_state(Ntrip_Rtcm_Client_State::ERROR,
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
#ifndef GNSS_SDR_NTRIP_RTCM_CLIENT_H
|
||||
#define GNSS_SDR_NTRIP_RTCM_CLIENT_H
|
||||
|
||||
#include "pvt_conf.h"
|
||||
#include "rtklib.h"
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
@@ -29,6 +30,11 @@
|
||||
/** \addtogroup PVT_libs
|
||||
* \{ */
|
||||
|
||||
//! Overwrites a credential string before releasing its storage, so the
|
||||
//! secret does not linger in freed heap memory. Single implementation for
|
||||
//! every holder of a Pvt_Conf / NTRIP configuration copy.
|
||||
void secure_clear_string(std::string* value) noexcept;
|
||||
|
||||
struct Ntrip_Rtcm_Client_Config
|
||||
{
|
||||
bool enabled = false;
|
||||
@@ -57,9 +63,22 @@ struct Ntrip_Rtcm_Client_Config
|
||||
// Zero accepts any stream station while keeping observations and position
|
||||
// bound to one ID. A positive value filters to that exact 12-bit ID.
|
||||
int station_id = 0;
|
||||
|
||||
//! Returns an empty string when the configuration is usable, or the
|
||||
//! reason it is not. Single source of the NTRIP configuration rules,
|
||||
//! shared by the adapter (which turns the reason into a throw at
|
||||
//! constructor time) and by start() (which turns it into an ERROR state).
|
||||
std::string validate() const;
|
||||
};
|
||||
|
||||
|
||||
//! Builds the NTRIP client configuration from the PVT configuration —
|
||||
//! the single home of the field mapping and of the mountpoint
|
||||
//! normalization, shared by the adapter's validation and by the PVT block's
|
||||
//! client construction.
|
||||
Ntrip_Rtcm_Client_Config make_ntrip_rtcm_client_config(const Pvt_Conf& conf);
|
||||
|
||||
|
||||
enum class Ntrip_Rtcm_Client_State
|
||||
{
|
||||
DISABLED,
|
||||
|
||||
@@ -1835,11 +1835,13 @@ void add_obs_sat_record_line(const Gnss_Synchro& synchro, std::string& line, boo
|
||||
{
|
||||
const int32_t ssi = signal_strength(synchro.CN0_dB_hz);
|
||||
const char lli = synchro.Flag_cycle_slip ? '1' : ' ';
|
||||
// bit 0: loss of lock or cycle slip, bit 1: half-cycle ambiguity change.
|
||||
// The half-cycle bit only makes sense for the carrier phase observable
|
||||
const int32_t phase_lli_bits = (synchro.Flag_cycle_slip ? 1 : 0) +
|
||||
(synchro.Flag_half_cycle_slip ? 2 : 0);
|
||||
const char phase_lli = phase_lli_bits != 0 ? static_cast<char>('0' + phase_lli_bits) : ' ';
|
||||
// bit 0: loss of lock or cycle slip; a half-cycle re-resolution is a
|
||||
// one-epoch slip event on the carrier phase only. RINEX defines bit 1 as
|
||||
// a persistent "half-cycle ambiguity unresolved" state, which never
|
||||
// applies here (polarity is always resolved before observations are
|
||||
// produced), so writing the event to bit 1 would make post-processors
|
||||
// see two spurious state transitions per event
|
||||
const char phase_lli = (synchro.Flag_cycle_slip || synchro.Flag_half_cycle_slip) ? '1' : ' ';
|
||||
|
||||
// PSEUDORANGE
|
||||
line += rightJustify(asString(synchro.Pseudorange_m, 3), 14);
|
||||
|
||||
@@ -55,23 +55,6 @@
|
||||
#include <absl/log/log.h>
|
||||
#endif
|
||||
|
||||
namespace
|
||||
{
|
||||
void clear_sensitive_string(std::string *value)
|
||||
{
|
||||
if (value == nullptr || value->empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
volatile char *data = &(*value)[0];
|
||||
for (std::size_t index = 0; index < value->size(); ++index)
|
||||
{
|
||||
data[index] = 0;
|
||||
}
|
||||
value->clear();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Rtklib_Solver::Rtklib_Solver(const rtk_t &rtk,
|
||||
const Pvt_Conf &conf,
|
||||
const std::string &dump_filename,
|
||||
@@ -86,9 +69,9 @@ Rtklib_Solver::Rtklib_Solver(const rtk_t &rtk,
|
||||
{
|
||||
// Solver instances need correction policy but never caster credentials.
|
||||
// Remove secrets from this long-lived configuration copy immediately.
|
||||
clear_sensitive_string(&d_conf.ntrip_username);
|
||||
clear_sensitive_string(&d_conf.ntrip_password);
|
||||
clear_sensitive_string(&d_conf.ntrip_password_env);
|
||||
secure_clear_string(&d_conf.ntrip_username);
|
||||
secure_clear_string(&d_conf.ntrip_password);
|
||||
secure_clear_string(&d_conf.ntrip_password_env);
|
||||
|
||||
// see freq index at src/algorithms/libs/rtklib/rtklib_rtkcmn.cc
|
||||
// function: satwavelen
|
||||
@@ -1949,6 +1932,37 @@ bool Rtklib_Solver::get_PVT(const std::map<int, Gnss_Synchro> &gnss_observables_
|
||||
clear_applied_has_phase_bias_discontinuity(applied_has_correction, prn);
|
||||
valid_obs++;
|
||||
}
|
||||
else if (!is_qzss && fixed_base != nullptr)
|
||||
{
|
||||
// the rover has not decoded this satellite's LNAV yet, but
|
||||
// the base stream may have delivered its broadcast ephemeris
|
||||
// over NTRIP (RTCM MT1019, already in RTKLIB format): use it
|
||||
// so the satellite contributes without waiting the ~30 s of
|
||||
// subframe decoding
|
||||
const eph_t *base_ephemeris = nullptr;
|
||||
for (const auto &candidate : fixed_base->gps_ephemerides)
|
||||
{
|
||||
if (candidate.sat == sat)
|
||||
{
|
||||
base_ephemeris = &candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (base_ephemeris != nullptr)
|
||||
{
|
||||
eph_data[valid_obs] = *base_ephemeris;
|
||||
obsd_t newobs{};
|
||||
d_obs_data[valid_obs + glo_valid_obs] = insert_obs_to_rtklib(newobs,
|
||||
gnss_observables_iter->second,
|
||||
base_ephemeris->week,
|
||||
d_rtklib_band_index.at(rtklib_sig));
|
||||
valid_obs++;
|
||||
}
|
||||
else
|
||||
{
|
||||
DLOG(INFO) << "No ephemeris data for SV " << gnss_observables_iter->first;
|
||||
}
|
||||
}
|
||||
else // the ephemeris are not available for this SV
|
||||
{
|
||||
DLOG(INFO) << "No ephemeris data for SV " << gnss_observables_iter->first;
|
||||
|
||||
@@ -170,9 +170,14 @@ obsd_t insert_obs_to_rtklib(obsd_t& rtklib_obs,
|
||||
rtklib_obs.D[band] = gnss_synchro.Carrier_Doppler_hz;
|
||||
rtklib_obs.P[band] = gnss_synchro.Pseudorange_m;
|
||||
rtklib_obs.L[band] = gnss_synchro.Carrier_phase_rads / TWO_PI;
|
||||
// bit 0: loss of lock or cycle slip, bit 1: half-cycle ambiguity change
|
||||
rtklib_obs.LLI[band] = static_cast<unsigned char>((gnss_synchro.Flag_cycle_slip ? 1U : 0U) |
|
||||
(gnss_synchro.Flag_half_cycle_slip ? 2U : 0U));
|
||||
// bit 0: loss of lock or cycle slip. A half-cycle re-resolution steps the
|
||||
// reported phase by half a cycle at this single epoch, i.e. it is a slip
|
||||
// event, not a persistent "half-cycle unresolved" condition: LLI bit 1 is
|
||||
// a state indicator whose every transition counts as a slip downstream
|
||||
// (detslp_ll), so mapping the one-epoch event flag there would reset the
|
||||
// phase bias a second, spurious time when the flag drops
|
||||
rtklib_obs.LLI[band] = static_cast<unsigned char>(
|
||||
(gnss_synchro.Flag_cycle_slip || gnss_synchro.Flag_half_cycle_slip) ? 1U : 0U);
|
||||
|
||||
switch (band)
|
||||
{
|
||||
|
||||
@@ -79,6 +79,25 @@ int galileo_bgd_index(unsigned char observation_code, int sat, const nav_t *nav)
|
||||
/* pseudorange measurement error variance ------------------------------------
|
||||
* var = fact^2*eratio^2*(a^2 + b^2/sin(el) + c^2*10^(0.1*(snr_max-snr))) +
|
||||
* (d*rcv_std)^2 (demo5 form) */
|
||||
/* first frequency slot carrying a pseudorange. GNSS-SDR places single-band
|
||||
L2C/L5/E5a/B3I observations in slots 1/2 with slot 0 empty, unlike the
|
||||
upstream receivers demo5 was written for, so the SNR and receiver-stdev
|
||||
weighting terms must follow the measurement instead of hardcoding slot 0
|
||||
(an empty slot reads as SNR 0 and inflates the variance by orders of
|
||||
magnitude) */
|
||||
static int used_frequency_slot(const obsd_t *obs)
|
||||
{
|
||||
for (int f = 0; f < NFREQ + NEXOBS; f++)
|
||||
{
|
||||
if (obs->P[f] != 0.0)
|
||||
{
|
||||
return f;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
double varerr(const prcopt_t *opt, const obsd_t *obs, double el, int sys)
|
||||
{
|
||||
double fact = 1.0;
|
||||
@@ -112,16 +131,17 @@ double varerr(const prcopt_t *opt, const obsd_t *obs, double el, int sys)
|
||||
{
|
||||
el = VARERR_MIN_EL;
|
||||
}
|
||||
const int slot = used_frequency_slot(obs);
|
||||
varr = std::pow(opt->err[1], 2.0) + std::pow(opt->err[2], 2.0) / sin(el);
|
||||
if (opt->err[6] > 0.0)
|
||||
{ /* SNR-dependent term */
|
||||
varr += std::pow(opt->err[6], 2.0) *
|
||||
std::pow(10.0, 0.1 * std::max(opt->err[5] - obs->SNR[0] * 0.25, 0.0));
|
||||
std::pow(10.0, 0.1 * std::max(opt->err[5] - obs->SNR[slot] * 0.25, 0.0));
|
||||
}
|
||||
varr *= std::pow(opt->eratio[0], 2.0);
|
||||
if (opt->err[7] > 0.0)
|
||||
{ /* receiver-reported stdev term */
|
||||
varr += std::pow(opt->err[7] * obs->Pstd[0], 2.0);
|
||||
varr += std::pow(opt->err[7] * obs->Pstd[slot], 2.0);
|
||||
}
|
||||
if (opt->ionoopt == IONOOPT_IFLC)
|
||||
{
|
||||
@@ -838,7 +858,7 @@ int rescode(int iter, const obsd_t *obs, int n, const double *rs,
|
||||
if (iono_scale == 0.0 && opt->ionoopt != IONOOPT_IFLC)
|
||||
{
|
||||
/* same noise amplification varerr applies for IONOOPT_IFLC */
|
||||
vmeasure *= std::pow(2, 3.0);
|
||||
vmeasure *= std::pow(3.0, 2.0);
|
||||
}
|
||||
var[nv++] = vmeasure + vare[i] + vmeas + vion + vtrp;
|
||||
|
||||
|
||||
@@ -2012,11 +2012,12 @@ int ddres(rtk_t *rtk, const obsd_t *obs, const nav_t *nav, double dt, const doub
|
||||
if (opt->maxinno[f < nf ? 0 : 1] > 0.0 &&
|
||||
fabs(v[nv]) > opt->maxinno[f < nf ? 0 : 1] * threshadj)
|
||||
{
|
||||
if (f < nf)
|
||||
{
|
||||
rtk->ssat[sat[i] - 1].rejc[f]++;
|
||||
rtk->ssat[sat[j] - 1].rejc[f]++;
|
||||
}
|
||||
/* charge the outlier only to the non-reference
|
||||
satellite (demo5): the reference is part of every
|
||||
pair and would otherwise reach the rejc-based bias
|
||||
reset from rejections it did not cause */
|
||||
rtk->ssat[sat[j] - 1].vsat[frq] = 0;
|
||||
rtk->ssat[sat[j] - 1].rejc[frq]++;
|
||||
errmsg(rtk, "outlier rejected (sat=%3d-%3d %s%d v=%.3f)\n",
|
||||
sat[i], sat[j], f < nf ? "L" : "P", f % nf + 1, v[nv]);
|
||||
continue;
|
||||
@@ -2978,6 +2979,10 @@ int relpos(rtk_t *rtk, const obsd_t *obs, int nu, int nr,
|
||||
Pp = zeros(rtk->nx, rtk->nx);
|
||||
xa = mat(rtk->nx, 1);
|
||||
matcpy(xp, rtk->x, rtk->nx, 1);
|
||||
/* the first ddres call reads Pp to detect freshly initialized phase
|
||||
biases (innovation-threshold opening), so it must see the real
|
||||
covariance, not zeros (demo5) */
|
||||
matcpy(Pp, rtk->P, rtk->nx, rtk->nx);
|
||||
|
||||
ny = ns * nf * 2 + 2;
|
||||
v = mat(ny, 1);
|
||||
@@ -3151,10 +3156,11 @@ int relpos(rtk_t *rtk, const obsd_t *obs, int nu, int nr,
|
||||
{
|
||||
for (j = 0; j < nf; j++)
|
||||
{
|
||||
if (rtk->ssat[i].fix[j] == 2 && stat != SOLQ_FIX)
|
||||
{
|
||||
rtk->ssat[i].fix[j] = 1;
|
||||
}
|
||||
/* keep fix[] == 2 across non-fixed epochs (demo5): the flags
|
||||
record which satellites entered the last AR attempt, and
|
||||
manage_amb_LAMBDA's mindropsats exclusion cycling counts
|
||||
them after a failed epoch — demoting them here would make
|
||||
that gate unreachable */
|
||||
if (rtk->ssat[i].slip[j] & 1)
|
||||
{
|
||||
rtk->ssat[i].slipc[j]++;
|
||||
|
||||
@@ -2280,10 +2280,32 @@ int rspntrip_c_v2(ntrip_t *ntrip, char *msg)
|
||||
}
|
||||
if (content_type_present)
|
||||
{
|
||||
const char *expected_type = *ntrip->mntpnt
|
||||
? "gnss/data"
|
||||
: "gnss/sourcetable";
|
||||
if (content_type != expected_type)
|
||||
if (*ntrip->mntpnt)
|
||||
{
|
||||
/* NTRIP 2.0 mandates gnss/data, but deployed casters also
|
||||
label correction streams application/octet-stream and
|
||||
the like: reject only what clearly is not a correction
|
||||
stream — a sourcetable answer (unknown mountpoint) or a
|
||||
textual document (an error page served with status
|
||||
200) — and tolerate the rest, since the RTCM decoder
|
||||
validates every frame anyway */
|
||||
const bool textual =
|
||||
content_type.compare(0, 5, "text/") == 0 ||
|
||||
content_type == "application/json" ||
|
||||
content_type == "application/xml" ||
|
||||
content_type == "application/xhtml+xml";
|
||||
if (textual || content_type == "gnss/sourcetable")
|
||||
{
|
||||
return reject_ntrip_v2_response(ntrip, msg,
|
||||
std::string("unexpected NTRIP Content-Type: ") + content_type);
|
||||
}
|
||||
if (content_type != "gnss/data")
|
||||
{
|
||||
tracet(2, "ntrip: tolerating Content-Type %s\n",
|
||||
content_type.c_str());
|
||||
}
|
||||
}
|
||||
else if (content_type != "gnss/sourcetable")
|
||||
{
|
||||
return reject_ntrip_v2_response(ntrip, msg,
|
||||
std::string("unexpected NTRIP Content-Type: ") + content_type);
|
||||
|
||||
@@ -225,6 +225,15 @@ public:
|
||||
}
|
||||
#endif
|
||||
|
||||
// SSL_write() demands a retry after WANT_READ/WANT_WRITE to present
|
||||
// the very same buffer and length, but the periodic GGA upload builds
|
||||
// a fresh sentence on a fresh stack buffer each cycle: without these
|
||||
// modes a backpressured GGA send is followed by a fatal
|
||||
// SSL_R_BAD_WRITE_RETRY and a full NTRIP disconnect. With them a
|
||||
// moved or shortened buffer is legal and partial writes report the
|
||||
// bytes actually consumed, like a plain socket send
|
||||
SSL_CTX_set_mode(d_context,
|
||||
SSL_MODE_ENABLE_PARTIAL_WRITE | SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER);
|
||||
SSL_CTX_set_verify(d_context, SSL_VERIFY_PEER, nullptr);
|
||||
if (SSL_CTX_set_default_verify_paths(d_context) != 1)
|
||||
{
|
||||
@@ -261,6 +270,7 @@ public:
|
||||
gnutls_deinit(d_session);
|
||||
d_session = nullptr;
|
||||
}
|
||||
d_send_pending = false;
|
||||
#else
|
||||
if (d_session)
|
||||
{
|
||||
@@ -499,13 +509,26 @@ public:
|
||||
set_tls_msg(msg, "TLS signal protection failed");
|
||||
return -1;
|
||||
}
|
||||
// after GNUTLS_E_AGAIN the record stays cached inside the session and
|
||||
// the next gnutls_record_send() transmits that cached record, ignoring
|
||||
// the new arguments and returning the cached size: track the pending
|
||||
// state so a caller retrying with different data (a fresh GGA
|
||||
// sentence) is never told more than its own length was written, and a
|
||||
// stable-buffer retry loop keeps its accounting exact
|
||||
const ssize_t ret = gnutls_record_send(d_session, buff, static_cast<size_t>(n));
|
||||
if (ret >= 0)
|
||||
{
|
||||
const bool flushed_cached_record = d_send_pending;
|
||||
d_send_pending = false;
|
||||
if (flushed_cached_record && ret > static_cast<ssize_t>(n))
|
||||
{
|
||||
return n;
|
||||
}
|
||||
return static_cast<int>(ret);
|
||||
}
|
||||
if (ret == GNUTLS_E_AGAIN || ret == GNUTLS_E_INTERRUPTED)
|
||||
{
|
||||
d_send_pending = true;
|
||||
return 0;
|
||||
}
|
||||
set_tls_msg(msg, "GnuTLS send failed");
|
||||
@@ -538,6 +561,7 @@ private:
|
||||
#ifdef USE_GNUTLS_FALLBACK
|
||||
gnutls_certificate_credentials_t d_credentials = nullptr;
|
||||
gnutls_session_t d_session = nullptr;
|
||||
bool d_send_pending = false;
|
||||
#else
|
||||
SSL_CTX *d_context = nullptr;
|
||||
SSL *d_session = nullptr;
|
||||
|
||||
@@ -493,7 +493,16 @@ bool hybrid_observables_gs::interp_trk_obs(Gnss_Synchro &interpolated_obs, uint3
|
||||
d_gnss_synchro_history->get(ch, t1_idx).RX_time);
|
||||
|
||||
// CARRIER PHASE INTERPOLATION
|
||||
interpolated_obs.Carrier_phase_rads = d_gnss_synchro_history->get(ch, t1_idx).Carrier_phase_rads + (d_gnss_synchro_history->get(ch, t2_idx).Carrier_phase_rads - d_gnss_synchro_history->get(ch, t1_idx).Carrier_phase_rads) * time_factor;
|
||||
// done in the late sample's polarity frame, whose PLL-180
|
||||
// state is also stamped on the output so the half-cycle
|
||||
// step and its flag land on the same epoch
|
||||
interpolated_obs.Carrier_phase_rads = interpolate_carrier_phase(
|
||||
d_gnss_synchro_history->get(ch, t1_idx).Carrier_phase_rads,
|
||||
d_gnss_synchro_history->get(ch, t1_idx).Flag_PLL_180_deg_phase_locked,
|
||||
d_gnss_synchro_history->get(ch, t2_idx).Carrier_phase_rads,
|
||||
d_gnss_synchro_history->get(ch, t2_idx).Flag_PLL_180_deg_phase_locked,
|
||||
time_factor);
|
||||
interpolated_obs.Flag_PLL_180_deg_phase_locked = d_gnss_synchro_history->get(ch, t2_idx).Flag_PLL_180_deg_phase_locked;
|
||||
// CARRIER DOPPLER INTERPOLATION
|
||||
interpolated_obs.Carrier_Doppler_hz = d_gnss_synchro_history->get(ch, t1_idx).Carrier_Doppler_hz + (d_gnss_synchro_history->get(ch, t2_idx).Carrier_Doppler_hz - d_gnss_synchro_history->get(ch, t1_idx).Carrier_Doppler_hz) * time_factor;
|
||||
// TOW INTERPOLATION
|
||||
@@ -669,7 +678,9 @@ void hybrid_observables_gs::smooth_pseudoranges(std::vector<Gnss_Synchro> &data)
|
||||
// observations and its carrier phase is continuous with them:
|
||||
// a phase discontinuity would otherwise propagate into the
|
||||
// smoothed pseudorange through the carrier phase term below
|
||||
if (d_channel_last_pll_lock[it->Channel_ID] == true && !it->Flag_cycle_slip)
|
||||
// (a half-cycle step is half a wavelength of the same poison)
|
||||
if (d_channel_last_pll_lock[it->Channel_ID] == true && !it->Flag_cycle_slip &&
|
||||
!it->Flag_half_cycle_slip)
|
||||
{
|
||||
// 2. Compute the smoothed pseudorange for this channel
|
||||
// Hatch filter algorithm (https://insidegnss.com/can-you-list-all-the-properties-of-the-carrier-smoothing-filter/)
|
||||
@@ -731,6 +742,26 @@ bool hybrid_observables_gs::half_cycle_ambiguity_changed(bool has_previous_obser
|
||||
}
|
||||
|
||||
|
||||
double hybrid_observables_gs::interpolate_carrier_phase(double phase_early_rads,
|
||||
bool pll_180_early,
|
||||
double phase_late_rads,
|
||||
bool pll_180_late,
|
||||
double time_factor)
|
||||
{
|
||||
// The Telemetry Decoder adds half a cycle to the reported phase while the
|
||||
// PLL is locked at 180 degrees, so when the two samples disagree the early
|
||||
// one is shifted into the late sample's frame before interpolating; the
|
||||
// resulting half-cycle step between consecutive epochs is reported through
|
||||
// Flag_half_cycle_slip on the epoch stamped with the new polarity
|
||||
double early = phase_early_rads;
|
||||
if (pll_180_early != pll_180_late)
|
||||
{
|
||||
early += pll_180_late ? (TWO_PI / 2.0) : -(TWO_PI / 2.0);
|
||||
}
|
||||
return early + (phase_late_rads - early) * time_factor;
|
||||
}
|
||||
|
||||
|
||||
void hybrid_observables_gs::detect_cycle_slips(std::vector<Gnss_Synchro> &data, uint64_t rx_clock)
|
||||
{
|
||||
constexpr double kCycleSlipThresholdCycles = 0.5;
|
||||
|
||||
@@ -124,6 +124,30 @@ public:
|
||||
bool last_pll_180_locked,
|
||||
bool current_pll_180_locked);
|
||||
|
||||
/*!
|
||||
* \brief Linearly interpolates the carrier phase between two tracking
|
||||
* samples, in the polarity frame of the later sample.
|
||||
*
|
||||
* When the PLL-180 half-cycle correction toggles between the two samples,
|
||||
* their reported phases differ by half a cycle on top of the true motion:
|
||||
* interpolating across that step would leak a fraction of it into the
|
||||
* output with no slip flag raised. Shifting the early sample into the
|
||||
* late sample's frame pins the whole step to the epoch where the polarity
|
||||
* change is reported.
|
||||
*
|
||||
* \param phase_early_rads Carrier phase of the earlier sample
|
||||
* \param pll_180_early PLL-180 correction state of the earlier sample
|
||||
* \param phase_late_rads Carrier phase of the later sample
|
||||
* \param pll_180_late PLL-180 correction state of the later sample
|
||||
* \param time_factor Position of the epoch inside the window, in [0, 1]
|
||||
* \return the interpolated carrier phase, in the late sample's frame
|
||||
*/
|
||||
static double interpolate_carrier_phase(double phase_early_rads,
|
||||
bool pll_180_early,
|
||||
double phase_late_rads,
|
||||
bool pll_180_late,
|
||||
double time_factor);
|
||||
|
||||
private:
|
||||
friend hybrid_observables_gs_sptr hybrid_observables_gs_make(const Obs_Conf& conf_);
|
||||
|
||||
|
||||
@@ -57,20 +57,22 @@ TEST(RtklibLliTest, ACycleSlipSetsBitZero)
|
||||
}
|
||||
|
||||
|
||||
TEST(RtklibLliTest, AHalfCycleSlipSetsBitOne)
|
||||
TEST(RtklibLliTest, AHalfCycleSlipSetsBitZero)
|
||||
{
|
||||
// RTKLIB reads a transition of this bit as a slip and excludes the satellite
|
||||
// from ambiguity resolution while it is set
|
||||
// A half-cycle re-resolution is a one-epoch slip event. LLI bit 1 is a
|
||||
// persistent "half-cycle unresolved" state whose every transition counts
|
||||
// as a slip in detslp_ll, so mapping the pulse there would reset the
|
||||
// phase bias a second time when the flag drops one epoch later
|
||||
Gnss_Synchro observation = make_gps_l1_observation();
|
||||
observation.Flag_half_cycle_slip = true;
|
||||
EXPECT_EQ(2U, lli_of(observation));
|
||||
EXPECT_EQ(1U, lli_of(observation));
|
||||
}
|
||||
|
||||
|
||||
TEST(RtklibLliTest, BothConditionsKeepTheirOwnBit)
|
||||
TEST(RtklibLliTest, BothConditionsShareBitZero)
|
||||
{
|
||||
Gnss_Synchro observation = make_gps_l1_observation();
|
||||
observation.Flag_cycle_slip = true;
|
||||
observation.Flag_half_cycle_slip = true;
|
||||
EXPECT_EQ(3U, lli_of(observation));
|
||||
EXPECT_EQ(1U, lli_of(observation));
|
||||
}
|
||||
|
||||
+37
@@ -15,6 +15,7 @@
|
||||
* -----------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
#include "MATH_CONSTANTS.h"
|
||||
#include "hybrid_observables_gs.h"
|
||||
#include <gtest/gtest.h>
|
||||
#include <cmath>
|
||||
@@ -115,3 +116,39 @@ TEST(ObservablesPhaseContinuityTest, ContinuityIsPreservedAcrossTheEpochCounterR
|
||||
EXPECT_FALSE(is_discontinuous(true, large_epoch, large_epoch + 1));
|
||||
EXPECT_TRUE(is_discontinuous(true, large_epoch, large_epoch + 1000));
|
||||
}
|
||||
|
||||
|
||||
TEST(ObservablesPhaseInterpolationTest, InterpolationIsLinearWhenThePolarityIsStable)
|
||||
{
|
||||
EXPECT_DOUBLE_EQ(1.25, hybrid_observables_gs::interpolate_carrier_phase(1.0, false, 2.0, false, 0.25));
|
||||
EXPECT_DOUBLE_EQ(1.75, hybrid_observables_gs::interpolate_carrier_phase(1.0, true, 2.0, true, 0.75));
|
||||
}
|
||||
|
||||
|
||||
TEST(ObservablesPhaseInterpolationTest, APolarityChangeInsideTheWindowDoesNotLeakAPartialStep)
|
||||
{
|
||||
// the late sample carries the pi correction, so the early one is lifted
|
||||
// into the same frame: the output must be the early phase plus the full
|
||||
// step plus the interpolated true motion, never a time_factor-dependent
|
||||
// fraction of pi
|
||||
const double true_motion = 0.5;
|
||||
const double phase_early = 10.0;
|
||||
const double phase_late = phase_early + true_motion + TWO_PI / 2.0;
|
||||
for (const double time_factor : {0.0, 0.25, 0.5, 0.75, 1.0})
|
||||
{
|
||||
const double interpolated = hybrid_observables_gs::interpolate_carrier_phase(
|
||||
phase_early, false, phase_late, true, time_factor);
|
||||
EXPECT_DOUBLE_EQ(phase_early + TWO_PI / 2.0 + true_motion * time_factor, interpolated);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
TEST(ObservablesPhaseInterpolationTest, ADroppedPolarityCorrectionIsRemovedBeforeInterpolating)
|
||||
{
|
||||
const double true_motion = -0.25;
|
||||
const double phase_early = 4.0; // reported while the pi correction was applied
|
||||
const double phase_late = phase_early + true_motion - TWO_PI / 2.0;
|
||||
const double interpolated = hybrid_observables_gs::interpolate_carrier_phase(
|
||||
phase_early, true, phase_late, false, 0.5);
|
||||
EXPECT_DOUBLE_EQ(phase_early - TWO_PI / 2.0 + true_motion * 0.5, interpolated);
|
||||
}
|
||||
|
||||
@@ -787,7 +787,9 @@ enum class Loopback_Caster_Response_Mode
|
||||
V2_HTTP_CHUNKED,
|
||||
V2_HTTP_UNAUTHORIZED,
|
||||
STRICT_V1_AFTER_V2_REJECTION,
|
||||
STRICT_V1_AFTER_V2_CLOSE
|
||||
STRICT_V1_AFTER_V2_CLOSE,
|
||||
V2_RECOVERY_AFTER_TRANSIENT_CLOSES,
|
||||
LOCK_RESET_AFTER_RECONNECT
|
||||
};
|
||||
|
||||
|
||||
@@ -1060,6 +1062,100 @@ private:
|
||||
receive_request(client_socket, &received_request);
|
||||
d_requests.push_back(received_request);
|
||||
}
|
||||
|
||||
if (d_response_mode ==
|
||||
Loopback_Caster_Response_Mode::V2_RECOVERY_AFTER_TRANSIENT_CLOSES)
|
||||
{
|
||||
// two consecutive zero-byte closes: the v2 attempt and its v1
|
||||
// retry both look like a transient caster drop; the third
|
||||
// connection is then served normally
|
||||
for (int closed_connection = 0; closed_connection < 2; ++closed_connection)
|
||||
{
|
||||
shutdown(client_socket, SHUT_RDWR);
|
||||
close(client_socket);
|
||||
client_socket = accept_client();
|
||||
if (client_socket < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
received_request.clear();
|
||||
receive_request(client_socket, &received_request);
|
||||
d_requests.push_back(received_request);
|
||||
}
|
||||
}
|
||||
|
||||
if (d_response_mode ==
|
||||
Loopback_Caster_Response_Mode::LOCK_RESET_AFTER_RECONNECT)
|
||||
{
|
||||
// first connection: healthy stream with a long lock time, then
|
||||
// a drop; second connection: the base receiver re-locked during
|
||||
// the outage, so the lock-time indicator decreased
|
||||
const char icy_response[] = "ICY 200 OK\r\n\r\n";
|
||||
const std::vector<unsigned char> base_position = make_mt1005();
|
||||
|
||||
std::vector<unsigned char> first_payload = base_position;
|
||||
const std::vector<unsigned char> locked_observations =
|
||||
make_mt1074(false, 9, TEST_TOW_S);
|
||||
first_payload.insert(first_payload.end(),
|
||||
locked_observations.begin(), locked_observations.end());
|
||||
send_all(client_socket,
|
||||
reinterpret_cast<const unsigned char*>(icy_response),
|
||||
sizeof(icy_response) - 1);
|
||||
send_all(client_socket, first_payload.data(), first_payload.size());
|
||||
// give the client time to ingest the epoch before the drop
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
shutdown(client_socket, SHUT_RDWR);
|
||||
close(client_socket);
|
||||
|
||||
client_socket = accept_client();
|
||||
if (client_socket < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
received_request.clear();
|
||||
receive_request(client_socket, &received_request);
|
||||
d_requests.push_back(received_request);
|
||||
d_request = received_request;
|
||||
|
||||
std::vector<unsigned char> second_payload = base_position;
|
||||
const std::vector<unsigned char> relocked_observations =
|
||||
make_mt1074(false, 1, TEST_TOW_S + 1.0);
|
||||
second_payload.insert(second_payload.end(),
|
||||
relocked_observations.begin(), relocked_observations.end());
|
||||
send_all(client_socket,
|
||||
reinterpret_cast<const unsigned char*>(icy_response),
|
||||
sizeof(icy_response) - 1);
|
||||
send_all(client_socket, second_payload.data(), second_payload.size());
|
||||
|
||||
// hold the connection open until the peer closes it (the
|
||||
// client's stop()), a stop request, or the server timeout
|
||||
const std::chrono::steady_clock::time_point close_deadline =
|
||||
std::chrono::steady_clock::now() +
|
||||
std::chrono::milliseconds(SERVER_TIMEOUT_MS);
|
||||
unsigned char discard[64];
|
||||
while (!d_stop_requested.load() &&
|
||||
std::chrono::steady_clock::now() < close_deadline)
|
||||
{
|
||||
if (!wait_readable(client_socket, SOCKET_WAIT_MS))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const ssize_t count = recv(client_socket, discard,
|
||||
sizeof(discard), 0);
|
||||
if (count == 0)
|
||||
{
|
||||
d_peer_closed_cleanly = true;
|
||||
break;
|
||||
}
|
||||
if (count < 0 && errno != EINTR)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
shutdown(client_socket, SHUT_RDWR);
|
||||
close(client_socket);
|
||||
return;
|
||||
}
|
||||
d_request = received_request;
|
||||
|
||||
if (d_request.find("\r\n\r\n") != std::string::npos)
|
||||
@@ -1327,10 +1423,14 @@ TEST(NtripRtcmClientTest, V2ResponseRejectsExplicitNonCorrectionContentTypes)
|
||||
{
|
||||
using namespace ntrip_rtcm_client_test;
|
||||
|
||||
// a sourcetable answer to a mountpoint request means the mountpoint does
|
||||
// not exist, and a textual document is an error page served with status
|
||||
// 200 — neither is a correction stream
|
||||
const char* const rejected_content_types[] = {
|
||||
"gnss/sourcetable",
|
||||
"text/html; charset=utf-8",
|
||||
"application/octet-stream"};
|
||||
"text/plain",
|
||||
"application/json"};
|
||||
for (const char* const content_type : rejected_content_types)
|
||||
{
|
||||
SCOPED_TRACE(content_type);
|
||||
@@ -1348,6 +1448,31 @@ TEST(NtripRtcmClientTest, V2ResponseRejectsExplicitNonCorrectionContentTypes)
|
||||
}
|
||||
|
||||
|
||||
TEST(NtripRtcmClientTest, V2ResponseToleratesNonStandardBinaryContentTypes)
|
||||
{
|
||||
using namespace ntrip_rtcm_client_test;
|
||||
|
||||
// NTRIP 2.0 mandates gnss/data, but deployed casters also label their
|
||||
// correction streams with generic binary types: rejecting them would loop
|
||||
// the client through reject-reconnect forever with no recovery path,
|
||||
// while the RTCM decoder validates every frame anyway
|
||||
const char* const tolerated_content_types[] = {
|
||||
"application/octet-stream",
|
||||
"application/rtcm3"};
|
||||
for (const char* const content_type : tolerated_content_types)
|
||||
{
|
||||
SCOPED_TRACE(content_type);
|
||||
const V2_Response_Result response = parse_v2_response(
|
||||
std::string("HTTP/1.1 200 OK\r\nContent-Type: ") +
|
||||
content_type + "\r\n\r\nRTCM");
|
||||
EXPECT_EQ(1, response.parsed) << response.message;
|
||||
EXPECT_EQ(2, response.state);
|
||||
EXPECT_EQ(2, response.transport_state);
|
||||
EXPECT_EQ("RTCM", response.payload);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
TEST(NtripRtcmClientTest, V2ResponseAcceptsSourcetableForEmptyMountpoint)
|
||||
{
|
||||
using namespace ntrip_rtcm_client_test;
|
||||
@@ -1794,7 +1919,7 @@ TEST(NtripRtcmClientTest, AuthenticatedFragmentedStreamPublishesFixedBaseSnapsho
|
||||
config.mountpoint = "/BASE";
|
||||
config.username = TRACE_USERNAME;
|
||||
config.password = TRACE_PASSWORD;
|
||||
config.reconnect_interval_ms = 500;
|
||||
config.reconnect_interval_ms = 1000;
|
||||
config.timeout_ms = 1000;
|
||||
config.max_age_s = 1.0;
|
||||
config.station_id = TEST_STATION_ID;
|
||||
@@ -1934,12 +2059,12 @@ TEST(NtripRtcmClientTest, RoverGgaReachesTheCasterWhenEnabled)
|
||||
config.mountpoint = "/BASE";
|
||||
config.username = TRACE_USERNAME;
|
||||
config.password = TRACE_PASSWORD;
|
||||
config.reconnect_interval_ms = 500;
|
||||
config.reconnect_interval_ms = 1000;
|
||||
config.timeout_ms = 1000;
|
||||
config.max_age_s = 1.0;
|
||||
config.station_id = TEST_STATION_ID;
|
||||
config.send_gga = true;
|
||||
config.gga_period_ms = 100;
|
||||
config.gga_period_ms = 1000;
|
||||
|
||||
Ntrip_Rtcm_Client client(config);
|
||||
// A VRS caster serves no corrections until it receives the rover GGA,
|
||||
@@ -1979,7 +2104,7 @@ TEST(NtripRtcmClientTest, NtripV2ChunkedStreamPublishesFixedBaseSnapshot)
|
||||
config.mountpoint = "/BASE";
|
||||
config.username = TRACE_USERNAME;
|
||||
config.password = TRACE_PASSWORD;
|
||||
config.reconnect_interval_ms = 500;
|
||||
config.reconnect_interval_ms = 1000;
|
||||
config.timeout_ms = 1000;
|
||||
config.max_age_s = 1.0;
|
||||
config.station_id = TEST_STATION_ID;
|
||||
@@ -2109,6 +2234,140 @@ TEST(NtripRtcmClientTest, V2CloseRetriesFreshConnectionAsV1)
|
||||
}
|
||||
|
||||
|
||||
TEST(NtripRtcmClientTest, UnconfirmedV1FallbackRestoresConfiguredV2OnReconnect)
|
||||
{
|
||||
using namespace ntrip_rtcm_client_test;
|
||||
|
||||
Loopback_Ntrip_Caster caster(Loopback_Caster_Close_Mode::KEEP_OPEN,
|
||||
Loopback_Caster_Response_Mode::V2_RECOVERY_AFTER_TRANSIENT_CLOSES);
|
||||
ASSERT_TRUE(caster.start()) << caster.start_failure() << ": "
|
||||
<< std::strerror(caster.start_errno());
|
||||
|
||||
Ntrip_Rtcm_Client_Config config;
|
||||
config.enabled = true;
|
||||
config.host = "127.0.0.1";
|
||||
config.port = caster.port();
|
||||
config.mountpoint = "/BASE";
|
||||
config.username = TRACE_USERNAME;
|
||||
config.password = TRACE_PASSWORD;
|
||||
config.reconnect_interval_ms = 1000;
|
||||
config.timeout_ms = 1000;
|
||||
config.max_age_s = 1.0;
|
||||
config.station_id = TEST_STATION_ID;
|
||||
|
||||
const gtime_t rover_time = gpst2time(TEST_GPS_WEEK, TEST_TOW_S);
|
||||
Ntrip_Rtcm_Client client(config);
|
||||
client.update_rover_time(rover_time);
|
||||
ASSERT_TRUE(client.start());
|
||||
|
||||
Ntrip_Rtcm_Snapshot snapshot;
|
||||
const std::chrono::steady_clock::time_point snapshot_deadline =
|
||||
std::chrono::steady_clock::now() + std::chrono::milliseconds(3500);
|
||||
do
|
||||
{
|
||||
snapshot = client.latest_snapshot(rover_time);
|
||||
if (snapshot.has_base_position && snapshot.has_observations)
|
||||
{
|
||||
break;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
}
|
||||
while (std::chrono::steady_clock::now() < snapshot_deadline);
|
||||
|
||||
client.stop();
|
||||
caster.join();
|
||||
|
||||
ASSERT_EQ(3U, caster.accepted_connection_count());
|
||||
ASSERT_EQ(3U, caster.requests().size());
|
||||
// the v2 attempt and its immediate v1 retry both die on a zero-byte close
|
||||
EXPECT_EQ(0U, caster.requests()[0].find("GET /BASE HTTP/1.1\r\n"));
|
||||
EXPECT_NE(std::string::npos,
|
||||
caster.requests()[0].find("Ntrip-Version: Ntrip/2.0\r\n"));
|
||||
EXPECT_EQ(0U, caster.requests()[1].find("GET /BASE HTTP/1.0\r\n"));
|
||||
// the fallback never carried a stream, so the next cycle starts from the
|
||||
// configured version again instead of staying locked to v1 for the session
|
||||
EXPECT_EQ(0U, caster.requests()[2].find("GET /BASE HTTP/1.1\r\n"));
|
||||
EXPECT_NE(std::string::npos,
|
||||
caster.requests()[2].find("Ntrip-Version: Ntrip/2.0\r\n"));
|
||||
EXPECT_TRUE(snapshot.has_base_position);
|
||||
EXPECT_TRUE(snapshot.has_observations);
|
||||
}
|
||||
|
||||
|
||||
TEST(NtripRtcmClientTest, BaseLockTimeBaselineSurvivesReconnect)
|
||||
{
|
||||
using namespace ntrip_rtcm_client_test;
|
||||
|
||||
Loopback_Ntrip_Caster caster(Loopback_Caster_Close_Mode::KEEP_OPEN,
|
||||
Loopback_Caster_Response_Mode::LOCK_RESET_AFTER_RECONNECT);
|
||||
ASSERT_TRUE(caster.start()) << caster.start_failure() << ": "
|
||||
<< std::strerror(caster.start_errno());
|
||||
|
||||
Ntrip_Rtcm_Client_Config config;
|
||||
config.enabled = true;
|
||||
config.host = "127.0.0.1";
|
||||
config.port = caster.port();
|
||||
config.mountpoint = "/BASE";
|
||||
config.username = TRACE_USERNAME;
|
||||
config.password = TRACE_PASSWORD;
|
||||
config.version = 1;
|
||||
config.reconnect_interval_ms = 1000;
|
||||
config.timeout_ms = 1000;
|
||||
config.max_age_s = 5.0;
|
||||
config.station_id = TEST_STATION_ID;
|
||||
|
||||
const gtime_t rover_time = gpst2time(TEST_GPS_WEEK, TEST_TOW_S + 1.0);
|
||||
Ntrip_Rtcm_Client client(config);
|
||||
client.update_rover_time(rover_time);
|
||||
ASSERT_TRUE(client.start());
|
||||
|
||||
// the first stream must publish observations before the drop
|
||||
Ntrip_Rtcm_Snapshot baseline;
|
||||
const std::chrono::steady_clock::time_point baseline_deadline =
|
||||
std::chrono::steady_clock::now() + std::chrono::milliseconds(2000);
|
||||
do
|
||||
{
|
||||
baseline = client.latest_snapshot(rover_time);
|
||||
if (baseline.has_observations)
|
||||
{
|
||||
break;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
}
|
||||
while (std::chrono::steady_clock::now() < baseline_deadline);
|
||||
ASSERT_TRUE(baseline.has_observations);
|
||||
|
||||
// the reconnected stream carries a decreased lock-time indicator: with the
|
||||
// lock-time baseline preserved across the reconnect, the observations must
|
||||
// arrive flagged with a loss-of-lock indication
|
||||
Ntrip_Rtcm_Snapshot slipped;
|
||||
const std::chrono::steady_clock::time_point slip_deadline =
|
||||
std::chrono::steady_clock::now() + std::chrono::milliseconds(3500);
|
||||
do
|
||||
{
|
||||
slipped = client.latest_snapshot(rover_time);
|
||||
if (slipped.has_observations &&
|
||||
(slipped.observations[0].LLI[0] & 1U) != 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
}
|
||||
while (std::chrono::steady_clock::now() < slip_deadline);
|
||||
|
||||
client.stop();
|
||||
caster.join();
|
||||
|
||||
ASSERT_EQ(2U, caster.accepted_connection_count());
|
||||
ASSERT_EQ(2U, caster.requests().size());
|
||||
ASSERT_TRUE(slipped.has_observations);
|
||||
// a per-connection decoder would restart the lock table at zero and
|
||||
// report the re-locked base satellite as continuous (LLI 0)
|
||||
EXPECT_EQ(1, slipped.observations[0].LLI[0]);
|
||||
EXPECT_EQ(1, slipped.observations[0].LLI[1]);
|
||||
}
|
||||
|
||||
|
||||
TEST(NtripRtcmClientTest, V2AuthenticationFailureDoesNotDowngrade)
|
||||
{
|
||||
using namespace ntrip_rtcm_client_test;
|
||||
@@ -2169,8 +2428,8 @@ TEST(NtripRtcmClientTest, ConnectFailureBeforeRequestDoesNotDowngradeV2)
|
||||
config.host = "127.0.0.1";
|
||||
config.port = reserved_port.port();
|
||||
config.mountpoint = "/BASE";
|
||||
config.reconnect_interval_ms = 50;
|
||||
config.timeout_ms = 500;
|
||||
config.reconnect_interval_ms = 1000;
|
||||
config.timeout_ms = 1000;
|
||||
config.max_age_s = 1.0;
|
||||
config.station_id = TEST_STATION_ID;
|
||||
|
||||
@@ -2244,7 +2503,7 @@ TEST(NtripRtcmClientTest, ForcedV1ClientSendsLegacyRequest)
|
||||
config.mountpoint = "/BASE";
|
||||
config.username = TRACE_USERNAME;
|
||||
config.password = TRACE_PASSWORD;
|
||||
config.reconnect_interval_ms = 500;
|
||||
config.reconnect_interval_ms = 1000;
|
||||
config.timeout_ms = 1000;
|
||||
config.max_age_s = 1.0;
|
||||
config.station_id = TEST_STATION_ID;
|
||||
@@ -2296,7 +2555,7 @@ TEST(NtripRtcmClientTest, ZeroReconnectIntervalStopsAfterStreamDisconnect)
|
||||
config.port = caster.port();
|
||||
config.mountpoint = "BASE";
|
||||
config.reconnect_interval_ms = 0;
|
||||
config.timeout_ms = 500;
|
||||
config.timeout_ms = 1000;
|
||||
config.max_age_s = 1.0;
|
||||
config.station_id = TEST_STATION_ID;
|
||||
|
||||
@@ -2359,7 +2618,7 @@ TEST(NtripRtcmClientTest, PeerResetDuringAuthenticationDoesNotTerminateClient)
|
||||
config.username = "reset-test-user";
|
||||
config.password = "reset-test-password";
|
||||
config.reconnect_interval_ms = 0;
|
||||
config.timeout_ms = 500;
|
||||
config.timeout_ms = 1000;
|
||||
|
||||
Ntrip_Rtcm_Client client(config);
|
||||
ASSERT_TRUE(client.start());
|
||||
|
||||
@@ -589,8 +589,11 @@ TEST_F(RinexPrinterTest, ObservationRecordsReportLossOfLockIndicatorBits)
|
||||
|
||||
EXPECT_EQ(' ', phase_lli_of("G01"));
|
||||
EXPECT_EQ('1', phase_lli_of("G02"));
|
||||
EXPECT_EQ('2', phase_lli_of("G03"));
|
||||
EXPECT_EQ('3', phase_lli_of("G04"));
|
||||
// a half-cycle slip is a one-epoch event, reported through bit 0: RINEX
|
||||
// bit 1 means "half-cycle ambiguity currently unresolved", which never
|
||||
// applies to observations produced after polarity resolution
|
||||
EXPECT_EQ('1', phase_lli_of("G03"));
|
||||
EXPECT_EQ('1', phase_lli_of("G04"));
|
||||
|
||||
fs::remove(obsfile);
|
||||
fs::remove(navfile);
|
||||
|
||||
@@ -1214,6 +1214,242 @@ TEST_F(RtklibFixedBaseTest, RepeatedPhaseOutliersResetTheStaleAmbiguity)
|
||||
}
|
||||
|
||||
|
||||
TEST_F(RtklibFixedBaseTest, AFreshlyResetBiasIsAdmittedThroughTheWidenedInnovationThreshold)
|
||||
{
|
||||
using namespace rtklib_fixed_base_test_detail;
|
||||
const double base_position_geodetic[3] = {45.0 * D2R, 8.0 * D2R, 100.0};
|
||||
double base_position_ecef[3]{};
|
||||
pos2ecef(base_position_geodetic, base_position_ecef);
|
||||
const double baseline_enu_m[3] = {8.0, 4.0, 1.0};
|
||||
double baseline_ecef_m[3]{};
|
||||
enu2ecef(base_position_geodetic, baseline_enu_m, baseline_ecef_m);
|
||||
const double rover_position_ecef[3] = {
|
||||
base_position_ecef[0] + baseline_ecef_m[0],
|
||||
base_position_ecef[1] + baseline_ecef_m[1],
|
||||
base_position_ecef[2] + baseline_ecef_m[2]};
|
||||
|
||||
prcopt_t options = fixed_base_options();
|
||||
options.modear = ARMODE_OFF;
|
||||
/* keep the corrupted code row itself out of the filter, beyond even the
|
||||
widened code gate: this test is about the phase row of the fresh bias */
|
||||
options.maxinno[1] = 10.0;
|
||||
auto solver = make_solver_with_options(options);
|
||||
const std::vector<unsigned int> satellites = select_relative_satellites(
|
||||
base_position_ecef, rover_position_ecef);
|
||||
ASSERT_GE(satellites.size(), 6U);
|
||||
|
||||
/* a cycle slip re-initializes the phase bias from the code pseudorange, so
|
||||
a code error at that very epoch becomes the innovation of the fresh
|
||||
bias: above the plain threshold (100 m here), below the widened one.
|
||||
Only the second band is touched, so the single-point stage that seeds
|
||||
relpos (which reads the first-band code) stays clean */
|
||||
const unsigned int slipped_prn = satellites.back();
|
||||
constexpr int SLIP_EPOCH = 4;
|
||||
constexpr double CODE_ERROR_M = 150.0;
|
||||
|
||||
for (int epoch_index = 0; epoch_index < 10; ++epoch_index)
|
||||
{
|
||||
Synthetic_Relative_Epoch epoch = make_relative_epoch(
|
||||
*solver, epoch_index, base_position_ecef, rover_position_ecef, satellites);
|
||||
if (epoch_index == SLIP_EPOCH)
|
||||
{
|
||||
const auto observation = epoch.rover_observations.find(
|
||||
static_cast<int>(slipped_prn * 2U + 1U));
|
||||
ASSERT_NE(epoch.rover_observations.end(), observation);
|
||||
observation->second.Flag_cycle_slip = true;
|
||||
observation->second.Pseudorange_m += CODE_ERROR_M;
|
||||
}
|
||||
SCOPED_TRACE(::testing::Message() << "epoch=" << epoch_index);
|
||||
ASSERT_TRUE(solve(*solver, epoch.rover_observations, &epoch.base_snapshot));
|
||||
/* with the widened gate on the measurement-forming pass the fresh
|
||||
bias enters the filter at the slip epoch and its innovation is
|
||||
absorbed, so the stored post-fit carrier residual is small;
|
||||
without it the phase row is rejected, the residual stays at the
|
||||
full code error, and the reject counter keeps climbing through
|
||||
the next epoch */
|
||||
if (epoch_index == SLIP_EPOCH)
|
||||
{
|
||||
EXPECT_LT(std::fabs(solver->pvt_ssat[slipped_prn - 1U].resc[1]), 1.0)
|
||||
<< "fresh-bias phase innovation was not absorbed";
|
||||
}
|
||||
if (epoch_index == SLIP_EPOCH + 1)
|
||||
{
|
||||
EXPECT_EQ(0, static_cast<int>(solver->pvt_ssat[slipped_prn - 1U].rejc[1]))
|
||||
<< "fresh-bias phase row was rejected again after the slip";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
TEST_F(RtklibFixedBaseTest, ATransientOutlierDoesNotChargeTheReferenceSatellite)
|
||||
{
|
||||
using namespace rtklib_fixed_base_test_detail;
|
||||
const double base_position_geodetic[3] = {45.0 * D2R, 8.0 * D2R, 100.0};
|
||||
double base_position_ecef[3]{};
|
||||
pos2ecef(base_position_geodetic, base_position_ecef);
|
||||
const double baseline_enu_m[3] = {8.0, 4.0, 1.0};
|
||||
double baseline_ecef_m[3]{};
|
||||
enu2ecef(base_position_geodetic, baseline_enu_m, baseline_ecef_m);
|
||||
const double rover_position_ecef[3] = {
|
||||
base_position_ecef[0] + baseline_ecef_m[0],
|
||||
base_position_ecef[1] + baseline_ecef_m[1],
|
||||
base_position_ecef[2] + baseline_ecef_m[2]};
|
||||
|
||||
auto solver = make_solver_with_options(fixed_base_options());
|
||||
const std::vector<unsigned int> satellites = select_relative_satellites(
|
||||
base_position_ecef, rover_position_ecef);
|
||||
ASSERT_GE(satellites.size(), 6U);
|
||||
|
||||
const unsigned int corrupted_prn = satellites.back();
|
||||
constexpr int OUTLIER_EPOCH = 6;
|
||||
constexpr double PHASE_JUMP_M = 300.0;
|
||||
const double wavelengths_m[2] = {
|
||||
SPEED_OF_LIGHT_M_S / FREQ1,
|
||||
SPEED_OF_LIGHT_M_S / FREQ2};
|
||||
|
||||
for (int epoch_index = 0; epoch_index <= OUTLIER_EPOCH; ++epoch_index)
|
||||
{
|
||||
Synthetic_Relative_Epoch epoch = make_relative_epoch(
|
||||
*solver, epoch_index, base_position_ecef, rover_position_ecef, satellites);
|
||||
if (epoch_index == OUTLIER_EPOCH)
|
||||
{
|
||||
for (unsigned int band = 0; band < 2U; ++band)
|
||||
{
|
||||
const auto observation = epoch.rover_observations.find(
|
||||
static_cast<int>(corrupted_prn * 2U + band));
|
||||
ASSERT_NE(epoch.rover_observations.end(), observation);
|
||||
observation->second.Carrier_phase_rads +=
|
||||
PHASE_JUMP_M / wavelengths_m[band] * 2.0 * GNSS_PI;
|
||||
}
|
||||
}
|
||||
SCOPED_TRACE(::testing::Message() << "epoch=" << epoch_index);
|
||||
ASSERT_TRUE(solve(*solver, epoch.rover_observations, &epoch.base_snapshot));
|
||||
}
|
||||
|
||||
/* every rejected double difference pairs the corrupted satellite with the
|
||||
reference: only the corrupted one may be charged, or the pre-fit and
|
||||
post-fit passes of a single transient event already push a healthy
|
||||
reference to the reject-counter bias reset */
|
||||
unsigned int charged_satellites = 0U;
|
||||
for (const unsigned int prn : satellites)
|
||||
{
|
||||
const auto& state = solver->pvt_ssat[prn - 1U];
|
||||
if (state.rejc[0] != 0 || state.rejc[1] != 0)
|
||||
{
|
||||
++charged_satellites;
|
||||
EXPECT_EQ(corrupted_prn, prn);
|
||||
}
|
||||
}
|
||||
EXPECT_EQ(1U, charged_satellites);
|
||||
}
|
||||
|
||||
|
||||
TEST_F(RtklibFixedBaseTest, MinDropSatsCyclesAPoisonedSatelliteOutOfAmbiguityResolution)
|
||||
{
|
||||
using namespace rtklib_fixed_base_test_detail;
|
||||
const double base_position_geodetic[3] = {45.0 * D2R, 8.0 * D2R, 100.0};
|
||||
double base_position_ecef[3]{};
|
||||
pos2ecef(base_position_geodetic, base_position_ecef);
|
||||
const double baseline_enu_m[3] = {8.0, 4.0, 1.0};
|
||||
double baseline_ecef_m[3]{};
|
||||
enu2ecef(base_position_geodetic, baseline_enu_m, baseline_ecef_m);
|
||||
const double rover_position_ecef[3] = {
|
||||
base_position_ecef[0] + baseline_ecef_m[0],
|
||||
base_position_ecef[1] + baseline_ecef_m[1],
|
||||
base_position_ecef[2] + baseline_ecef_m[2]};
|
||||
|
||||
/* a persistent non-integer offset on one satellite's L1 carrier poisons
|
||||
every LAMBDA search that includes it, while the float solution absorbs
|
||||
it in the bias state: only the mindropsats exclusion cycling can reach
|
||||
a fixed solution, by cycling satellites out of AR until the poisoned
|
||||
one is excluded */
|
||||
prcopt_t options = fixed_base_options();
|
||||
options.modear = ARMODE_CONT;
|
||||
options.mindropsats = 5;
|
||||
auto solver = make_solver_with_options(options);
|
||||
const std::vector<unsigned int> satellites = select_relative_satellites(
|
||||
base_position_ecef, rover_position_ecef);
|
||||
ASSERT_GE(satellites.size(), 6U);
|
||||
|
||||
const unsigned int poisoned_prn = satellites.back();
|
||||
bool obtained_fix = false;
|
||||
double fixed_position_error_m = 1.0e9;
|
||||
for (int epoch_index = 0; epoch_index < 16; ++epoch_index)
|
||||
{
|
||||
Synthetic_Relative_Epoch epoch = make_relative_epoch(
|
||||
*solver, epoch_index, base_position_ecef, rover_position_ecef, satellites);
|
||||
const auto observation = epoch.rover_observations.find(
|
||||
static_cast<int>(poisoned_prn * 2U));
|
||||
ASSERT_NE(epoch.rover_observations.end(), observation);
|
||||
observation->second.Carrier_phase_rads += 0.4 * 2.0 * GNSS_PI;
|
||||
|
||||
SCOPED_TRACE(::testing::Message() << "epoch=" << epoch_index);
|
||||
ASSERT_TRUE(solve(*solver, epoch.rover_observations, &epoch.base_snapshot));
|
||||
if (solver->pvt_sol.stat == SOLQ_FIX)
|
||||
{
|
||||
obtained_fix = true;
|
||||
const double position_error_m = std::sqrt(
|
||||
std::pow(solver->pvt_sol.rr[0] - rover_position_ecef[0], 2.0) +
|
||||
std::pow(solver->pvt_sol.rr[1] - rover_position_ecef[1], 2.0) +
|
||||
std::pow(solver->pvt_sol.rr[2] - rover_position_ecef[2], 2.0));
|
||||
if (position_error_m < fixed_position_error_m)
|
||||
{
|
||||
fixed_position_error_m = position_error_m;
|
||||
}
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(obtained_fix);
|
||||
EXPECT_LT(fixed_position_error_m, 0.1);
|
||||
}
|
||||
|
||||
|
||||
TEST_F(RtklibFixedBaseTest, BaseStreamEphemerisSubstitutesForUndecodedRoverEphemeris)
|
||||
{
|
||||
using namespace rtklib_fixed_base_test_detail;
|
||||
const double base_position_geodetic[3] = {45.0 * D2R, 8.0 * D2R, 100.0};
|
||||
double base_position_ecef[3]{};
|
||||
pos2ecef(base_position_geodetic, base_position_ecef);
|
||||
const double baseline_enu_m[3] = {8.0, 4.0, 1.0};
|
||||
double baseline_ecef_m[3]{};
|
||||
enu2ecef(base_position_geodetic, baseline_enu_m, baseline_ecef_m);
|
||||
const double rover_position_ecef[3] = {
|
||||
base_position_ecef[0] + baseline_ecef_m[0],
|
||||
base_position_ecef[1] + baseline_ecef_m[1],
|
||||
base_position_ecef[2] + baseline_ecef_m[2]};
|
||||
|
||||
prcopt_t options = fixed_base_options();
|
||||
options.nf = 1;
|
||||
options.modear = ARMODE_OFF;
|
||||
auto solver = make_solver_with_options(options, false, GPS_1C);
|
||||
const std::vector<unsigned int> satellites = select_relative_satellites(
|
||||
base_position_ecef, rover_position_ecef);
|
||||
ASSERT_GE(satellites.size(), 6U);
|
||||
|
||||
/* the rover tracks this satellite but has not finished decoding its LNAV:
|
||||
the broadcast ephemeris delivered by the base stream (RTCM MT1019) must
|
||||
substitute for it instead of dropping the satellite for ~30 s */
|
||||
const unsigned int undecoded_prn = satellites.back();
|
||||
|
||||
for (int epoch_index = 0; epoch_index < 6; ++epoch_index)
|
||||
{
|
||||
Synthetic_Relative_Epoch epoch = make_relative_epoch(
|
||||
*solver, epoch_index, base_position_ecef, rover_position_ecef,
|
||||
satellites, false, true);
|
||||
solver->gps_ephemeris_map.erase(static_cast<int>(undecoded_prn));
|
||||
solver->gps_cnav_ephemeris_map.erase(static_cast<int>(undecoded_prn));
|
||||
epoch.base_snapshot.gps_ephemerides.push_back(
|
||||
eph_to_rtklib(make_relative_ephemeris(undecoded_prn)));
|
||||
|
||||
SCOPED_TRACE(::testing::Message() << "epoch=" << epoch_index);
|
||||
ASSERT_TRUE(solve(*solver, epoch.rover_observations, &epoch.base_snapshot));
|
||||
EXPECT_EQ(Rtklib_Fixed_Base_Status::APPLIED, solver->get_fixed_base_status());
|
||||
/* without the substitution the satellite is skipped and ns drops */
|
||||
EXPECT_EQ(static_cast<unsigned int>(satellites.size()),
|
||||
static_cast<unsigned int>(solver->pvt_sol.ns));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
TEST_F(RtklibFixedBaseTest, AnUnresolvedHalfCycleAmbiguityIsDeweighted)
|
||||
{
|
||||
using namespace rtklib_fixed_base_test_detail;
|
||||
@@ -1230,7 +1466,10 @@ TEST_F(RtklibFixedBaseTest, AnUnresolvedHalfCycleAmbiguityIsDeweighted)
|
||||
|
||||
/* keep the float solution: a satellite flagged as half-cycle ambiguous is
|
||||
excluded from ambiguity resolution on its own, and this exercises the
|
||||
measurement weighting instead */
|
||||
measurement weighting instead. The rover always resolves its polarity
|
||||
(a change is a one-epoch slip event), so the persistent unresolved
|
||||
state can only arrive on base observations, through the MSM half-cycle
|
||||
ambiguity indicator */
|
||||
prcopt_t options = fixed_base_options();
|
||||
options.modear = ARMODE_OFF;
|
||||
|
||||
@@ -1244,9 +1483,13 @@ TEST_F(RtklibFixedBaseTest, AnUnresolvedHalfCycleAmbiguityIsDeweighted)
|
||||
{
|
||||
Synthetic_Relative_Epoch epoch = make_relative_epoch(
|
||||
*solver, epoch_index, base_position_ecef, rover_position_ecef, satellites);
|
||||
for (auto& rover_observation : epoch.rover_observations)
|
||||
if (report_half_cycle)
|
||||
{
|
||||
rover_observation.second.Flag_half_cycle_slip = report_half_cycle;
|
||||
for (auto& base_observation : epoch.base_snapshot.observations)
|
||||
{
|
||||
base_observation.LLI[0] |= 2U;
|
||||
base_observation.LLI[1] |= 2U;
|
||||
}
|
||||
}
|
||||
|
||||
EXPECT_TRUE(solve(*solver, epoch.rover_observations, &epoch.base_snapshot));
|
||||
|
||||
+6
-6
@@ -131,11 +131,11 @@ TEST_F(RtklibPvtNtripConfigurationTest, RejectsMissingCasterOrMountpointBeforeCo
|
||||
using namespace rtklib_pvt_ntrip_configuration_test_detail;
|
||||
std::unique_ptr<InMemoryConfiguration> missing_caster = make_valid_ntrip_configuration();
|
||||
missing_caster->supersede_property("PVT.ntrip_caster_address", "");
|
||||
expect_invalid_configuration(*missing_caster, "ntrip_caster_address");
|
||||
expect_invalid_configuration(*missing_caster, "caster address");
|
||||
|
||||
std::unique_ptr<InMemoryConfiguration> missing_mountpoint = make_valid_ntrip_configuration();
|
||||
missing_mountpoint->supersede_property("PVT.ntrip_mountpoint", "");
|
||||
expect_invalid_configuration(*missing_mountpoint, "ntrip_mountpoint");
|
||||
expect_invalid_configuration(*missing_mountpoint, "mountpoint");
|
||||
}
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ TEST_F(RtklibPvtNtripConfigurationTest, RejectsUnsupportedNtripVersionBeforeConn
|
||||
using namespace rtklib_pvt_ntrip_configuration_test_detail;
|
||||
std::unique_ptr<InMemoryConfiguration> bad_version = make_valid_ntrip_configuration();
|
||||
bad_version->supersede_property("PVT.ntrip_version", "3");
|
||||
expect_invalid_configuration(*bad_version, "ntrip_version");
|
||||
expect_invalid_configuration(*bad_version, "NTRIP version");
|
||||
|
||||
std::unique_ptr<InMemoryConfiguration> forced_v1 = make_valid_ntrip_configuration();
|
||||
forced_v1->supersede_property("PVT.ntrip_version", "1");
|
||||
@@ -177,7 +177,7 @@ TEST_F(RtklibPvtNtripConfigurationTest, RejectsInvalidAgeOrTimeoutBeforeConnecti
|
||||
using namespace rtklib_pvt_ntrip_configuration_test_detail;
|
||||
std::unique_ptr<InMemoryConfiguration> bad_age = make_valid_ntrip_configuration();
|
||||
bad_age->supersede_property("PVT.ntrip_max_correction_age_s", "0");
|
||||
expect_invalid_configuration(*bad_age, "ntrip_max_correction_age_s");
|
||||
expect_invalid_configuration(*bad_age, "maximum correction age");
|
||||
|
||||
std::unique_ptr<InMemoryConfiguration> bad_timeout = make_valid_ntrip_configuration();
|
||||
bad_timeout->supersede_property("PVT.ntrip_inactivity_timeout_ms", "999");
|
||||
@@ -194,7 +194,7 @@ TEST_F(RtklibPvtNtripConfigurationTest, RejectsInvalidGgaPeriodOnlyWhenGgaIsEnab
|
||||
using namespace rtklib_pvt_ntrip_configuration_test_detail;
|
||||
std::unique_ptr<InMemoryConfiguration> bad_period = make_valid_ntrip_configuration();
|
||||
bad_period->supersede_property("PVT.ntrip_gga_period_ms", "999");
|
||||
expect_invalid_configuration(*bad_period, "ntrip_gga_period_ms");
|
||||
expect_invalid_configuration(*bad_period, "GGA period");
|
||||
|
||||
// the period is not used while the GGA upload is disabled
|
||||
std::unique_ptr<InMemoryConfiguration> gga_disabled = make_valid_ntrip_configuration();
|
||||
@@ -346,7 +346,7 @@ TEST_F(RtklibPvtNtripConfigurationTest, RejectsInvalidPasswordConfigurationBefor
|
||||
using namespace rtklib_pvt_ntrip_configuration_test_detail;
|
||||
std::unique_ptr<InMemoryConfiguration> missing_username = make_valid_ntrip_configuration();
|
||||
missing_username->set_property("PVT.ntrip_password", "secret");
|
||||
expect_invalid_configuration(*missing_username, "ntrip_username is required");
|
||||
expect_invalid_configuration(*missing_username, "username is required");
|
||||
|
||||
std::unique_ptr<InMemoryConfiguration> duplicate_password_source = make_valid_ntrip_configuration();
|
||||
duplicate_password_source->set_property("PVT.ntrip_username", "user");
|
||||
|
||||
Reference in New Issue
Block a user