diff --git a/src/main/fc/fc_msp.c b/src/main/fc/fc_msp.c
index 8ac4b36301d..a4240986382 100644
--- a/src/main/fc/fc_msp.c
+++ b/src/main/fc/fc_msp.c
@@ -3357,6 +3357,7 @@ static mspResult_e mspFcProcessInCommand(uint16_t cmdMSP, sbuf_t *src)
gpsSol.flags.validVelD = false;
gpsSol.flags.validEPE = false;
gpsSol.flags.validTime = false;
+ gpsSol.flags.validFixTime = false;
gpsSol.numSat = sbufReadU8(src);
gpsSol.llh.lat = sbufReadU32(src);
gpsSol.llh.lon = sbufReadU32(src);
@@ -4588,6 +4589,7 @@ static void readMspSimulatorValues(sbuf_t *src, const int dataSize, const uint8_
gpsSolDRV.fixType = sbufReadU8(src);
gpsSolDRV.hdop = gpsSolDRV.fixType == GPS_NO_FIX ? 9999 : 100;
gpsSolDRV.numSat = sbufReadU8(src);
+ gpsSolDRV.flags.validFixTime = false;
if (gpsSolDRV.fixType != GPS_NO_FIX) {
gpsSolDRV.flags.validVelNE = true;
diff --git a/src/main/io/crsf_sensor.c b/src/main/io/crsf_sensor.c
index c140fef6320..2fc8eae4fb1 100644
--- a/src/main/io/crsf_sensor.c
+++ b/src/main/io/crsf_sensor.c
@@ -136,6 +136,7 @@ static void crsfSensorHandleGPS(const uint8_t *payload)
gpsSolDRV.flags.validVelD = false;
gpsSolDRV.flags.validEPE = false;
gpsSolDRV.flags.validTime = false;
+ gpsSolDRV.flags.validFixTime = false;
gpsSolDRV.eph = gpsConstrainEPE(200); // default ~2m
gpsSolDRV.epv = gpsConstrainEPE(400); // default ~4m
diff --git a/src/main/io/gps.c b/src/main/io/gps.c
index 5d34c97ec3c..f83707372d5 100755
--- a/src/main/io/gps.c
+++ b/src/main/io/gps.c
@@ -264,6 +264,7 @@ void processDisableGPSFix(void)
gpsSol.flags.validVelD = false;
gpsSol.flags.validEPE = false;
gpsSol.flags.validTime = false;
+ gpsSol.flags.validFixTime = false;
gpsSol.flags.validEllipsoidAltitude = false;
gpsSol.flags.validSpeedAccuracy = false;
gpsSol.flags.validHeadingAccuracy = false;
@@ -313,6 +314,7 @@ void updateEstimatedGPSFix(void)
gpsSol.flags.validVelD = false; //do not provide velocity.z
gpsSol.flags.validEPE = true;
gpsSol.flags.validTime = false;
+ gpsSol.flags.validFixTime = false;
float speed = pidProfile()->fixedWingReferenceAirspeed;
@@ -440,6 +442,7 @@ static void gpsResetSolution(gpsSolutionData_t* gpsSol)
gpsSol->flags.validVelD = false;
gpsSol->flags.validEPE = false;
gpsSol->flags.validTime = false;
+ gpsSol->flags.validFixTime = false;
gpsSol->flags.validEllipsoidAltitude = false;
gpsSol->flags.validSpeedAccuracy = false;
gpsSol->flags.validHeadingAccuracy = false;
diff --git a/src/main/io/gps.h b/src/main/io/gps.h
index ec531a5c6ba..66e204d96e8 100755
--- a/src/main/io/gps.h
+++ b/src/main/io/gps.h
@@ -132,6 +132,7 @@ typedef struct gpsSolutionData_s {
bool validVelD;
bool validEPE; // EPH/EPV values are valid - actual accuracy
bool validTime;
+ bool validFixTime; // time is the fully resolved UTC of the same measurement as llh, with ms resolution
bool validEllipsoidAltitude;
bool validSpeedAccuracy;
bool validHeadingAccuracy;
@@ -155,6 +156,7 @@ typedef struct gpsSolutionData_s {
uint16_t hdop; // generic HDOP value (*HDOP_SCALE)
dateTime_t time; // GPS time in UTC
+ uint32_t fixTimeOfDayMs; // UTC time of day of the same measurement as llh in ms, valid when flags.validFixTime
} gpsSolutionData_t;
diff --git a/src/main/io/gps_dronecan.c b/src/main/io/gps_dronecan.c
index 2c689d88724..584767ad3be 100644
--- a/src/main/io/gps_dronecan.c
+++ b/src/main/io/gps_dronecan.c
@@ -124,6 +124,7 @@ void dronecanGPSReceiveGNSSFix(const struct uavcan_equipment_gnss_Fix * pgnssFix
// gpsSolDRV.time.millis = 0;
gpsSolDRV.flags.validTime = 0; //(pkt->fixType >= 3);
+ gpsSolDRV.flags.validFixTime = false;
gpsProcessNewDriverData();
newDataReady = true;
@@ -173,6 +174,7 @@ void dronecanGPSReceiveGNSSFix2(const struct uavcan_equipment_gnss_Fix2 * pgnssF
// gpsSolDRV.time.millis = 0;
gpsSolDRV.flags.validTime = 0; //(pkt->fixType >= 3);
+ gpsSolDRV.flags.validFixTime = false;
gpsProcessNewDriverData();
newDataReady = true;
diff --git a/src/main/io/gps_fake.c b/src/main/io/gps_fake.c
index da24e9c03c3..4d5bf87a591 100644
--- a/src/main/io/gps_fake.c
+++ b/src/main/io/gps_fake.c
@@ -80,6 +80,7 @@ void gpsFakeSet(
gpsSolDRV.flags.validVelNE = true;
gpsSolDRV.flags.validVelD = true;
gpsSolDRV.flags.validEPE = true;
+ gpsSolDRV.flags.validFixTime = false;
if (time) {
struct tm* gTime = gmtime(&time);
diff --git a/src/main/io/gps_msp.c b/src/main/io/gps_msp.c
index 222e2651e7f..0c31eb8797a 100644
--- a/src/main/io/gps_msp.c
+++ b/src/main/io/gps_msp.c
@@ -111,6 +111,7 @@ void mspGPSReceiveNewData(const uint8_t * bufferPtr, unsigned int dataSize)
gpsSolDRV.time.millis = 0;
gpsSolDRV.flags.validTime = (pkt->fixType >= 3);
+ gpsSolDRV.flags.validFixTime = false; // MSP2_SENSOR_GPS is accepted with any gps_provider
gpsProcessNewDriverData();
newDataReady = true;
diff --git a/src/main/io/gps_ublox.c b/src/main/io/gps_ublox.c
index 703242d10dd..b8ffe2fdc17 100755
--- a/src/main/io/gps_ublox.c
+++ b/src/main/io/gps_ublox.c
@@ -114,6 +114,12 @@ static bool _new_position;
// do we have new speed information?
static bool _new_speed;
+// tAcc stays around 20 s while the leap seconds are unresolved, validTime alone does not exclude that
+#define UBX_PVT_FIX_TIME_MAX_TACC_NS 1000000
+// validTime and the leap seconds can still change right after start, require a stable run of good epochs
+#define UBX_PVT_FIX_TIME_STABLE_EPOCHS 5
+static uint8_t fixTimeGoodEpochs;
+
// Need this to determine if Galileo capable only
static struct {
uint8_t supported;
@@ -626,6 +632,29 @@ static uint8_t gpsDecodeHardwareVersion(const char * szBuf, unsigned nBufSize)
return UBX_HW_VERSION_UNKNOWN;
}
+static bool isPvtFixTimeExact(const ubx_nav_pvt *pvt, uint16_t payloadLength, uint8_t fixType)
+{
+ return payloadLength >= sizeof(ubx_nav_pvt)
+ && UBX_VALID_GPS_DATE_TIME(pvt->valid)
+ && UBX_VALID_GPS_FULLY_RESOLVED(pvt->valid)
+ && (!UBX_PVT_CONFIRMED_AVAILABLE(pvt->flags2) || UBX_PVT_CONFIRMED_TIME(pvt->flags2))
+ && fixType == GPS_FIX_3D
+ && pvt->tAcc <= UBX_PVT_FIX_TIME_MAX_TACC_NS;
+}
+
+// NAV-PVT hour..sec are rounded to 1/100 s, nano (-5..+995 ms) is the signed offset from them
+static bool getPvtTimeOfDayMs(const ubx_nav_pvt *pvt, uint32_t *timeOfDayMs)
+{
+ const int32_t nanoMs = pvt->nano >= 0 ? (pvt->nano + 500000) / 1000000 : -((500000 - pvt->nano) / 1000000);
+ const int32_t ms = ((pvt->hour * 60 + pvt->min) * 60 + pvt->sec) * 1000 + nanoMs;
+ // outside hour..sec's day we cannot tell whether that day had a leap second
+ if (ms < 0 || (ms >= 86400000 && pvt->sec < 60)) {
+ return false;
+ }
+ *timeOfDayMs = ms;
+ return true;
+}
+
static bool gpsParseFrameUBLOX(void)
{
switch (_msg_id) {
@@ -638,6 +667,7 @@ static bool gpsParseFrameUBLOX(void)
gpsSolDRV.epv = gpsConstrainEPE(_buffer.posllh.vertical_accuracy / 10);
gpsSolDRV.flags.validEPE = true;
gpsSolDRV.flags.validEllipsoidAltitude = true;
+ gpsSolDRV.flags.validFixTime = false; // fixTimeOfDayMs belongs to the last NAV-PVT, not to this position
if (next_fix_type != GPS_NO_FIX)
gpsSolDRV.fixType = next_fix_type;
_new_position = true;
@@ -684,6 +714,9 @@ static bool gpsParseFrameUBLOX(void)
}
break;
case MSG_PVT:
+ if (_class != CLASS_NAV) {
+ break;
+ }
{
static int pvtCount = 0;
DEBUG_SET(DEBUG_GPS, 0, pvtCount++);
@@ -714,6 +747,12 @@ static bool gpsParseFrameUBLOX(void)
gpsSolDRV.flags.validSpeedAccuracy = true;
gpsSolDRV.flags.validHeadingAccuracy = true;
+ if (isPvtFixTimeExact(&_buffer.pvt, _payload_length, next_fix_type)) {
+ fixTimeGoodEpochs = MIN(fixTimeGoodEpochs + 1, UBX_PVT_FIX_TIME_STABLE_EPOCHS);
+ } else {
+ fixTimeGoodEpochs = 0;
+ }
+
if (UBX_VALID_GPS_DATE_TIME(_buffer.pvt.valid)) {
gpsSolDRV.time.year = _buffer.pvt.year;
gpsSolDRV.time.month = _buffer.pvt.month;
@@ -724,8 +763,11 @@ static bool gpsParseFrameUBLOX(void)
gpsSolDRV.time.millis = (uint16_t)(MAX(0, _buffer.pvt.nano) / (1000*1000));
gpsSolDRV.flags.validTime = true;
+ gpsSolDRV.flags.validFixTime = fixTimeGoodEpochs >= UBX_PVT_FIX_TIME_STABLE_EPOCHS
+ && getPvtTimeOfDayMs(&_buffer.pvt, &gpsSolDRV.fixTimeOfDayMs);
} else {
gpsSolDRV.flags.validTime = false;
+ gpsSolDRV.flags.validFixTime = false;
}
_new_position = true;
@@ -860,7 +902,7 @@ static bool gpsParseFrameUBLOX(void)
return false;
}
-static bool gpsNewFrameUBLOX(uint8_t data)
+STATIC_UNIT_TESTED bool gpsNewFrameUBLOX(uint8_t data)
{
bool parsed = false;
@@ -1320,6 +1362,8 @@ void gpsRestartUBLOX(void)
satelites[i].gnssId = 0xFF;
}
+ fixTimeGoodEpochs = 0;
+
ptSemaphoreInit(semNewDataReady);
ptRestart(ptGetHandle(gpsProtocolReceiverThread));
ptRestart(ptGetHandle(gpsProtocolStateThread));
diff --git a/src/main/io/gps_ublox.h b/src/main/io/gps_ublox.h
index 75f10901035..de9e788843d 100644
--- a/src/main/io/gps_ublox.h
+++ b/src/main/io/gps_ublox.h
@@ -61,6 +61,9 @@ STATIC_ASSERT(MAX_UBLOX_PAYLOAD_SIZE >= 256, ubx_size_too_small);
#define UBX_VALID_GPS_DATE(valid) (valid & 1 << 0)
#define UBX_VALID_GPS_TIME(valid) (valid & 1 << 1)
#define UBX_VALID_GPS_DATE_TIME(valid) (UBX_VALID_GPS_DATE(valid) && UBX_VALID_GPS_TIME(valid))
+#define UBX_VALID_GPS_FULLY_RESOLVED(valid) (valid & 1 << 2)
+#define UBX_PVT_CONFIRMED_AVAILABLE(flags2) (flags2 & 1 << 5)
+#define UBX_PVT_CONFIRMED_TIME(flags2) (flags2 & 1 << 7)
/*
* hwVersion encoding (fits in uint8_t):
@@ -436,7 +439,7 @@ typedef struct {
int32_t nano;
uint8_t fix_type;
uint8_t fix_status;
- uint8_t reserved1;
+ uint8_t flags2;
uint8_t satellites;
int32_t longitude;
int32_t latitude;
diff --git a/src/main/telemetry/crsf.c b/src/main/telemetry/crsf.c
index b30819f185f..2f80acb26b8 100755
--- a/src/main/telemetry/crsf.c
+++ b/src/main/telemetry/crsf.c
@@ -80,6 +80,8 @@
#define CRSF_MSP_BUFFER_SIZE 96
#define CRSF_MSP_LENGTH_OFFSET 1
+#define CRSF_FRAME_GPS_TIME_TAIL_PAYLOAD_SIZE 5
+
static uint8_t crsfCrc;
static bool crsfTelemetryEnabled;
static bool deviceInfoReplyPending;
@@ -231,11 +233,20 @@ uint16_t Groundspeed ( km/h / 10 )
uint16_t GPS heading ( degree / 100 )
uint16 Altitude ( meter 1000m offset )
uint8_t Satellites in use ( counter )
+Optional (extension, not part of the CRSF spec), only when the UTC time of this fix is exact
+(u-blox NAV-PVT: date/time valid, fully resolved, confirmed if available, 3D fix, tAcc <= 1 ms,
+all of it for 5 epochs in a row) and crsf_use_legacy_baro_packet is OFF:
+uint32_t UTC time of day of this fix ( ms, rounded to the nearest ms )
+uint8_t Fix type ( always 2 = 3D while this tail is present )
*/
static void crsfFrameGps(sbuf_t *dst)
{
+ // UTC time of this fix in the same frame; a separate frame cannot be paired with it on the ground
+ const bool hasFixTime = gpsSol.flags.validFixTime && gpsSol.time.year != 0
+ && !telemetryConfig()->crsf_use_legacy_baro_packet;
+ const uint8_t payloadSize = CRSF_FRAME_GPS_PAYLOAD_SIZE + (hasFixTime ? CRSF_FRAME_GPS_TIME_TAIL_PAYLOAD_SIZE : 0);
// use sbufWrite since CRC does not include frame length
- sbufWriteU8(dst, CRSF_FRAME_GPS_PAYLOAD_SIZE + CRSF_FRAME_LENGTH_TYPE_CRC);
+ sbufWriteU8(dst, payloadSize + CRSF_FRAME_LENGTH_TYPE_CRC);
crsfSerialize8(dst, CRSF_FRAMETYPE_GPS);
crsfSerialize32(dst, gpsSol.llh.lat); // CRSF and betaflight use same units for degrees
crsfSerialize32(dst, gpsSol.llh.lon);
@@ -243,6 +254,10 @@ static void crsfFrameGps(sbuf_t *dst)
crsfSerialize16(dst, DECIDEGREES_TO_CENTIDEGREES(gpsSol.groundCourse)); // gpsSol.groundCourse is 0.1 degrees, need 0.01 deg
crsfSerialize16(dst, (uint16_t)( (telemetryConfig()->crsf_use_legacy_baro_packet ? getEstimatedActualPosition(Z) : gpsSol.llh.alt ) / 100 + 1000) );
crsfSerialize8(dst, gpsSol.numSat);
+ if (hasFixTime) {
+ crsfSerialize32(dst, gpsSol.fixTimeOfDayMs);
+ crsfSerialize8(dst, gpsSol.fixType);
+ }
}
/*
diff --git a/src/test/unit/CMakeLists.txt b/src/test/unit/CMakeLists.txt
index 04a156b2298..1d18c3dbdb0 100644
--- a/src/test/unit/CMakeLists.txt
+++ b/src/test/unit/CMakeLists.txt
@@ -155,6 +155,11 @@ set_property(SOURCE osd_unittest.cc PROPERTY definitions OSD_UNIT_TEST USE_MSP_D
set_property(SOURCE gps_ublox_unittest.cc PROPERTY depends "io/gps_ublox_utils.c")
set_property(SOURCE gps_ublox_unittest.cc PROPERTY definitions GPS_UBLOX_UNIT_TEST)
+set_property(SOURCE gps_ublox_pvt_unittest.cc PROPERTY depends "io/gps_ublox.c" "io/gps_ublox_utils.c")
+
+set_property(SOURCE telemetry_crsf_unittest.cc PROPERTY depends "telemetry/crsf.c" "common/crc.c" "common/streambuf.c")
+set_property(SOURCE telemetry_crsf_unittest.cc PROPERTY definitions USE_SERIALRX_CRSF USE_TELEMETRY_CRSF)
+
set_property(SOURCE gps_null_port_unittest.cc PROPERTY depends "io/gps.c")
set_property(SOURCE gps_null_port_unittest.cc PROPERTY definitions GPS_NULL_PORT_UNIT_TEST USE_GPS_PROTO_UBLOX)
diff --git a/src/test/unit/gps_ublox_pvt_unittest.cc b/src/test/unit/gps_ublox_pvt_unittest.cc
new file mode 100644
index 00000000000..675694b34f6
--- /dev/null
+++ b/src/test/unit/gps_ublox_pvt_unittest.cc
@@ -0,0 +1,411 @@
+/*
+ * This file is part of INAV.
+ *
+ * INAV is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * INAV is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with INAV. If not, see .
+ */
+
+#include
+#include
+
+#include "gtest/gtest.h"
+#include "unittest_macros.h"
+
+extern "C" {
+#include "platform.h"
+
+#include "build/debug.h"
+
+#include "common/typeconversion.h"
+
+#include "drivers/serial.h"
+#include "drivers/time.h"
+
+#include "io/gps.h"
+#include "io/gps_private.h"
+#include "io/gps_ublox.h"
+#include "io/serial.h"
+
+bool gpsNewFrameUBLOX(uint8_t data);
+
+gpsReceiverData_t gpsState;
+gpsStatistics_t gpsStats;
+gpsSolutionData_t gpsSolDRV;
+gpsSolutionData_t gpsSol;
+gpsConfig_t gpsConfig_System;
+baudRate_e gpsToSerialBaudRate[GPS_BAUDRATE_COUNT];
+const uint32_t baudRates[] = { 0 };
+
+int32_t debug[DEBUG32_VALUE_COUNT];
+uint8_t debugMode;
+
+// the parser under test only needs these to link, the serial and state paths are not exercised
+timeMs_t millis(void) { return 0; }
+float fastA2F(const char *p) { UNUSED(p); return 0; }
+int gpsBaudRateToInt(gpsBaudRate_e baudrate) { UNUSED(baudrate); return 0; }
+uint16_t gpsConstrainEPE(uint32_t epe) { return epe; }
+uint16_t gpsConstrainHDOP(uint32_t hdop) { return hdop; }
+void gpsProcessNewDriverData(void) {}
+void gpsProcessNewSolutionData(bool timeout) { UNUSED(timeout); }
+void gpsSetProtocolTimeout(timeMs_t timeoutMs) { UNUSED(timeoutMs); }
+void gpsSetState(gpsState_e state) { UNUSED(state); }
+bool isSerialTransmitBufferEmpty(const serialPort_t *instance) { UNUSED(instance); return true; }
+void serialPrint(serialPort_t *instance, const char *str) { UNUSED(instance); UNUSED(str); }
+uint8_t serialRead(serialPort_t *instance) { UNUSED(instance); return 0; }
+uint32_t serialRxBytesWaiting(const serialPort_t *instance) { UNUSED(instance); return 0; }
+void serialSetBaudRate(serialPort_t *instance, uint32_t baudRate) { UNUSED(instance); UNUSED(baudRate); }
+void serialWriteBuf(serialPort_t *instance, const uint8_t *data, int count)
+{
+ UNUSED(instance);
+ UNUSED(data);
+ UNUSED(count);
+}
+
+char *strnstr(const char *s, const char *find, size_t slen)
+{
+ const size_t len = strlen(find);
+ for (size_t i = 0; i + len <= slen && s[i]; i++) {
+ if (strncmp(s + i, find, len) == 0) {
+ return (char *)(s + i);
+ }
+ }
+ return NULL;
+}
+}
+
+#define NAV_PVT_PAYLOAD_SIZE 92
+#define STABLE_EPOCHS 5
+
+static bool feedUbx(uint8_t msgClass, uint8_t msgId, const uint8_t *payload, uint16_t payloadLength)
+{
+ const uint8_t header[] = { PREAMBLE1, PREAMBLE2, msgClass, msgId,
+ (uint8_t)(payloadLength & 0xFF), (uint8_t)(payloadLength >> 8) };
+ uint8_t ckA = 0;
+ uint8_t ckB = 0;
+ bool parsed = false;
+
+ for (unsigned i = 0; i < sizeof(header); i++) {
+ if (i >= 2) {
+ ckA += header[i];
+ ckB += ckA;
+ }
+ parsed |= gpsNewFrameUBLOX(header[i]);
+ }
+ for (unsigned i = 0; i < payloadLength; i++) {
+ ckA += payload[i];
+ ckB += ckA;
+ parsed |= gpsNewFrameUBLOX(payload[i]);
+ }
+ parsed |= gpsNewFrameUBLOX(ckA);
+ parsed |= gpsNewFrameUBLOX(ckB);
+ return parsed;
+}
+
+static bool feedPvt(uint8_t msgClass, uint16_t payloadLength, const ubx_nav_pvt *pvt)
+{
+ uint8_t payload[NAV_PVT_PAYLOAD_SIZE] = { 0 };
+ memcpy(payload, pvt, sizeof(*pvt));
+ return feedUbx(msgClass, MSG_PVT, payload, payloadLength);
+}
+
+static bool feedPvtEpochs(uint8_t msgClass, uint16_t payloadLength, const ubx_nav_pvt *pvt, int count)
+{
+ bool parsed = true;
+ for (int i = 0; i < count; i++) {
+ parsed &= feedPvt(msgClass, payloadLength, pvt);
+ }
+ return parsed;
+}
+
+static ubx_nav_pvt validPvt(uint8_t hour, uint8_t min, uint8_t sec, int32_t nano)
+{
+ ubx_nav_pvt pvt;
+ memset(&pvt, 0, sizeof(pvt));
+ pvt.year = 2026;
+ pvt.month = 9;
+ pvt.day = 29;
+ pvt.hour = hour;
+ pvt.min = min;
+ pvt.sec = sec;
+ pvt.valid = 0x07; // validDate, validTime, fullyResolved
+ pvt.tAcc = 500000; // 500 us
+ pvt.nano = nano;
+ pvt.fix_type = FIX_3D;
+ pvt.fix_status = NAV_STATUS_FIX_VALID;
+ pvt.satellites = 12;
+ pvt.latitude = 470000000;
+ pvt.longitude = 80000000;
+ return pvt;
+}
+
+static uint32_t timeOfDayMs(uint32_t hour, uint32_t min, uint32_t sec, uint32_t ms)
+{
+ return ((hour * 60 + min) * 60 + sec) * 1000 + ms;
+}
+
+class GpsUbloxPvtTest : public ::testing::Test {
+protected:
+ void SetUp() override
+ {
+ // one epoch without a valid time resets the driver's stability counter
+ ubx_nav_pvt reset = validPvt(0, 0, 0, 0);
+ reset.valid = 0;
+ feedPvt(CLASS_NAV, NAV_PVT_PAYLOAD_SIZE, &reset);
+ memset(&gpsSolDRV, 0, sizeof(gpsSolDRV));
+ }
+
+ void expectFixTime(const ubx_nav_pvt *pvt, uint32_t expectedTimeOfDayMs)
+ {
+ EXPECT_TRUE(feedPvtEpochs(CLASS_NAV, NAV_PVT_PAYLOAD_SIZE, pvt, STABLE_EPOCHS));
+ EXPECT_TRUE(gpsSolDRV.flags.validFixTime);
+ EXPECT_EQ(expectedTimeOfDayMs, gpsSolDRV.fixTimeOfDayMs);
+ }
+
+ void expectNoFixTime(uint8_t msgClass, uint16_t payloadLength, const ubx_nav_pvt *pvt)
+ {
+ feedPvtEpochs(msgClass, payloadLength, pvt, STABLE_EPOCHS + 1);
+ EXPECT_FALSE(gpsSolDRV.flags.validFixTime);
+ }
+
+ void expectSingleEpoch(const ubx_nav_pvt *pvt, bool validFixTime, uint32_t expectedTimeOfDayMs = 0)
+ {
+ EXPECT_TRUE(feedPvt(CLASS_NAV, NAV_PVT_PAYLOAD_SIZE, pvt));
+ EXPECT_EQ(validFixTime, gpsSolDRV.flags.validFixTime);
+ if (validFixTime) {
+ EXPECT_EQ(expectedTimeOfDayMs, gpsSolDRV.fixTimeOfDayMs);
+ }
+ }
+};
+
+TEST_F(GpsUbloxPvtTest, PositiveNanoRoundsToNearestMs)
+{
+ const ubx_nav_pvt pvt = validPvt(10, 20, 30, 199999990);
+ expectFixTime(&pvt, timeOfDayMs(10, 20, 30, 200));
+ EXPECT_EQ(470000000, gpsSolDRV.llh.lat);
+ EXPECT_EQ(80000000, gpsSolDRV.llh.lon);
+}
+
+TEST_F(GpsUbloxPvtTest, SmallNegativeNanoAtSecondBoundary)
+{
+ const ubx_nav_pvt pvt = validPvt(10, 20, 31, -10);
+ expectFixTime(&pvt, timeOfDayMs(10, 20, 31, 0));
+}
+
+TEST_F(GpsUbloxPvtTest, NegativeNanoGivesPreviousSecond)
+{
+ const ubx_nav_pvt pvt = validPvt(10, 20, 31, -3000000);
+ expectFixTime(&pvt, timeOfDayMs(10, 20, 30, 997));
+}
+
+TEST_F(GpsUbloxPvtTest, NanoRoundsHalfAwayFromZero)
+{
+ ubx_nav_pvt pvt = validPvt(10, 20, 31, 0);
+ expectFixTime(&pvt, timeOfDayMs(10, 20, 31, 0));
+
+ pvt.nano = -2600000; // round toward zero would give .998
+ expectSingleEpoch(&pvt, true, timeOfDayMs(10, 20, 30, 997));
+
+ pvt.nano = -500000;
+ expectSingleEpoch(&pvt, true, timeOfDayMs(10, 20, 30, 999));
+
+ pvt.nano = -499999;
+ expectSingleEpoch(&pvt, true, timeOfDayMs(10, 20, 31, 0));
+
+ pvt.nano = 499999;
+ expectSingleEpoch(&pvt, true, timeOfDayMs(10, 20, 31, 0));
+
+ pvt.nano = 500000;
+ expectSingleEpoch(&pvt, true, timeOfDayMs(10, 20, 31, 1));
+}
+
+TEST_F(GpsUbloxPvtTest, TimeBeforeTheReportedDayIsNotExact)
+{
+ const ubx_nav_pvt pvt = validPvt(10, 20, 30, 0);
+ expectFixTime(&pvt, timeOfDayMs(10, 20, 30, 0));
+
+ // 23:59:59.997, or 23:59:60.997 after a leap second, of the previous day
+ const ubx_nav_pvt midnight = validPvt(0, 0, 0, -3000000);
+ expectSingleEpoch(&midnight, false);
+
+ // a representation limit, not a receiver problem: the stable run continues
+ expectSingleEpoch(&pvt, true, timeOfDayMs(10, 20, 30, 0));
+}
+
+TEST_F(GpsUbloxPvtTest, TimeAfterTheReportedDayIsNotExact)
+{
+ const ubx_nav_pvt pvt = validPvt(10, 20, 30, 0);
+ expectFixTime(&pvt, timeOfDayMs(10, 20, 30, 0));
+
+ const ubx_nav_pvt pastMidnight = validPvt(23, 59, 59, 999600000);
+ expectSingleEpoch(&pastMidnight, false);
+
+ const ubx_nav_pvt beforeMidnight = validPvt(23, 59, 59, 999400000);
+ expectSingleEpoch(&beforeMidnight, true, 86399999u);
+}
+
+TEST_F(GpsUbloxPvtTest, LeapSecond)
+{
+ ubx_nav_pvt pvt = validPvt(23, 59, 60, 0);
+ expectFixTime(&pvt, 86400000u);
+
+ pvt.nano = 995000000;
+ expectFixTime(&pvt, 86400995u);
+
+ pvt.nano = -3000000;
+ expectFixTime(&pvt, 86399997u);
+}
+
+TEST_F(GpsUbloxPvtTest, NotFullyResolved)
+{
+ ubx_nav_pvt pvt = validPvt(10, 20, 30, 0);
+ pvt.valid = 0x03;
+ expectNoFixTime(CLASS_NAV, NAV_PVT_PAYLOAD_SIZE, &pvt);
+ EXPECT_TRUE(gpsSolDRV.flags.validTime);
+}
+
+TEST_F(GpsUbloxPvtTest, ConfirmedTime)
+{
+ ubx_nav_pvt pvt = validPvt(10, 20, 30, 0);
+
+ pvt.flags2 = 0x20; // confirmedAvai without confirmedTime
+ expectNoFixTime(CLASS_NAV, NAV_PVT_PAYLOAD_SIZE, &pvt);
+
+ pvt.flags2 = 0xA0; // confirmedAvai and confirmedTime
+ expectFixTime(&pvt, timeOfDayMs(10, 20, 30, 0));
+
+ pvt.flags2 = 0x20;
+ expectSingleEpoch(&pvt, false);
+
+ pvt.flags2 = 0x00; // confirmation not available
+ expectFixTime(&pvt, timeOfDayMs(10, 20, 30, 0));
+}
+
+TEST_F(GpsUbloxPvtTest, WrongClassIsIgnored)
+{
+ const ubx_nav_pvt pvt = validPvt(10, 20, 30, 0);
+
+ EXPECT_FALSE(feedPvtEpochs(0x02, NAV_PVT_PAYLOAD_SIZE, &pvt, STABLE_EPOCHS)); // RXM class, same id
+ EXPECT_FALSE(gpsSolDRV.flags.validFixTime);
+ EXPECT_FALSE(gpsSolDRV.flags.validTime);
+ EXPECT_EQ(0, gpsSolDRV.llh.lat);
+ EXPECT_EQ(0, gpsSolDRV.llh.lon);
+
+ EXPECT_FALSE(feedPvtEpochs(0x29, NAV_PVT_PAYLOAD_SIZE, &pvt, STABLE_EPOCHS)); // NAV2-PVT, same id and layout
+ EXPECT_FALSE(gpsSolDRV.flags.validFixTime);
+ EXPECT_FALSE(gpsSolDRV.flags.validTime);
+ EXPECT_EQ(0, gpsSolDRV.llh.lat);
+ EXPECT_EQ(0, gpsSolDRV.llh.lon);
+}
+
+TEST_F(GpsUbloxPvtTest, ShortPayload)
+{
+ const ubx_nav_pvt pvt = validPvt(10, 20, 30, 0);
+ expectNoFixTime(CLASS_NAV, sizeof(ubx_nav_pvt) - 4, &pvt);
+ EXPECT_TRUE(gpsSolDRV.flags.validTime);
+
+ EXPECT_TRUE(feedPvtEpochs(CLASS_NAV, sizeof(ubx_nav_pvt), &pvt, STABLE_EPOCHS));
+ EXPECT_TRUE(gpsSolDRV.flags.validFixTime);
+}
+
+TEST_F(GpsUbloxPvtTest, DateOrTimeNotValid)
+{
+ ubx_nav_pvt pvt = validPvt(10, 20, 30, 0);
+
+ pvt.valid = 0x06; // no validDate
+ expectNoFixTime(CLASS_NAV, NAV_PVT_PAYLOAD_SIZE, &pvt);
+ EXPECT_FALSE(gpsSolDRV.flags.validTime);
+
+ pvt.valid = 0x05; // no validTime
+ expectNoFixTime(CLASS_NAV, NAV_PVT_PAYLOAD_SIZE, &pvt);
+ EXPECT_FALSE(gpsSolDRV.flags.validTime);
+}
+
+TEST_F(GpsUbloxPvtTest, InvalidTimeClearsFixTime)
+{
+ ubx_nav_pvt pvt = validPvt(10, 20, 30, 0);
+ expectFixTime(&pvt, timeOfDayMs(10, 20, 30, 0));
+
+ pvt.valid = 0;
+ expectSingleEpoch(&pvt, false);
+ EXPECT_FALSE(gpsSolDRV.flags.validTime);
+}
+
+TEST_F(GpsUbloxPvtTest, PosllhClearsFixTime)
+{
+ const ubx_nav_pvt pvt = validPvt(10, 20, 30, 0);
+ expectFixTime(&pvt, timeOfDayMs(10, 20, 30, 0));
+
+ ubx_nav_posllh posllh;
+ memset(&posllh, 0, sizeof(posllh));
+ posllh.latitude = 470001000;
+ posllh.longitude = 80001000;
+ ASSERT_EQ(28u, sizeof(posllh));
+ feedUbx(CLASS_NAV, MSG_POSLLH, (const uint8_t *)&posllh, sizeof(posllh));
+ EXPECT_EQ(470001000, gpsSolDRV.llh.lat);
+ EXPECT_FALSE(gpsSolDRV.flags.validFixTime);
+}
+
+TEST_F(GpsUbloxPvtTest, Requires3dFix)
+{
+ ubx_nav_pvt pvt = validPvt(10, 20, 30, 0);
+
+ pvt.fix_type = FIX_2D;
+ expectNoFixTime(CLASS_NAV, NAV_PVT_PAYLOAD_SIZE, &pvt);
+ EXPECT_EQ(GPS_FIX_2D, gpsSolDRV.fixType);
+
+ pvt.fix_type = FIX_3D;
+ pvt.fix_status = 0; // gnssFixOK not set
+ expectNoFixTime(CLASS_NAV, NAV_PVT_PAYLOAD_SIZE, &pvt);
+ EXPECT_EQ(GPS_NO_FIX, gpsSolDRV.fixType);
+}
+
+TEST_F(GpsUbloxPvtTest, RequiresTimeAccuracy)
+{
+ ubx_nav_pvt pvt = validPvt(10, 20, 30, 0);
+
+ pvt.tAcc = 2000000; // 2 ms
+ expectNoFixTime(CLASS_NAV, NAV_PVT_PAYLOAD_SIZE, &pvt);
+
+ pvt.tAcc = 1000000; // 1 ms, the limit
+ expectFixTime(&pvt, timeOfDayMs(10, 20, 30, 0));
+
+ pvt.tAcc = 0xFFFFFFFF; // largest U4, far off like unresolved leap seconds
+ expectSingleEpoch(&pvt, false);
+
+ pvt.tAcc = 500000; // 500 us
+ expectFixTime(&pvt, timeOfDayMs(10, 20, 30, 0));
+}
+
+TEST_F(GpsUbloxPvtTest, RequiresStableRunOfGoodEpochs)
+{
+ ubx_nav_pvt pvt = validPvt(10, 20, 30, 0);
+
+ for (int epoch = 1; epoch <= STABLE_EPOCHS; epoch++) {
+ EXPECT_TRUE(feedPvt(CLASS_NAV, NAV_PVT_PAYLOAD_SIZE, &pvt));
+ EXPECT_EQ(epoch == STABLE_EPOCHS, gpsSolDRV.flags.validFixTime) << "epoch " << epoch;
+ }
+ // 5 + 251 = 256 good epochs: a counter without saturation would wrap to 0 on the last one
+ for (int epoch = STABLE_EPOCHS + 1; epoch <= 256; epoch++) {
+ EXPECT_TRUE(feedPvt(CLASS_NAV, NAV_PVT_PAYLOAD_SIZE, &pvt));
+ ASSERT_TRUE(gpsSolDRV.flags.validFixTime) << "epoch " << epoch;
+ }
+
+ pvt.tAcc = 2000000; // one bad epoch resets the run
+ expectSingleEpoch(&pvt, false);
+
+ pvt.tAcc = 500000;
+ for (int epoch = 1; epoch <= STABLE_EPOCHS; epoch++) {
+ EXPECT_TRUE(feedPvt(CLASS_NAV, NAV_PVT_PAYLOAD_SIZE, &pvt));
+ EXPECT_EQ(epoch == STABLE_EPOCHS, gpsSolDRV.flags.validFixTime) << "epoch " << epoch;
+ }
+}
diff --git a/src/test/unit/target.h b/src/test/unit/target.h
index 99ae99cb6ce..1e95d8269df 100755
--- a/src/test/unit/target.h
+++ b/src/test/unit/target.h
@@ -57,3 +57,7 @@
#define TARGET_IO_PORTB 0xffff
#define TARGET_IO_PORTC 0xffff
+#include
+// glibc has no strnstr, newlib has it (see target/SITL/target.h)
+char *strnstr(const char *s, const char *find, size_t slen);
+
diff --git a/src/test/unit/telemetry_crsf_unittest.cc b/src/test/unit/telemetry_crsf_unittest.cc
new file mode 100644
index 00000000000..af50bb991e9
--- /dev/null
+++ b/src/test/unit/telemetry_crsf_unittest.cc
@@ -0,0 +1,208 @@
+/*
+ * This file is part of INAV.
+ *
+ * INAV is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * INAV is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with INAV. If not, see .
+ */
+
+#include
+#include
+
+#include "gtest/gtest.h"
+#include "unittest_macros.h"
+
+extern "C" {
+#include "platform.h"
+
+#include "common/maths.h"
+#include "common/printf.h"
+
+#include "config/feature.h"
+
+#include "fc/rc_modes.h"
+#include "fc/runtime_config.h"
+
+#include "flight/imu.h"
+
+#include "io/gps.h"
+
+#include "navigation/navigation.h"
+
+#include "rx/crsf.h"
+
+#include "sensors/battery.h"
+#include "sensors/pitotmeter.h"
+#include "sensors/sensors.h"
+#include "sensors/temperature.h"
+
+#include "telemetry/crsf.h"
+#include "telemetry/msp_shared.h"
+#include "telemetry/telemetry.h"
+
+gpsSolutionData_t gpsSol;
+telemetryConfig_t telemetryConfig_System;
+navConfig_t navConfig_System;
+attitudeEulerAngles_t attitude;
+uint32_t armingFlags;
+uint32_t stateFlags;
+uint32_t flightModeFlags;
+
+static float estimatedAltitudeCm;
+
+// only the GPS frame is under test, the other frames and the receiver link just need to link
+float getEstimatedActualPosition(int axis) { return axis == Z ? estimatedAltitudeCm : 0; }
+float getEstimatedActualVelocity(int axis) { UNUSED(axis); return 0; }
+bool feature(uint32_t mask) { UNUSED(mask); return false; }
+bool sensors(uint32_t mask) { UNUSED(mask); return false; }
+bool IS_RC_MODE_ACTIVE(boxId_e boxId) { UNUSED(boxId); return false; }
+bool navigationRequiresAngleMode(void) { return false; }
+bool isWaypointMissionRTHActive(void) { return false; }
+int32_t constrain(int32_t amt, int32_t low, int32_t high) { return amt < low ? low : (amt > high ? high : amt); }
+uint8_t calculateBatteryPercentage(void) { return 0; }
+int16_t getAmperage(void) { return 0; }
+int32_t getMAhDrawn(void) { return 0; }
+uint16_t getBatteryVoltage(void) { return 0; }
+uint16_t getBatteryAverageCellVoltage(void) { return 0; }
+float getAirspeedEstimate(void) { return 0; }
+bool getSensorTemperature(uint8_t sensorIndex, int16_t *temperature)
+{
+ UNUSED(sensorIndex);
+ UNUSED(temperature);
+ return false;
+}
+bool crsfRxIsActive(void) { return true; }
+bool crsfRxIsTelemetryBufEmpty(void) { return true; }
+void crsfRxSendTelemetryData(void) {}
+void crsfRxWriteTelemetryData(const void *data, int len) { UNUSED(data); UNUSED(len); }
+bool handleMspFrame(uint8_t *frameStart, int payloadLength)
+{
+ UNUSED(frameStart);
+ UNUSED(payloadLength);
+ return false;
+}
+bool sendMspReply(uint8_t payloadSize, mspResponseFnPtr responseFn)
+{
+ UNUSED(payloadSize);
+ UNUSED(responseFn);
+ return false;
+}
+int tfp_sprintf(char *s, const char *fmt, ...) { UNUSED(fmt); s[0] = 0; return 0; }
+}
+
+#define GPS_FRAME_SIZE 19 // sync, length, type, 15 byte payload, CRC
+#define GPS_FRAME_TAIL_SIZE 5
+
+static uint8_t crc8DvbS2(const uint8_t *data, int len)
+{
+ uint8_t crc = 0;
+ for (int i = 0; i < len; i++) {
+ crc ^= data[i];
+ for (int bit = 0; bit < 8; bit++) {
+ crc = (crc & 0x80) ? (uint8_t)((crc << 1) ^ 0xD5) : (uint8_t)(crc << 1);
+ }
+ }
+ return crc;
+}
+
+static uint32_t readU32BigEndian(const uint8_t *p)
+{
+ return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | p[3];
+}
+
+class TelemetryCrsfGpsTest : public ::testing::Test {
+protected:
+ uint8_t frame[CRSF_FRAME_SIZE_MAX];
+
+ void SetUp() override
+ {
+ memset(frame, 0, sizeof(frame));
+ memset(&telemetryConfig_System, 0, sizeof(telemetryConfig_System));
+ memset(&gpsSol, 0, sizeof(gpsSol));
+ gpsSol.llh.lat = 470000000;
+ gpsSol.llh.lon = 80000000;
+ gpsSol.llh.alt = 50000; // cm
+ gpsSol.groundSpeed = 500; // cm/s
+ gpsSol.groundCourse = 900; // decidegrees
+ gpsSol.numSat = 12;
+ gpsSol.fixType = GPS_FIX_3D;
+ gpsSol.time.year = 2026;
+ gpsSol.flags.validFixTime = true;
+ gpsSol.fixTimeOfDayMs = 0x01A2B3C4;
+ }
+
+ void expectStandardPart(void)
+ {
+ EXPECT_EQ(CRSF_TELEMETRY_SYNC_BYTE, frame[0]);
+ EXPECT_EQ(CRSF_FRAMETYPE_GPS, frame[2]);
+ EXPECT_EQ(470000000u, readU32BigEndian(&frame[3]));
+ EXPECT_EQ(80000000u, readU32BigEndian(&frame[7]));
+ EXPECT_EQ(180, (frame[11] << 8) | frame[12]); // km/h * 10
+ EXPECT_EQ(9000, (frame[13] << 8) | frame[14]); // centidegrees
+ EXPECT_EQ(1500, (frame[15] << 8) | frame[16]); // m + 1000
+ EXPECT_EQ(12, frame[17]);
+ }
+
+ void expectCrcOverTypeAndPayload(int frameSize)
+ {
+ // CRC covers type and payload: everything after sync and length, before the CRC byte
+ EXPECT_EQ(crc8DvbS2(&frame[2], frameSize - 3), frame[frameSize - 1]);
+ }
+};
+
+TEST_F(TelemetryCrsfGpsTest, TailWithExactFixTime)
+{
+ const int frameSize = getCrsfFrame(frame, CRSF_FRAMETYPE_GPS);
+
+ EXPECT_EQ(GPS_FRAME_SIZE + GPS_FRAME_TAIL_SIZE, frameSize);
+ EXPECT_EQ(22, frame[1]);
+ expectStandardPart();
+ EXPECT_EQ(0x01, frame[18]); // big-endian time of day
+ EXPECT_EQ(0xA2, frame[19]);
+ EXPECT_EQ(0xB3, frame[20]);
+ EXPECT_EQ(0xC4, frame[21]);
+ EXPECT_EQ(GPS_FIX_3D, frame[22]);
+ expectCrcOverTypeAndPayload(frameSize);
+}
+
+TEST_F(TelemetryCrsfGpsTest, NoTailWithoutExactFixTime)
+{
+ gpsSol.flags.validFixTime = false;
+ const int frameSize = getCrsfFrame(frame, CRSF_FRAMETYPE_GPS);
+
+ EXPECT_EQ(GPS_FRAME_SIZE, frameSize);
+ EXPECT_EQ(17, frame[1]);
+ expectStandardPart();
+ expectCrcOverTypeAndPayload(frameSize);
+}
+
+TEST_F(TelemetryCrsfGpsTest, NoTailWithoutYear)
+{
+ gpsSol.time.year = 0;
+ const int frameSize = getCrsfFrame(frame, CRSF_FRAMETYPE_GPS);
+
+ EXPECT_EQ(GPS_FRAME_SIZE, frameSize);
+ EXPECT_EQ(17, frame[1]);
+}
+
+TEST_F(TelemetryCrsfGpsTest, NoTailWithLegacyBaroPacket)
+{
+ // the altitude field then carries the current estimate, not the altitude of this fix
+ telemetryConfig_System.crsf_use_legacy_baro_packet = true;
+ estimatedAltitudeCm = 12300;
+ const int frameSize = getCrsfFrame(frame, CRSF_FRAMETYPE_GPS);
+
+ EXPECT_EQ(GPS_FRAME_SIZE, frameSize);
+ EXPECT_EQ(17, frame[1]);
+ EXPECT_EQ(1123, (frame[15] << 8) | frame[16]);
+ expectCrcOverTypeAndPayload(frameSize);
+}