Fix defects found in a deep review of the RTK and NTRIP code

This commit is contained in:
Carles Fernandez
2026-08-18 12:56:49 +02:00
parent 9accccdbaa
commit dd7931fc5b
20 changed files with 980 additions and 307 deletions
+9 -84
View File
@@ -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);
}
}
+10 -4
View File
@@ -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
+173 -116
View File
@@ -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,
+7 -5
View File
@@ -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);
+34 -20
View File
@@ -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)
{
+23 -3
View File
@@ -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;
+15 -9
View File
@@ -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]++;
+26 -4
View File
@@ -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);
+24
View File
@@ -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));
}
@@ -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));
@@ -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");