working now with wifi HTTP server to pull down log files; TODO get real time at start-up for log names, fix RTC if batteries need replacement, adopt ChatGPT recommendations. Consider higher precision time?

This commit is contained in:
John Poole 2026-04-05 21:35:42 -07:00
commit 02721701a0
15 changed files with 2243 additions and 0 deletions

View file

@ -0,0 +1,76 @@
#pragma once
#include <Arduino.h>
#ifndef BOARD_ID
#define BOARD_ID "BOB"
#endif
#ifndef GNSS_CHIP_NAME
#define GNSS_CHIP_NAME "L76K"
#endif
#ifndef OLED_SDA
#define OLED_SDA 17
#endif
#ifndef OLED_SCL
#define OLED_SCL 18
#endif
#ifndef OLED_ADDR
#define OLED_ADDR 0x3C
#endif
#ifndef RTC_I2C_ADDR
#define RTC_I2C_ADDR 0x51
#endif
#ifndef GPS_BAUD
#define GPS_BAUD 9600
#endif
#ifndef GPS_RX_PIN
#define GPS_RX_PIN 9
#endif
#ifndef GPS_TX_PIN
#define GPS_TX_PIN 8
#endif
#ifndef FW_BUILD_UTC
#define FW_BUILD_UTC unknown
#endif
#define FIELD_QA_STR_INNER(x) #x
#define FIELD_QA_STR(x) FIELD_QA_STR_INNER(x)
namespace field_qa {
static constexpr const char* kExerciseName = "18_GPS_Field_QA";
static constexpr const char* kFirmwareVersion = FIELD_QA_STR(FW_BUILD_UTC);
static constexpr const char* kBoardId = BOARD_ID;
static constexpr const char* kGnssChip = GNSS_CHIP_NAME;
static constexpr const char* kStorageName = "SPIFFS";
static constexpr const char* kLogDir = "/";
static constexpr const char* kLogApPrefix = "GPSQA-";
static constexpr const char* kLogApPassword = "";
static constexpr uint8_t kLogApIpOctet = 23;
static constexpr uint32_t kSerialDelayMs = 4000;
static constexpr uint32_t kSamplePeriodMs = 1000;
static constexpr uint32_t kLogFlushPeriodMs = 10000;
static constexpr uint32_t kDisplayPeriodMs = 1000;
static constexpr uint32_t kStatusPeriodMs = 1000;
static constexpr uint32_t kProbeWindowL76kMs = 20000;
static constexpr uint32_t kProbeWindowUbloxMs = 45000;
static constexpr uint32_t kFixFreshMs = 5000;
static constexpr size_t kMaxLogFilesBeforePause = 5;
static constexpr uint8_t kPoorMinSatsUsed = 4;
static constexpr uint8_t kGoodMinSatsUsed = 10;
static constexpr uint8_t kExcellentMinSatsUsed = 16;
static constexpr float kMarginalHdop = 3.0f;
static constexpr float kExcellentHdop = 1.5f;
static constexpr size_t kBufferedSamples = 10;
static constexpr size_t kMaxSatellites = 64;
} // namespace field_qa

View file

@ -0,0 +1,73 @@
#include "DisplayManager.h"
#include <Wire.h>
#include "Config.h"
namespace field_qa {
namespace {
static void formatElapsed(uint32_t ms, char* out, size_t outSize) {
const uint32_t sec = ms / 1000U;
const uint32_t hh = sec / 3600U;
const uint32_t mm = (sec % 3600U) / 60U;
const uint32_t ss = sec % 60U;
snprintf(out, outSize, "%02lu:%02lu:%02lu", (unsigned long)hh, (unsigned long)mm, (unsigned long)ss);
}
} // namespace
void DisplayManager::begin() {
Wire.begin(OLED_SDA, OLED_SCL);
m_oled.setI2CAddress(OLED_ADDR << 1);
m_oled.begin();
}
void DisplayManager::drawLines(const char* l1,
const char* l2,
const char* l3,
const char* l4,
const char* l5,
const char* l6) {
m_oled.clearBuffer();
m_oled.setFont(u8g2_font_5x8_tf);
if (l1) m_oled.drawUTF8(0, 10, l1);
if (l2) m_oled.drawUTF8(0, 20, l2);
if (l3) m_oled.drawUTF8(0, 30, l3);
if (l4) m_oled.drawUTF8(0, 40, l4);
if (l5) m_oled.drawUTF8(0, 50, l5);
if (l6) m_oled.drawUTF8(0, 60, l6);
m_oled.sendBuffer();
}
void DisplayManager::showBoot(const char* line2, const char* line3) {
drawLines(kExerciseName, kFirmwareVersion, line2, line3);
}
void DisplayManager::showError(const char* line1, const char* line2) {
drawLines(kExerciseName, "ERROR", line1, line2);
}
void DisplayManager::showSample(const GnssSample& sample, const RunStats& stats) {
char l1[24];
char l2[20];
char l3[20];
char l4[20];
char l5[20];
char l6[20];
snprintf(l1, sizeof(l1), "%s %.5s", __DATE__, __TIME__);
snprintf(l2, sizeof(l2), "FIX: %s", fixTypeToString(sample.fixType));
snprintf(l3, sizeof(l3), "USED: %d/%d", sample.satsUsed < 0 ? 0 : sample.satsUsed, sample.satsInView < 0 ? 0 : sample.satsInView);
if (sample.validHdop) {
snprintf(l4, sizeof(l4), "HDOP: %.1f", sample.hdop);
} else {
snprintf(l4, sizeof(l4), "HDOP: --");
}
snprintf(l5, sizeof(l5), "Q: %s", qualityClassForSample(sample));
formatElapsed(stats.elapsedMs(millis()), l6, sizeof(l6));
drawLines(l1, l2, l3, l4, l5, l6);
}
} // namespace field_qa

View file

@ -0,0 +1,29 @@
#pragma once
#include <Arduino.h>
#include <U8g2lib.h>
#include "GnssTypes.h"
#include "RunStats.h"
namespace field_qa {
class DisplayManager {
public:
void begin();
void showBoot(const char* line2, const char* line3 = nullptr);
void showError(const char* line1, const char* line2 = nullptr);
void showSample(const GnssSample& sample, const RunStats& stats);
private:
void drawLines(const char* l1,
const char* l2 = nullptr,
const char* l3 = nullptr,
const char* l4 = nullptr,
const char* l5 = nullptr,
const char* l6 = nullptr);
U8G2_SH1106_128X64_NONAME_F_HW_I2C m_oled{U8G2_R0, U8X8_PIN_NONE};
};
} // namespace field_qa

View file

@ -0,0 +1,488 @@
#include "GnssManager.h"
#include <ctype.h>
#include <math.h>
#include <string.h>
#include "Config.h"
namespace field_qa {
namespace {
enum class GpsModuleKind : uint8_t {
Unknown = 0,
L76K,
Ublox
};
#if defined(GPS_UBLOX)
static constexpr GpsModuleKind kExpectedGpsModule = GpsModuleKind::Ublox;
#elif defined(GPS_L76K)
static constexpr GpsModuleKind kExpectedGpsModule = GpsModuleKind::L76K;
#else
static constexpr GpsModuleKind kExpectedGpsModule = GpsModuleKind::Unknown;
#endif
static GpsModuleKind talkerToConstellation(const char* talker) {
if (!talker) return GpsModuleKind::Unknown;
if (strcmp(talker, "GP") == 0) return GpsModuleKind::L76K;
if (strcmp(talker, "GA") == 0) return GpsModuleKind::Ublox;
return GpsModuleKind::Unknown;
}
static FixType fixTypeFromQuality(int quality, int dimension) {
switch (quality) {
case 2:
return FixType::Dgps;
case 4:
return FixType::RtkFixed;
case 5:
return FixType::RtkFloat;
default:
if (dimension >= 3) return FixType::Fix3D;
if (dimension == 2) return FixType::Fix2D;
return FixType::NoFix;
}
}
static void copyTalker(const char* header, char* out) {
if (!header || strlen(header) < 3) {
out[0] = '?';
out[1] = '?';
out[2] = '\0';
return;
}
out[0] = header[1];
out[1] = header[2];
out[2] = '\0';
}
} // namespace
void GnssManager::begin() {
m_bootMs = millis();
strlcpy(m_detectedChip, kGnssChip, sizeof(m_detectedChip));
#ifdef GPS_1PPS_PIN
pinMode(GPS_1PPS_PIN, INPUT);
#endif
#ifdef GPS_WAKEUP_PIN
pinMode(GPS_WAKEUP_PIN, INPUT);
#endif
startUart(GPS_BAUD, GPS_RX_PIN, GPS_TX_PIN);
}
void GnssManager::startUart(uint32_t baud, int rxPin, int txPin) {
m_serial.end();
delay(20);
m_serial.setRxBufferSize(2048);
m_serial.begin(baud, SERIAL_8N1, rxPin, txPin);
}
bool GnssManager::collectTraffic(uint32_t windowMs) {
uint32_t start = millis();
bool sawBytes = false;
while ((uint32_t)(millis() - start) < windowMs) {
if (m_serial.available() > 0) {
sawBytes = true;
}
poll();
delay(2);
}
return sawBytes || m_sawSentence;
}
bool GnssManager::probeAtBaud(uint32_t baud, int rxPin, int txPin) {
startUart(baud, rxPin, txPin);
if (collectTraffic(700)) {
return true;
}
m_serial.write("$PCAS06,0*1B\r\n");
m_serial.write("$PMTK605*31\r\n");
m_serial.write("$PQTMVERNO*58\r\n");
m_serial.write("$PUBX,00*33\r\n");
m_serial.write("$PMTK353,1,1,1,1,1*2A\r\n");
m_serial.write("$PMTK314,0,1,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0*29\r\n");
return collectTraffic(1200);
}
bool GnssManager::probeAtStartup(Stream& serialOut) {
const uint32_t bauds[] = {GPS_BAUD, 115200, 38400, 57600, 19200};
int pins[2][2] = {{GPS_RX_PIN, GPS_TX_PIN}, {34, 12}};
size_t pinCount = (kExpectedGpsModule == GpsModuleKind::Ublox && !(GPS_RX_PIN == 34 && GPS_TX_PIN == 12)) ? 2 : 1;
for (size_t p = 0; p < pinCount; ++p) {
for (size_t i = 0; i < sizeof(bauds) / sizeof(bauds[0]); ++i) {
if (probeAtBaud(bauds[i], pins[p][0], pins[p][1])) {
serialOut.printf("GPS traffic detected at baud=%lu rx=%d tx=%d\n",
(unsigned long)bauds[i], pins[p][0], pins[p][1]);
return true;
}
}
}
serialOut.println("WARNING: no GPS traffic detected during startup probe");
return false;
}
bool GnssManager::parseUInt2(const char* s, uint8_t& out) {
if (!s || !isdigit((unsigned char)s[0]) || !isdigit((unsigned char)s[1])) {
return false;
}
out = (uint8_t)((s[0] - '0') * 10 + (s[1] - '0'));
return true;
}
double GnssManager::parseNmeaCoord(const char* value, const char* hemi) {
if (!value || !value[0] || !hemi || !hemi[0]) {
return 0.0;
}
double raw = atof(value);
double deg = floor(raw / 100.0);
double minutes = raw - (deg * 100.0);
double result = deg + minutes / 60.0;
if (hemi[0] == 'S' || hemi[0] == 'W') {
result = -result;
}
return result;
}
int GnssManager::splitCsvPreserveEmpty(char* line, char* fields[], int maxFields) {
if (!line || !fields || maxFields <= 0) {
return 0;
}
int count = 0;
char* p = line;
fields[count++] = p;
while (*p && count < maxFields) {
if (*p == ',') {
*p = '\0';
fields[count++] = p + 1;
}
++p;
}
return count;
}
void GnssManager::parseGga(char* fields[], int count) {
if (count < 10) {
return;
}
const int quality = atoi(fields[6]);
const int satsUsed = atoi(fields[7]);
if (satsUsed >= 0) {
m_state.satsUsed = satsUsed;
}
if (fields[8] && fields[8][0]) {
m_state.hdop = atof(fields[8]);
m_state.validHdop = true;
}
if (fields[9] && fields[9][0]) {
m_state.altitudeM = atof(fields[9]);
m_state.validAltitude = true;
}
if (fields[2] && fields[2][0] && fields[4] && fields[4][0]) {
m_state.latitude = parseNmeaCoord(fields[2], fields[3]);
m_state.longitude = parseNmeaCoord(fields[4], fields[5]);
m_state.validLocation = true;
}
if (quality > 0) {
m_state.validFix = true;
m_lastFixMs = millis();
} else {
m_state.validFix = false;
}
m_state.fixType = fixTypeFromQuality(quality, m_state.fixDimension);
}
void GnssManager::parseGsa(char* fields[], int count) {
if (count < 18) {
return;
}
const int dim = atoi(fields[2]);
m_state.fixDimension = dim;
if (count > 15 && fields[15] && fields[15][0]) {
m_state.pdop = atof(fields[15]);
m_state.validPdop = true;
}
if (count > 16 && fields[16] && fields[16][0]) {
m_state.hdop = atof(fields[16]);
m_state.validHdop = true;
}
if (count > 17 && fields[17] && fields[17][0]) {
m_state.vdop = atof(fields[17]);
m_state.validVdop = true;
}
int satsUsed = 0;
m_usedPrnCount = 0;
for (int i = 3; i <= 14 && i < count; ++i) {
if (fields[i] && fields[i][0]) {
++satsUsed;
if (m_usedPrnCount < sizeof(m_usedPrns) / sizeof(m_usedPrns[0])) {
m_usedPrns[m_usedPrnCount++] = (uint8_t)atoi(fields[i]);
}
}
}
if (satsUsed > 0) {
m_state.satsUsed = satsUsed;
}
if (dim >= 2) {
m_state.validFix = true;
m_lastFixMs = millis();
}
m_state.fixType = fixTypeFromQuality(m_state.validFix ? 1 : 0, dim);
}
void GnssManager::clearSatelliteView() {
m_satCount = 0;
for (size_t i = 0; i < kMaxSatellites; ++i) {
m_satellites[i] = SatelliteInfo{};
}
m_state.gpsCount = 0;
m_state.galileoCount = 0;
m_state.glonassCount = 0;
m_state.beidouCount = 0;
m_state.navicCount = 0;
m_state.qzssCount = 0;
m_state.sbasCount = 0;
m_state.meanSnr = -1.0f;
m_state.maxSnr = 0;
}
void GnssManager::finalizeSatelliteStats() {
uint32_t snrSum = 0;
uint32_t snrCount = 0;
for (size_t i = 0; i < m_satCount; ++i) {
SatelliteInfo& sat = m_satellites[i];
if (!sat.valid) {
continue;
}
sat.usedInSolution = prnUsedInSolution(sat.prn);
if (strcmp(sat.talker, "GP") == 0 || strcmp(sat.talker, "GN") == 0) {
++m_state.gpsCount;
} else if (strcmp(sat.talker, "GA") == 0) {
++m_state.galileoCount;
} else if (strcmp(sat.talker, "GL") == 0) {
++m_state.glonassCount;
} else if (strcmp(sat.talker, "GB") == 0 || strcmp(sat.talker, "BD") == 0) {
++m_state.beidouCount;
} else if (strcmp(sat.talker, "GI") == 0) {
++m_state.navicCount;
} else if (strcmp(sat.talker, "GQ") == 0) {
++m_state.qzssCount;
} else if (strcmp(sat.talker, "GS") == 0) {
++m_state.sbasCount;
}
if (sat.snr > 0) {
snrSum += sat.snr;
++snrCount;
if (sat.snr > m_state.maxSnr) {
m_state.maxSnr = sat.snr;
}
}
}
m_state.meanSnr = snrCount > 0 ? ((float)snrSum / (float)snrCount) : -1.0f;
}
void GnssManager::parseGsv(char* fields[], int count) {
if (count < 4) {
return;
}
const int totalMsgs = atoi(fields[1]);
const int msgNum = atoi(fields[2]);
const int satsInView = atoi(fields[3]);
if (msgNum == 1) {
clearSatelliteView();
}
if (satsInView >= 0) {
m_state.satsInView = satsInView;
}
char talker[3];
copyTalker(fields[0], talker);
for (int i = 4; i + 3 < count && m_satCount < kMaxSatellites; i += 4) {
if (!fields[i] || !fields[i][0]) {
continue;
}
SatelliteInfo& sat = m_satellites[m_satCount++];
sat.valid = true;
sat.talker[0] = talker[0];
sat.talker[1] = talker[1];
sat.talker[2] = '\0';
sat.prn = (uint8_t)atoi(fields[i]);
sat.usedInSolution = prnUsedInSolution(sat.prn);
sat.elevation = (uint8_t)atoi(fields[i + 1]);
sat.azimuth = (uint16_t)atoi(fields[i + 2]);
sat.snr = (uint8_t)atoi(fields[i + 3]);
}
if (msgNum == totalMsgs) {
finalizeSatelliteStats();
}
m_lastGsvMs = millis();
}
bool GnssManager::prnUsedInSolution(uint8_t prn) const {
for (size_t i = 0; i < m_usedPrnCount; ++i) {
if (m_usedPrns[i] == prn) {
return true;
}
}
return false;
}
void GnssManager::parseRmc(char* fields[], int count) {
if (count < 10) {
return;
}
const char* utc = fields[1];
const char* status = fields[2];
if (status && status[0] == 'A') {
m_state.validFix = true;
m_lastFixMs = millis();
}
if (utc && strlen(utc) >= 6 && fields[9] && strlen(fields[9]) >= 6) {
uint8_t hh = 0, mm = 0, ss = 0, dd = 0, mo = 0, yy = 0;
if (parseUInt2(utc + 0, hh) && parseUInt2(utc + 2, mm) && parseUInt2(utc + 4, ss) &&
parseUInt2(fields[9] + 0, dd) && parseUInt2(fields[9] + 2, mo) && parseUInt2(fields[9] + 4, yy)) {
m_state.hour = hh;
m_state.minute = mm;
m_state.second = ss;
m_state.day = dd;
m_state.month = mo;
m_state.year = (uint16_t)(2000U + yy);
m_state.validTime = true;
}
}
if (fields[3] && fields[3][0] && fields[5] && fields[5][0]) {
m_state.latitude = parseNmeaCoord(fields[3], fields[4]);
m_state.longitude = parseNmeaCoord(fields[5], fields[6]);
m_state.validLocation = true;
}
if (fields[7] && fields[7][0]) {
m_state.speedMps = (float)(atof(fields[7]) * 0.514444);
m_state.validSpeed = true;
}
if (fields[8] && fields[8][0]) {
m_state.courseDeg = atof(fields[8]);
m_state.validCourse = true;
}
}
void GnssManager::parseVtg(char* fields[], int count) {
if (count > 1 && fields[1] && fields[1][0]) {
m_state.courseDeg = atof(fields[1]);
m_state.validCourse = true;
}
if (count > 7 && fields[7] && fields[7][0]) {
m_state.speedMps = (float)(atof(fields[7]) / 3.6);
m_state.validSpeed = true;
}
}
void GnssManager::parseTxt(char* fields[], int count) {
if (count <= 4 || !fields[4]) {
return;
}
String text(fields[4]);
text.toUpperCase();
if (text.indexOf("L76K") >= 0 || text.indexOf("QUECTEL") >= 0) {
strlcpy(m_detectedChip, "L76K", sizeof(m_detectedChip));
}
}
void GnssManager::processNmeaLine(char* line) {
if (!line || line[0] != '$') {
return;
}
m_sawSentence = true;
m_state.sawSentence = true;
char* star = strchr(line, '*');
if (star) {
*star = '\0';
}
char* fields[32] = {0};
int count = splitCsvPreserveEmpty(line, fields, 32);
if (count <= 0 || !fields[0]) {
return;
}
if (strcmp(fields[0], "$PUBX") == 0) {
m_seenUbloxPubx = true;
strlcpy(m_detectedChip, "MAX-M10S", sizeof(m_detectedChip));
return;
}
size_t n = strlen(fields[0]);
if (n < 6) {
return;
}
const char* type = fields[0] + (n - 3);
if (strcmp(type, "GGA") == 0) {
parseGga(fields, count);
} else if (strcmp(type, "GSA") == 0) {
parseGsa(fields, count);
} else if (strcmp(type, "GSV") == 0) {
parseGsv(fields, count);
} else if (strcmp(type, "RMC") == 0) {
parseRmc(fields, count);
} else if (strcmp(type, "VTG") == 0) {
parseVtg(fields, count);
} else if (strcmp(type, "TXT") == 0) {
parseTxt(fields, count);
}
}
void GnssManager::poll() {
#ifdef GPS_1PPS_PIN
m_hasPps = (digitalRead(GPS_1PPS_PIN) == HIGH);
#endif
while (m_serial.available() > 0) {
char c = (char)m_serial.read();
if (c == '\r') {
continue;
}
if (c == '\n') {
if (m_lineLen > 0) {
m_line[m_lineLen] = '\0';
processNmeaLine(m_line);
m_lineLen = 0;
}
continue;
}
if (m_lineLen + 1 < sizeof(m_line)) {
m_line[m_lineLen++] = c;
} else {
m_lineLen = 0;
}
}
}
GnssSample GnssManager::makeSample() const {
GnssSample sample = m_state;
sample.ppsSeen = m_hasPps;
sample.sampleMillis = millis();
if (m_lastFixMs > 0) {
sample.ageOfFixMs = millis() - m_lastFixMs;
}
sample.ttffMs = (m_lastFixMs > 0) ? (m_lastFixMs - m_bootMs) : 0;
if (sample.fixType == FixType::NoFix) {
if (sample.fixDimension >= 3) {
sample.fixType = FixType::Fix3D;
} else if (sample.fixDimension == 2) {
sample.fixType = FixType::Fix2D;
}
}
return sample;
}
size_t GnssManager::copySatellites(SatelliteInfo* out, size_t maxCount) const {
if (!out || maxCount == 0) {
return 0;
}
size_t n = m_satCount < maxCount ? m_satCount : maxCount;
for (size_t i = 0; i < n; ++i) {
out[i] = m_satellites[i];
}
return n;
}
const char* GnssManager::detectedChipName() const {
return m_detectedChip;
}
} // namespace field_qa

View file

@ -0,0 +1,54 @@
#pragma once
#include <Arduino.h>
#include "Config.h"
#include "GnssTypes.h"
namespace field_qa {
class GnssManager {
public:
void begin();
void poll();
bool probeAtStartup(Stream& serialOut);
GnssSample makeSample() const;
size_t copySatellites(SatelliteInfo* out, size_t maxCount) const;
const char* detectedChipName() const;
private:
void startUart(uint32_t baud, int rxPin, int txPin);
bool probeAtBaud(uint32_t baud, int rxPin, int txPin);
bool collectTraffic(uint32_t windowMs);
void processNmeaLine(char* line);
void parseGga(char* fields[], int count);
void parseGsa(char* fields[], int count);
void parseGsv(char* fields[], int count);
void parseRmc(char* fields[], int count);
void parseVtg(char* fields[], int count);
void parseTxt(char* fields[], int count);
int splitCsvPreserveEmpty(char* line, char* fields[], int maxFields);
static bool parseUInt2(const char* s, uint8_t& out);
static double parseNmeaCoord(const char* value, const char* hemi);
void clearSatelliteView();
void finalizeSatelliteStats();
bool prnUsedInSolution(uint8_t prn) const;
HardwareSerial m_serial{1};
char m_line[160] = {0};
size_t m_lineLen = 0;
char m_detectedChip[16] = {0};
bool m_sawSentence = false;
bool m_seenUbloxPubx = false;
bool m_hasPps = false;
GnssSample m_state;
SatelliteInfo m_satellites[kMaxSatellites];
uint8_t m_usedPrns[16] = {0};
size_t m_usedPrnCount = 0;
size_t m_satCount = 0;
uint32_t m_lastGsvMs = 0;
uint32_t m_lastFixMs = 0;
uint32_t m_bootMs = 0;
};
} // namespace field_qa

View file

@ -0,0 +1,41 @@
#include "GnssTypes.h"
#include "Config.h"
namespace field_qa {
const char* fixTypeToString(FixType type) {
switch (type) {
case FixType::Fix2D:
return "2D";
case FixType::Fix3D:
return "3D";
case FixType::Dgps:
return "DGPS";
case FixType::RtkFloat:
return "RTK_FLOAT";
case FixType::RtkFixed:
return "RTK_FIXED";
case FixType::NoFix:
default:
return "NO_FIX";
}
}
const char* qualityClassForSample(const GnssSample& sample) {
if (!sample.validFix || sample.fixDimension < 2 || sample.satsUsed < (int)kPoorMinSatsUsed ||
(!sample.validHdop && sample.fixDimension < 3)) {
return "POOR";
}
if (sample.fixDimension < 3 || sample.satsUsed < (int)kGoodMinSatsUsed ||
(sample.validHdop && sample.hdop >= kMarginalHdop)) {
return "MARGINAL";
}
if (sample.fixDimension >= 3 && sample.satsUsed >= (int)kExcellentMinSatsUsed &&
sample.validHdop && sample.hdop < kExcellentHdop) {
return "EXCELLENT";
}
return "GOOD";
}
} // namespace field_qa

View file

@ -0,0 +1,78 @@
#pragma once
#include <Arduino.h>
namespace field_qa {
enum class FixType : uint8_t {
NoFix = 0,
Fix2D,
Fix3D,
Dgps,
RtkFloat,
RtkFixed
};
struct SatelliteInfo {
bool valid = false;
bool usedInSolution = false;
char talker[3] = {'?', '?', '\0'};
uint8_t prn = 0;
uint8_t elevation = 0;
uint16_t azimuth = 0;
uint8_t snr = 0;
};
struct GnssSample {
bool sawSentence = false;
bool validTime = false;
bool validFix = false;
bool validLocation = false;
bool validAltitude = false;
bool validCourse = false;
bool validSpeed = false;
bool validHdop = false;
bool validVdop = false;
bool validPdop = false;
bool ppsSeen = false;
FixType fixType = FixType::NoFix;
int fixDimension = 0;
int satsInView = -1;
int satsUsed = -1;
float hdop = -1.0f;
float vdop = -1.0f;
float pdop = -1.0f;
double latitude = 0.0;
double longitude = 0.0;
double altitudeM = 0.0;
float speedMps = -1.0f;
float courseDeg = -1.0f;
uint16_t year = 0;
uint8_t month = 0;
uint8_t day = 0;
uint8_t hour = 0;
uint8_t minute = 0;
uint8_t second = 0;
uint8_t gpsCount = 0;
uint8_t galileoCount = 0;
uint8_t glonassCount = 0;
uint8_t beidouCount = 0;
uint8_t navicCount = 0;
uint8_t qzssCount = 0;
uint8_t sbasCount = 0;
float meanSnr = -1.0f;
uint8_t maxSnr = 0;
uint32_t ageOfFixMs = 0;
uint32_t ttffMs = 0;
uint32_t longestNoFixMs = 0;
uint32_t sampleMillis = 0;
};
const char* fixTypeToString(FixType type);
const char* qualityClassForSample(const GnssSample& sample);
} // namespace field_qa

View file

@ -0,0 +1,53 @@
#include "RunStats.h"
namespace field_qa {
void RunStats::begin(uint32_t nowMs) {
m_bootMs = nowMs;
m_noFixStartMs = nowMs;
m_longestNoFixMs = 0;
m_ttffMs = 0;
m_started = true;
m_haveFirstFix = false;
}
void RunStats::updateFromSample(const GnssSample& sample, uint32_t nowMs) {
if (!m_started) {
begin(nowMs);
}
if (sample.validFix) {
if (!m_haveFirstFix) {
m_ttffMs = nowMs - m_bootMs;
m_haveFirstFix = true;
}
if (m_noFixStartMs != 0) {
uint32_t noFixMs = nowMs - m_noFixStartMs;
if (noFixMs > m_longestNoFixMs) {
m_longestNoFixMs = noFixMs;
}
m_noFixStartMs = 0;
}
} else if (m_noFixStartMs == 0) {
m_noFixStartMs = nowMs;
}
}
uint32_t RunStats::elapsedMs(uint32_t nowMs) const {
return m_started ? (nowMs - m_bootMs) : 0;
}
uint32_t RunStats::longestNoFixMs() const {
return m_longestNoFixMs;
}
uint32_t RunStats::ttffMs() const {
return m_ttffMs;
}
bool RunStats::hasFirstFix() const {
return m_haveFirstFix;
}
} // namespace field_qa

View file

@ -0,0 +1,27 @@
#pragma once
#include <Arduino.h>
#include "GnssTypes.h"
namespace field_qa {
class RunStats {
public:
void begin(uint32_t nowMs);
void updateFromSample(const GnssSample& sample, uint32_t nowMs);
uint32_t elapsedMs(uint32_t nowMs) const;
uint32_t longestNoFixMs() const;
uint32_t ttffMs() const;
bool hasFirstFix() const;
private:
uint32_t m_bootMs = 0;
uint32_t m_noFixStartMs = 0;
uint32_t m_longestNoFixMs = 0;
uint32_t m_ttffMs = 0;
bool m_started = false;
bool m_haveFirstFix = false;
};
} // namespace field_qa

View file

@ -0,0 +1,481 @@
#include "StorageManager.h"
#include "Config.h"
#include "GnssTypes.h"
namespace field_qa {
namespace {
static constexpr char kLogFieldDelimiter = ',';
static bool isRecognizedLogName(const String& name) {
return name.endsWith(".csv") || name.endsWith(".tsv");
}
static String formatFloat(float value, bool valid, uint8_t decimals = 1) {
if (!valid) {
return "";
}
return String(value, (unsigned int)decimals);
}
static String formatDouble(double value, bool valid, uint8_t decimals = 6) {
if (!valid) {
return "";
}
return String(value, (unsigned int)decimals);
}
static String sampleTimestamp(const GnssSample& sample) {
if (!sample.validTime) {
return "";
}
char buf[24];
snprintf(buf,
sizeof(buf),
"%04u-%02u-%02uT%02u:%02u:%02uZ",
(unsigned)sample.year,
(unsigned)sample.month,
(unsigned)sample.day,
(unsigned)sample.hour,
(unsigned)sample.minute,
(unsigned)sample.second);
return String(buf);
}
static const char* constellationForTalker(const char* talker) {
if (!talker) return "UNKNOWN";
if (strcmp(talker, "GP") == 0 || strcmp(talker, "GN") == 0) return "GPS";
if (strcmp(talker, "GA") == 0) return "GALILEO";
if (strcmp(talker, "GL") == 0) return "GLONASS";
if (strcmp(talker, "GB") == 0 || strcmp(talker, "BD") == 0) return "BEIDOU";
if (strcmp(talker, "GI") == 0) return "NAVIC";
if (strcmp(talker, "GQ") == 0) return "QZSS";
if (strcmp(talker, "GS") == 0) return "SBAS";
return "UNKNOWN";
}
} // namespace
bool StorageManager::mount() {
m_ready = false;
m_lastError = "";
m_path = "";
m_buffer = "";
if (m_file) {
m_file.close();
}
if (!SPIFFS.begin(true)) {
m_lastError = "SPIFFS.begin failed";
return false;
}
if (!ensureDir()) {
return false;
}
return true;
}
bool StorageManager::begin(const char* runId) {
if (!mount()) {
return false;
}
return startLog(runId, "");
}
bool StorageManager::startLog(const char* runId, const char* bootTimestampUtc) {
m_ready = false;
m_lastError = "";
m_path = makeFilePath(runId);
if (!openFile()) {
return false;
}
m_ready = true;
writeHeader(runId, bootTimestampUtc);
return true;
}
bool StorageManager::mounted() const {
File root = SPIFFS.open("/");
return root && root.isDirectory();
}
bool StorageManager::ready() const {
return m_ready;
}
const char* StorageManager::currentPath() const {
return m_path.c_str();
}
const char* StorageManager::lastError() const {
return m_lastError.c_str();
}
bool StorageManager::fileOpen() const {
return (bool)m_file;
}
size_t StorageManager::bufferedBytes() const {
return m_buffer.length();
}
size_t StorageManager::logFileCount() const {
File dir = SPIFFS.open("/");
if (!dir || !dir.isDirectory()) {
return 0;
}
size_t count = 0;
File file = dir.openNextFile();
while (file) {
String name = file.name();
if (isRecognizedLogName(name)) {
++count;
}
file = dir.openNextFile();
}
return count;
}
bool StorageManager::ensureDir() {
if (strcmp(kLogDir, "/") == 0) {
return true;
}
if (SPIFFS.exists(kLogDir)) {
return true;
}
if (!SPIFFS.mkdir(kLogDir)) {
m_lastError = "SPIFFS.mkdir failed";
return false;
}
return true;
}
String StorageManager::makeFilePath(const char* runId) const {
char path[96];
const char* rid = runId ? runId : "run";
char shortId[32];
if (strlen(rid) >= 19 && rid[8] == '_' && rid[15] == '_') {
snprintf(shortId,
sizeof(shortId),
"%.6s_%.6s_%s",
rid + 2,
rid + 9,
rid + 16);
} else {
snprintf(shortId, sizeof(shortId), "%s", rid);
}
if (strcmp(kLogDir, "/") == 0) {
snprintf(path, sizeof(path), "/%s.csv", shortId);
} else {
snprintf(path, sizeof(path), "%s/%s.csv", kLogDir, shortId);
}
return String(path);
}
bool StorageManager::openFile() {
m_file = SPIFFS.open(m_path, FILE_WRITE);
if (!m_file) {
m_lastError = "SPIFFS.open write failed";
return false;
}
return true;
}
void StorageManager::writeHeader(const char* runId, const char* bootTimestampUtc) {
if (!m_file) {
return;
}
if (m_file.size() > 0) {
return;
}
m_file.printf("# exercise: %s\n", kExerciseName);
m_file.printf("# version: %s\n", kFirmwareVersion);
m_file.printf("# board_id: %s\n", kBoardId);
m_file.printf("# gnss_chip: %s\n", kGnssChip);
m_file.printf("# storage: %s\n", kStorageName);
m_file.printf("# sample_period_ms: %lu\n", (unsigned long)kSamplePeriodMs);
m_file.printf("# log_period_ms: %lu\n", (unsigned long)kLogFlushPeriodMs);
m_file.printf("# run_id: %s\n", runId ? runId : "");
m_file.printf("# boot_timestamp_utc: %s\n", bootTimestampUtc ? bootTimestampUtc : "");
m_file.printf("# created_by: ChatGPT/Codex handoff\n");
m_file.print("record_type,timestamp_utc,board_id,gnss_chip,firmware_exercise_name,firmware_version,boot_timestamp_utc,run_id,fix_type,fix_dimension,sats_in_view,sat_seen,sats_used,hdop,vdop,pdop,latitude,longitude,altitude_m,speed_mps,course_deg,pps_seen,quality_class,gps_count,galileo_count,glonass_count,beidou_count,navic_count,qzss_count,sbas_count,mean_cn0,max_cn0,age_of_fix_ms,ttff_ms,longest_no_fix_ms,sat_talker,sat_constellation,sat_prn,sat_elevation_deg,sat_azimuth_deg,sat_snr,sat_used_in_solution\n");
m_file.flush();
}
void StorageManager::appendLine(const String& line) {
m_buffer += line;
if (!m_buffer.endsWith("\n")) {
m_buffer += "\n";
}
}
void StorageManager::appendSampleTsv(const GnssSample& sample, const char* runId, const char* bootTimestampUtc) {
if (!m_file) {
return;
}
if (m_file.size() == 0) {
writeHeader(runId, bootTimestampUtc);
}
String line = "sample,";
line += sampleTimestamp(sample);
line += kLogFieldDelimiter;
line += kBoardId;
line += kLogFieldDelimiter;
line += kGnssChip;
line += kLogFieldDelimiter;
line += kExerciseName;
line += kLogFieldDelimiter;
line += kFirmwareVersion;
line += kLogFieldDelimiter;
line += (bootTimestampUtc ? bootTimestampUtc : "");
line += kLogFieldDelimiter;
line += (runId ? runId : "");
line += kLogFieldDelimiter;
line += fixTypeToString(sample.fixType);
line += kLogFieldDelimiter;
line += String(sample.fixDimension);
line += kLogFieldDelimiter;
line += (sample.satsInView >= 0 ? String(sample.satsInView) : "");
line += kLogFieldDelimiter;
line += (sample.satsInView >= 0 ? String(sample.satsInView) : "");
line += kLogFieldDelimiter;
line += (sample.satsUsed >= 0 ? String(sample.satsUsed) : "");
line += kLogFieldDelimiter;
line += formatFloat(sample.hdop, sample.validHdop);
line += kLogFieldDelimiter;
line += formatFloat(sample.vdop, sample.validVdop);
line += kLogFieldDelimiter;
line += formatFloat(sample.pdop, sample.validPdop);
line += kLogFieldDelimiter;
line += formatDouble(sample.latitude, sample.validLocation);
line += kLogFieldDelimiter;
line += formatDouble(sample.longitude, sample.validLocation);
line += kLogFieldDelimiter;
line += formatDouble(sample.altitudeM, sample.validAltitude, 2);
line += kLogFieldDelimiter;
line += formatFloat(sample.speedMps, sample.validSpeed, 2);
line += kLogFieldDelimiter;
line += formatFloat(sample.courseDeg, sample.validCourse, 1);
line += kLogFieldDelimiter;
line += sample.ppsSeen ? "1" : "0";
line += kLogFieldDelimiter;
line += qualityClassForSample(sample);
line += kLogFieldDelimiter;
line += String(sample.gpsCount);
line += kLogFieldDelimiter;
line += String(sample.galileoCount);
line += kLogFieldDelimiter;
line += String(sample.glonassCount);
line += kLogFieldDelimiter;
line += String(sample.beidouCount);
line += kLogFieldDelimiter;
line += String(sample.navicCount);
line += kLogFieldDelimiter;
line += String(sample.qzssCount);
line += kLogFieldDelimiter;
line += String(sample.sbasCount);
line += kLogFieldDelimiter;
line += formatFloat(sample.meanSnr, sample.meanSnr >= 0.0f, 1);
line += kLogFieldDelimiter;
line += (sample.maxSnr > 0 ? String(sample.maxSnr) : "");
line += kLogFieldDelimiter;
line += String(sample.ageOfFixMs);
line += kLogFieldDelimiter;
line += String(sample.ttffMs);
line += kLogFieldDelimiter;
line += String(sample.longestNoFixMs);
line += ",,,,,,,";
appendLine(line);
}
void StorageManager::appendSatelliteTsv(const GnssSample& sample,
const SatelliteInfo* satellites,
size_t satelliteCount,
const char* runId,
const char* bootTimestampUtc) {
if (!satellites || satelliteCount == 0 || !m_file) {
return;
}
if (m_file.size() == 0) {
writeHeader(runId, bootTimestampUtc);
}
for (size_t i = 0; i < satelliteCount; ++i) {
const SatelliteInfo& sat = satellites[i];
if (!sat.valid) {
continue;
}
String line = "satellite,";
line += sampleTimestamp(sample);
line += kLogFieldDelimiter;
line += kBoardId;
line += kLogFieldDelimiter;
line += kGnssChip;
line += kLogFieldDelimiter;
line += kExerciseName;
line += kLogFieldDelimiter;
line += kFirmwareVersion;
line += kLogFieldDelimiter;
line += (bootTimestampUtc ? bootTimestampUtc : "");
line += kLogFieldDelimiter;
line += (runId ? runId : "");
line += kLogFieldDelimiter;
line += fixTypeToString(sample.fixType);
line += kLogFieldDelimiter;
line += String(sample.fixDimension);
line += kLogFieldDelimiter;
line += (sample.satsInView >= 0 ? String(sample.satsInView) : "");
line += kLogFieldDelimiter;
line += (sample.satsInView >= 0 ? String(sample.satsInView) : "");
line += kLogFieldDelimiter;
line += (sample.satsUsed >= 0 ? String(sample.satsUsed) : "");
line += kLogFieldDelimiter;
line += formatFloat(sample.hdop, sample.validHdop);
line += kLogFieldDelimiter;
line += formatFloat(sample.vdop, sample.validVdop);
line += kLogFieldDelimiter;
line += formatFloat(sample.pdop, sample.validPdop);
line += kLogFieldDelimiter;
line += formatDouble(sample.latitude, sample.validLocation);
line += kLogFieldDelimiter;
line += formatDouble(sample.longitude, sample.validLocation);
line += kLogFieldDelimiter;
line += formatDouble(sample.altitudeM, sample.validAltitude, 2);
line += kLogFieldDelimiter;
line += formatFloat(sample.speedMps, sample.validSpeed, 2);
line += kLogFieldDelimiter;
line += formatFloat(sample.courseDeg, sample.validCourse, 1);
line += kLogFieldDelimiter;
line += sample.ppsSeen ? "1" : "0";
line += kLogFieldDelimiter;
line += qualityClassForSample(sample);
line += kLogFieldDelimiter;
line += String(sample.gpsCount);
line += kLogFieldDelimiter;
line += String(sample.galileoCount);
line += kLogFieldDelimiter;
line += String(sample.glonassCount);
line += kLogFieldDelimiter;
line += String(sample.beidouCount);
line += kLogFieldDelimiter;
line += String(sample.navicCount);
line += kLogFieldDelimiter;
line += String(sample.qzssCount);
line += kLogFieldDelimiter;
line += String(sample.sbasCount);
line += kLogFieldDelimiter;
line += formatFloat(sample.meanSnr, sample.meanSnr >= 0.0f, 1);
line += kLogFieldDelimiter;
line += (sample.maxSnr > 0 ? String(sample.maxSnr) : "");
line += kLogFieldDelimiter;
line += String(sample.ageOfFixMs);
line += kLogFieldDelimiter;
line += String(sample.ttffMs);
line += kLogFieldDelimiter;
line += String(sample.longestNoFixMs);
line += kLogFieldDelimiter;
line += sat.talker;
line += kLogFieldDelimiter;
line += constellationForTalker(sat.talker);
line += kLogFieldDelimiter;
line += String(sat.prn);
line += kLogFieldDelimiter;
line += String(sat.elevation);
line += kLogFieldDelimiter;
line += String(sat.azimuth);
line += kLogFieldDelimiter;
line += String(sat.snr);
line += kLogFieldDelimiter;
line += sat.usedInSolution ? "1" : "0";
appendLine(line);
}
}
void StorageManager::flush() {
if (!m_file || m_buffer.isEmpty()) {
return;
}
m_file.print(m_buffer);
m_file.flush();
m_buffer = "";
}
void StorageManager::close() {
flush();
if (m_file) {
m_file.close();
}
}
void StorageManager::listFiles(Stream& out) {
if (!mounted()) {
out.println("storage not mounted");
return;
}
File dir = SPIFFS.open("/");
if (!dir || !dir.isDirectory()) {
out.println("root directory unavailable");
return;
}
File file = dir.openNextFile();
if (!file) {
out.println("(no files)");
return;
}
while (file) {
String name = file.name();
if (isRecognizedLogName(name)) {
out.printf("%s\t%u\n", file.name(), (unsigned)file.size());
}
file = dir.openNextFile();
}
}
void StorageManager::catFile(Stream& out, const char* path) {
if (!mounted()) {
out.println("storage not mounted");
return;
}
if (!path || path[0] == '\0') {
out.println("cat requires a filename");
return;
}
String fullPath = path[0] == '/' ? String(path) : String("/") + path;
File file = SPIFFS.open(fullPath, FILE_READ);
if (!file) {
out.printf("unable to open %s\n", fullPath.c_str());
return;
}
while (file.available()) {
out.write(file.read());
}
if (file.size() > 0) {
out.println();
}
}
void StorageManager::eraseLogs(Stream& out) {
if (!mounted()) {
out.println("storage not mounted");
return;
}
File dir = SPIFFS.open("/");
if (!dir || !dir.isDirectory()) {
out.println("root directory unavailable");
return;
}
File file = dir.openNextFile();
while (file) {
String path = file.path();
bool isLog = isRecognizedLogName(path);
file.close();
if (isLog) {
SPIFFS.remove(path);
}
file = dir.openNextFile();
}
out.println("logs erased");
}
} // namespace field_qa

View file

@ -0,0 +1,47 @@
#pragma once
#include <Arduino.h>
#include <SPIFFS.h>
#include "GnssTypes.h"
namespace field_qa {
class StorageManager {
public:
bool mount();
bool begin(const char* runId);
bool startLog(const char* runId, const char* bootTimestampUtc);
bool mounted() const;
bool ready() const;
const char* currentPath() const;
const char* lastError() const;
bool fileOpen() const;
size_t bufferedBytes() const;
size_t logFileCount() const;
void appendSampleTsv(const GnssSample& sample, const char* runId, const char* bootTimestampUtc);
void appendSatelliteTsv(const GnssSample& sample,
const SatelliteInfo* satellites,
size_t satelliteCount,
const char* runId,
const char* bootTimestampUtc);
void flush();
void close();
void listFiles(Stream& out);
void catFile(Stream& out, const char* path);
void eraseLogs(Stream& out);
private:
bool ensureDir();
bool openFile();
void writeHeader(const char* runId, const char* bootTimestampUtc);
String makeFilePath(const char* runId) const;
void appendLine(const String& line);
bool m_ready = false;
String m_path;
String m_lastError;
File m_file;
String m_buffer;
};
} // namespace field_qa