From 172e9d429016b99a8d8b4cac432ee9e258309f26 Mon Sep 17 00:00:00 2001 From: Olexandr Tyshchenko Date: Thu, 12 Nov 2026 00:34:05 +0200 Subject: [PATCH 1/2] GPS guard: reject a fix the receiver reports as 2D (GSA) The guard checked satellites and HDOP but not the fix type. When the receiver sends GSA, a fix it reports as 2D or no-fix is now rejected (`rej 2d`). Receivers without GSA are unchanged (fix mode unknown). GPS_GUARD_REQUIRE_3D=0 turns the check off. `gps diag` shows the fix mode as `fm`. --- docs/cli_commands.md | 2 +- src/helpers/sensors/GpsGuard.h | 17 +++++++++-- .../sensors/MicroNMEALocationProvider.h | 28 ++++++++++++++++++- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index bc30adbd48..01eed22699 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1078,7 +1078,7 @@ region save --- #### GPS anti-spoofing guard (When built with `GPS_GUARD`) -With `GPS_GUARD`, a fix becomes the node's last fix (position, telemetry, adverts) only after it passes plausibility checks: at least `GPS_GUARD_MIN_SATS` satellites, HDOP ≤ `GPS_GUARD_MAX_HDOP10`/10, inside the optional build-time region (`GPS_GUARD_LAT_MIN/MAX`, `GPS_GUARD_LON_MIN/MAX`), within the fence around the node's configured `lat`/`lon` (if set), no jump faster than `GPS_GUARD_MAX_SPEED_KMH`, and `GPS_GUARD_STABLE_FIXES` consistent fixes in a row. An unverified position is never reported. +With `GPS_GUARD`, a fix becomes the node's last fix (position, telemetry, adverts) only after it passes plausibility checks: at least `GPS_GUARD_MIN_SATS` satellites, HDOP ≤ `GPS_GUARD_MAX_HDOP10`/10, a 3D fix if the receiver reports the fix mode in GSA (`GPS_GUARD_REQUIRE_3D`, default on; status `rej 2d`), inside the optional build-time region (`GPS_GUARD_LAT_MIN/MAX`, `GPS_GUARD_LON_MIN/MAX`), within the fence around the node's configured `lat`/`lon` (if set), no jump faster than `GPS_GUARD_MAX_SPEED_KMH`, and `GPS_GUARD_STABLE_FIXES` consistent fixes in a row. An unverified position is never reported. GPS time is written to the clock only while such a fix is current, never earlier than the firmware build date, and, once the clock holds a plausible time (from the build date to 10 years after it), only within `GPS_GUARD_TIME_MAX_STEP` seconds for the first sync and within clock drift (`GPS_GUARD_TIME_DRIFT_BASE` s + `GPS_GUARD_TIME_DRIFT_PPM`) afterwards. Setting the clock manually (`time`, `clock sync`) resets that anchor. A clock outside that window (e.g. a bogus date in 2080) is corrected by the first trusted fix. diff --git a/src/helpers/sensors/GpsGuard.h b/src/helpers/sensors/GpsGuard.h index 46b1936caa..bcd949f484 100644 --- a/src/helpers/sensors/GpsGuard.h +++ b/src/helpers/sensors/GpsGuard.h @@ -4,7 +4,8 @@ // // A location provider asks the guard before it stores a fix as its last fix and before it // writes GPS time to the clock: -// - acceptFix(): plausibility checks (satellites, HDOP, optional build-time region, distance +// - acceptFix(): plausibility checks (satellites, HDOP, 3D fix if the receiver reports the fix +// mode, optional build-time region, distance // from a reference position, jump speed) and N consecutive consistent fixes; // - acceptTime(): GPS time only while a trusted fix is current, not before this firmware // was built, and once the clock is valid only small corrections (clock drift after the @@ -25,6 +26,9 @@ #ifndef GPS_GUARD_MAX_HDOP10 #define GPS_GUARD_MAX_HDOP10 40 // HDOP 4.0 #endif +#ifndef GPS_GUARD_REQUIRE_3D + #define GPS_GUARD_REQUIRE_3D 1 // reject a fix the receiver reports as 2D (GSA); 0 = off +#endif #ifndef GPS_GUARD_FENCE_M #define GPS_GUARD_FENCE_M 1000 // max distance from the reference position (0 = off) #endif @@ -135,8 +139,10 @@ class GpsGuard { bool isTimeAlert() const { return _time_alert; } // Fix as reported by the receiver: lat/lon in micro-degrees, HDOP in tenths (255 = unknown), - // gps_time = UTC epoch of the fix. Returns true if the fix can be stored as trusted. - bool acceptFix(long lat_e6, long lon_e6, long sats, uint8_t hdop10, uint32_t gps_time) { + // gps_time = UTC epoch of the fix, fix_mode = receiver fix mode from GSA (1 = none, 2 = 2D, + // 3 = 3D, 0 = unknown: no recent GSA). Returns true if the fix can be stored as trusted. + bool acceptFix(long lat_e6, long lon_e6, long sats, uint8_t hdop10, uint32_t gps_time, + uint8_t fix_mode = 0) { const double lat = lat_e6 / 1000000.0; const double lon = lon_e6 / 1000000.0; const unsigned long now = millis(); @@ -149,6 +155,11 @@ class GpsGuard { } else if (hdop10 == 255 || hdop10 > GPS_GUARD_MAX_HDOP10) { why = "hdop"; } +#if GPS_GUARD_REQUIRE_3D + else if (fix_mode == 1 || fix_mode == 2) { + why = "2d"; + } +#endif #if defined(GPS_GUARD_LAT_MIN) && defined(GPS_GUARD_LAT_MAX) && defined(GPS_GUARD_LON_MIN) && defined(GPS_GUARD_LON_MAX) else if (lat < GPS_GUARD_LAT_MIN || lat > GPS_GUARD_LAT_MAX || lon < GPS_GUARD_LON_MIN || lon > GPS_GUARD_LON_MAX) { why = "region"; diff --git a/src/helpers/sensors/MicroNMEALocationProvider.h b/src/helpers/sensors/MicroNMEALocationProvider.h index 09b4d95546..98f04e4ba7 100644 --- a/src/helpers/sensors/MicroNMEALocationProvider.h +++ b/src/helpers/sensors/MicroNMEALocationProvider.h @@ -54,6 +54,26 @@ class MicroNMEALocationProvider : public LocationProvider { bool _nmea_seen = false; #ifdef GPS_GUARD GpsGuard _guard; + uint8_t _gsa_fix_mode = 0; // fix mode from the last GSA: 1 = none, 2 = 2D, 3 = 3D + unsigned long _gsa_ms = 0; + + static bool isGsa(const char* id) { // GPGSA, GNGSA, BDGSA, ... + const size_t n = strlen(id); + return n >= 3 && strcmp(id + n - 3, "GSA") == 0; + } + // $--GSA,,,... + void parseGsa(const char* s) { + s = strchr(s, ','); + if (s != NULL) s = strchr(s + 1, ','); + if (s != NULL && s[1] >= '1' && s[1] <= '3') { + _gsa_fix_mode = s[1] - '0'; + _gsa_ms = millis(); + } + } + // 0 (unknown) if the receiver does not send GSA, or sent none recently + uint8_t currentFixMode() const { + return (_gsa_fix_mode != 0 && (uint32_t)(millis() - _gsa_ms) <= 2000) ? _gsa_fix_mode : 0; + } #endif static uint32_t ageMs(unsigned long timestamp) { @@ -94,7 +114,7 @@ class MicroNMEALocationProvider : public LocationProvider { #ifdef GPS_GUARD // only plausible, stable fixes become the last fix (position, telemetry, time sync) if (!_guard.acceptFix(nmea.getLatitude(), nmea.getLongitude(), nmea.getNumSatellites(), - nmea.getHDOP(), getTimestamp())) return false; + nmea.getHDOP(), getTimestamp(), currentFixMode())) return false; #endif LastFix fix; @@ -336,6 +356,7 @@ class MicroNMEALocationProvider : public LocationProvider { const char* messageId = nmea.getMessageID(); #ifdef GPS_GUARD + if (isGsa(messageId)) parseGsa(nmea.getSentence()); if (parsed && !nmea.isValid() && (strcmp(messageId, "GGA") == 0 || strcmp(messageId, "RMC") == 0)) { _guard.noFix(nmea.getNumSatellites()); @@ -395,5 +416,10 @@ class MicroNMEALocationProvider : public LocationProvider { fix_age, (unsigned long)_begin_calls, (unsigned long)_stop_calls); +#ifdef GPS_GUARD + // fix mode from GSA (1 none, 2 2D, 3 3D, 0 no recent GSA) + const size_t len = strlen(out); + if (len < out_size) snprintf(out + len, out_size - len, " fm:%u", currentFixMode()); +#endif } }; From 402abb7dbd339d89306f2ac58dd780cef1b72291 Mon Sep 17 00:00:00 2001 From: Olexandr Tyshchenko Date: Fri, 13 Nov 2026 14:30:24 +0200 Subject: [PATCH 2/2] GPS: accept a 2D fix, but keep the GPS on for a 3D fix Per review: a 2D fix is valid, only less precise, and in some places a receiver never gets a 3D fix. So the guard no longer rejects it; instead the fix mode decides when the GPS may sleep: - the fix mode is read from GSA for every NMEA receiver, not only with GPS_GUARD (`fm:` in `gps diag`), and stored with the last fix; - in periodic mode, and for `gps once`, the GPS sleeps only after a 3D fix (or a fix from a receiver that does not report the mode); after a 2D fix it stays on up to the acquisition timeout; - a 2D fix does not replace a 3D fix younger than 5 min (GPS_3D_FIX_PREFERRED_MS). --- docs/cli_commands.md | 4 +-- src/helpers/sensors/GpsGuard.h | 17 ++-------- src/helpers/sensors/LocationProvider.h | 1 + .../sensors/MicroNMEALocationProvider.h | 34 ++++++++++++++----- 4 files changed, 31 insertions(+), 25 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 01eed22699..838ab81f30 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1026,7 +1026,7 @@ region save **Parameters:** - `value`: `0` for continuous tracking, or a period. The value can be postfixed with `s`, `m`, `h` or `d` to specify the unit (seconds by default). -**Note:** A periodic acquisition stops after receiving a valid fix, then resumes after the configured period. +**Note:** A periodic acquisition stops after receiving a valid fix, then resumes after the configured period. If the receiver reports the fix mode (GSA) and the fix is 2D, the GPS stays on up to the acquisition timeout to get a 3D fix; a 2D fix is used as the position, but does not replace a 3D fix younger than 5 minutes (`GPS_3D_FIX_PREFERRED_MS`). --- @@ -1078,7 +1078,7 @@ region save --- #### GPS anti-spoofing guard (When built with `GPS_GUARD`) -With `GPS_GUARD`, a fix becomes the node's last fix (position, telemetry, adverts) only after it passes plausibility checks: at least `GPS_GUARD_MIN_SATS` satellites, HDOP ≤ `GPS_GUARD_MAX_HDOP10`/10, a 3D fix if the receiver reports the fix mode in GSA (`GPS_GUARD_REQUIRE_3D`, default on; status `rej 2d`), inside the optional build-time region (`GPS_GUARD_LAT_MIN/MAX`, `GPS_GUARD_LON_MIN/MAX`), within the fence around the node's configured `lat`/`lon` (if set), no jump faster than `GPS_GUARD_MAX_SPEED_KMH`, and `GPS_GUARD_STABLE_FIXES` consistent fixes in a row. An unverified position is never reported. +With `GPS_GUARD`, a fix becomes the node's last fix (position, telemetry, adverts) only after it passes plausibility checks: at least `GPS_GUARD_MIN_SATS` satellites, HDOP ≤ `GPS_GUARD_MAX_HDOP10`/10, inside the optional build-time region (`GPS_GUARD_LAT_MIN/MAX`, `GPS_GUARD_LON_MIN/MAX`), within the fence around the node's configured `lat`/`lon` (if set), no jump faster than `GPS_GUARD_MAX_SPEED_KMH`, and `GPS_GUARD_STABLE_FIXES` consistent fixes in a row. An unverified position is never reported. GPS time is written to the clock only while such a fix is current, never earlier than the firmware build date, and, once the clock holds a plausible time (from the build date to 10 years after it), only within `GPS_GUARD_TIME_MAX_STEP` seconds for the first sync and within clock drift (`GPS_GUARD_TIME_DRIFT_BASE` s + `GPS_GUARD_TIME_DRIFT_PPM`) afterwards. Setting the clock manually (`time`, `clock sync`) resets that anchor. A clock outside that window (e.g. a bogus date in 2080) is corrected by the first trusted fix. diff --git a/src/helpers/sensors/GpsGuard.h b/src/helpers/sensors/GpsGuard.h index bcd949f484..46b1936caa 100644 --- a/src/helpers/sensors/GpsGuard.h +++ b/src/helpers/sensors/GpsGuard.h @@ -4,8 +4,7 @@ // // A location provider asks the guard before it stores a fix as its last fix and before it // writes GPS time to the clock: -// - acceptFix(): plausibility checks (satellites, HDOP, 3D fix if the receiver reports the fix -// mode, optional build-time region, distance +// - acceptFix(): plausibility checks (satellites, HDOP, optional build-time region, distance // from a reference position, jump speed) and N consecutive consistent fixes; // - acceptTime(): GPS time only while a trusted fix is current, not before this firmware // was built, and once the clock is valid only small corrections (clock drift after the @@ -26,9 +25,6 @@ #ifndef GPS_GUARD_MAX_HDOP10 #define GPS_GUARD_MAX_HDOP10 40 // HDOP 4.0 #endif -#ifndef GPS_GUARD_REQUIRE_3D - #define GPS_GUARD_REQUIRE_3D 1 // reject a fix the receiver reports as 2D (GSA); 0 = off -#endif #ifndef GPS_GUARD_FENCE_M #define GPS_GUARD_FENCE_M 1000 // max distance from the reference position (0 = off) #endif @@ -139,10 +135,8 @@ class GpsGuard { bool isTimeAlert() const { return _time_alert; } // Fix as reported by the receiver: lat/lon in micro-degrees, HDOP in tenths (255 = unknown), - // gps_time = UTC epoch of the fix, fix_mode = receiver fix mode from GSA (1 = none, 2 = 2D, - // 3 = 3D, 0 = unknown: no recent GSA). Returns true if the fix can be stored as trusted. - bool acceptFix(long lat_e6, long lon_e6, long sats, uint8_t hdop10, uint32_t gps_time, - uint8_t fix_mode = 0) { + // gps_time = UTC epoch of the fix. Returns true if the fix can be stored as trusted. + bool acceptFix(long lat_e6, long lon_e6, long sats, uint8_t hdop10, uint32_t gps_time) { const double lat = lat_e6 / 1000000.0; const double lon = lon_e6 / 1000000.0; const unsigned long now = millis(); @@ -155,11 +149,6 @@ class GpsGuard { } else if (hdop10 == 255 || hdop10 > GPS_GUARD_MAX_HDOP10) { why = "hdop"; } -#if GPS_GUARD_REQUIRE_3D - else if (fix_mode == 1 || fix_mode == 2) { - why = "2d"; - } -#endif #if defined(GPS_GUARD_LAT_MIN) && defined(GPS_GUARD_LAT_MAX) && defined(GPS_GUARD_LON_MIN) && defined(GPS_GUARD_LON_MAX) else if (lat < GPS_GUARD_LAT_MIN || lat > GPS_GUARD_LAT_MAX || lon < GPS_GUARD_LON_MIN || lon > GPS_GUARD_LON_MAX) { why = "region"; diff --git a/src/helpers/sensors/LocationProvider.h b/src/helpers/sensors/LocationProvider.h index 5b5b69c044..8b0ed18f4a 100644 --- a/src/helpers/sensors/LocationProvider.h +++ b/src/helpers/sensors/LocationProvider.h @@ -18,6 +18,7 @@ class LocationProvider { long satellites = 0; long timestamp = 0; unsigned long acquiredAt = 0; // millis() when the fix was stored + uint8_t fixMode = 0; // from GSA: 2 = 2D, 3 = 3D, 0 = unknown }; struct FixStats { diff --git a/src/helpers/sensors/MicroNMEALocationProvider.h b/src/helpers/sensors/MicroNMEALocationProvider.h index 98f04e4ba7..b095d46e73 100644 --- a/src/helpers/sensors/MicroNMEALocationProvider.h +++ b/src/helpers/sensors/MicroNMEALocationProvider.h @@ -22,6 +22,11 @@ #define GPS_FIX_SETTLE_MS 10000UL #endif +// a 2D fix does not replace a 3D fix younger than this (it is less precise) +#ifndef GPS_3D_FIX_PREFERRED_MS + #define GPS_3D_FIX_PREFERRED_MS 300000UL +#endif + class MicroNMEALocationProvider : public LocationProvider { // MicroNMEA never writes past the length it is given, but its fixed-width field // skips can read a few bytes past the end of a malformed sentence. The zeroed tail @@ -39,6 +44,7 @@ class MicroNMEALocationProvider : public LocationProvider { unsigned long _nextAcquisition = 0; unsigned long _acquisitionStarted = 0; unsigned long _firstFixInWindow = 0; + bool _goodFixInWindow = false; // a fix that lets the GPS sleep (3D, or fix mode unknown) bool _acquiringPeriodically = false; unsigned long _onceDeadline = 0; static const unsigned long TIME_SYNC_INTERVAL = 1800000; @@ -52,8 +58,6 @@ class MicroNMEALocationProvider : public LocationProvider { unsigned long _last_nmea_ms = 0; bool _uart_seen = false; bool _nmea_seen = false; -#ifdef GPS_GUARD - GpsGuard _guard; uint8_t _gsa_fix_mode = 0; // fix mode from the last GSA: 1 = none, 2 = 2D, 3 = 3D unsigned long _gsa_ms = 0; @@ -74,6 +78,8 @@ class MicroNMEALocationProvider : public LocationProvider { uint8_t currentFixMode() const { return (_gsa_fix_mode != 0 && (uint32_t)(millis() - _gsa_ms) <= 2000) ? _gsa_fix_mode : 0; } +#ifdef GPS_GUARD + GpsGuard _guard; #endif static uint32_t ageMs(unsigned long timestamp) { @@ -96,6 +102,7 @@ class MicroNMEALocationProvider : public LocationProvider { _acquiringPeriodically = true; _acquisitionStarted = millis(); _firstFixInWindow = 0; + _goodFixInWindow = false; } void finishPeriodicAcquisition() { @@ -109,13 +116,17 @@ class MicroNMEALocationProvider : public LocationProvider { bool saveFix() { // only store a fix when the snapshot is coherent (sats > 0). - // Returns true if the fix was stored. + // Returns true if the fix was accepted (stored, or kept out by a recent 3D fix). if (nmea.getNumSatellites() == 0) return false; #ifdef GPS_GUARD // only plausible, stable fixes become the last fix (position, telemetry, time sync) if (!_guard.acceptFix(nmea.getLatitude(), nmea.getLongitude(), nmea.getNumSatellites(), - nmea.getHDOP(), getTimestamp(), currentFixMode())) return false; + nmea.getHDOP(), getTimestamp())) return false; #endif + const uint8_t mode = currentFixMode(); + // a 2D fix is valid but less precise: keep a recent 3D fix instead + if (mode == 2 && _lastFix.valid && _lastFix.fixMode == 3 && + (uint32_t)(millis() - _lastFix.acquiredAt) < GPS_3D_FIX_PREFERRED_MS) return true; LastFix fix; fix.latitude = nmea.getLatitude(); @@ -127,6 +138,7 @@ class MicroNMEALocationProvider : public LocationProvider { fix.satellites = nmea.getNumSatellites(); fix.timestamp = getTimestamp(); fix.acquiredAt = millis(); + fix.fixMode = mode; fix.valid = true; _lastFix = fix; return true; @@ -170,6 +182,7 @@ class MicroNMEALocationProvider : public LocationProvider { if (!wasActive) { _acquisitionStarted = millis(); _firstFixInWindow = 0; + _goodFixInWindow = false; } if (_gpsPeriodSeconds > 0 && _driver->isActive()) { nmea.clear(); @@ -177,6 +190,7 @@ class MicroNMEALocationProvider : public LocationProvider { _acquiringPeriodically = true; _acquisitionStarted = millis(); _firstFixInWindow = 0; + _goodFixInWindow = false; } } @@ -319,7 +333,7 @@ class MicroNMEALocationProvider : public LocationProvider { if (_driver->pollGpsSwitch()) _gpsOn = true; if (_onceDeadline != 0) { - if (_firstFixInWindow != 0 && (long)(_onceDeadline - (millis() + GPS_FIX_SETTLE_MS)) > 0) { + if (_goodFixInWindow && (long)(_onceDeadline - (millis() + GPS_FIX_SETTLE_MS)) > 0) { _onceDeadline = millis() + GPS_FIX_SETTLE_MS; } if ((long)(millis() - _onceDeadline) >= 0) { @@ -355,8 +369,8 @@ class MicroNMEALocationProvider : public LocationProvider { _nmea_seen = true; const char* messageId = nmea.getMessageID(); -#ifdef GPS_GUARD if (isGsa(messageId)) parseGsa(nmea.getSentence()); +#ifdef GPS_GUARD if (parsed && !nmea.isValid() && (strcmp(messageId, "GGA") == 0 || strcmp(messageId, "RMC") == 0)) { _guard.noFix(nmea.getNumSatellites()); @@ -369,7 +383,11 @@ class MicroNMEALocationProvider : public LocationProvider { _firstFixInWindow = millis(); fixStatsFix((uint32_t)(_firstFixInWindow - _acquisitionStarted)); } - if (_acquiringPeriodically) { + // after a 2D fix the GPS stays on (up to the acquisition timeout) + // to give the receiver a chance to get a 3D fix + const uint8_t mode = currentFixMode(); + if (mode == 0 || mode == 3) _goodFixInWindow = true; + if (_acquiringPeriodically && _goodFixInWindow) { // stay on until a pending time sync is done (or the settle time is over) const bool synced = _clock == nullptr || !_time_sync_needed; if ((nmea.getNumSatellites() >= GPS_MIN_SATS_FOR_FIX && synced) || @@ -416,10 +434,8 @@ class MicroNMEALocationProvider : public LocationProvider { fix_age, (unsigned long)_begin_calls, (unsigned long)_stop_calls); -#ifdef GPS_GUARD // fix mode from GSA (1 none, 2 2D, 3 3D, 0 no recent GSA) const size_t len = strlen(out); if (len < out_size) snprintf(out + len, out_size - len, " fm:%u", currentFixMode()); -#endif } };