From 02721701a07ad28dcdcaf48b60f3b78f9a6bea2a Mon Sep 17 00:00:00 2001 From: John Poole Date: Sun, 5 Apr 2026 21:35:42 -0700 Subject: [PATCH] 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? --- exercises/18_GPS_Field_QA/README.md | 30 + .../18_GPS_Field_QA/lib/field_qa/Config.h | 76 ++ .../lib/field_qa/DisplayManager.cpp | 73 ++ .../lib/field_qa/DisplayManager.h | 29 + .../lib/field_qa/GnssManager.cpp | 488 +++++++++++++ .../lib/field_qa/GnssManager.h | 54 ++ .../lib/field_qa/GnssTypes.cpp | 41 ++ .../18_GPS_Field_QA/lib/field_qa/GnssTypes.h | 78 +++ .../18_GPS_Field_QA/lib/field_qa/RunStats.cpp | 53 ++ .../18_GPS_Field_QA/lib/field_qa/RunStats.h | 27 + .../lib/field_qa/StorageManager.cpp | 481 +++++++++++++ .../lib/field_qa/StorageManager.h | 47 ++ exercises/18_GPS_Field_QA/platformio.ini | 104 +++ .../scripts/set_build_epoch.py | 13 + exercises/18_GPS_Field_QA/src/main.cpp | 649 ++++++++++++++++++ 15 files changed, 2243 insertions(+) create mode 100644 exercises/18_GPS_Field_QA/README.md create mode 100644 exercises/18_GPS_Field_QA/lib/field_qa/Config.h create mode 100644 exercises/18_GPS_Field_QA/lib/field_qa/DisplayManager.cpp create mode 100644 exercises/18_GPS_Field_QA/lib/field_qa/DisplayManager.h create mode 100644 exercises/18_GPS_Field_QA/lib/field_qa/GnssManager.cpp create mode 100644 exercises/18_GPS_Field_QA/lib/field_qa/GnssManager.h create mode 100644 exercises/18_GPS_Field_QA/lib/field_qa/GnssTypes.cpp create mode 100644 exercises/18_GPS_Field_QA/lib/field_qa/GnssTypes.h create mode 100644 exercises/18_GPS_Field_QA/lib/field_qa/RunStats.cpp create mode 100644 exercises/18_GPS_Field_QA/lib/field_qa/RunStats.h create mode 100644 exercises/18_GPS_Field_QA/lib/field_qa/StorageManager.cpp create mode 100644 exercises/18_GPS_Field_QA/lib/field_qa/StorageManager.h create mode 100644 exercises/18_GPS_Field_QA/platformio.ini create mode 100644 exercises/18_GPS_Field_QA/scripts/set_build_epoch.py create mode 100644 exercises/18_GPS_Field_QA/src/main.cpp diff --git a/exercises/18_GPS_Field_QA/README.md b/exercises/18_GPS_Field_QA/README.md new file mode 100644 index 0000000..b37d45f --- /dev/null +++ b/exercises/18_GPS_Field_QA/README.md @@ -0,0 +1,30 @@ +## Exercise 18: GPS Field QA + +Survey/reconnaissance firmware for LilyGO T-Beam SUPREME. + +This exercise measures GNSS visibility and solution quality, logs results to internal flash using CSV, and provides a minimal serial interface for retrieving the logs in the field. + +Current storage choice: + +- `SPIFFS` + +Current environments: + +- `bob_l76k` +- `guy_ublox` + +Primary serial commands: + +- `status` +- `summary` +- `ls` +- `cat ` +- `stop` +- `erase_logs` + +Notes: + +- Samples are aggregated once per second. +- Records are flushed to flash every 10 seconds. +- Satellite snapshot records are written as additional CSV lines when GSV data is available. +- The implementation uses common NMEA parsing so it can normalize L76K and MAX-M10S output without adding a new GNSS dependency. diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/Config.h b/exercises/18_GPS_Field_QA/lib/field_qa/Config.h new file mode 100644 index 0000000..ddfa4c4 --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/field_qa/Config.h @@ -0,0 +1,76 @@ +#pragma once + +#include + +#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 diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/DisplayManager.cpp b/exercises/18_GPS_Field_QA/lib/field_qa/DisplayManager.cpp new file mode 100644 index 0000000..e4f6ec1 --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/field_qa/DisplayManager.cpp @@ -0,0 +1,73 @@ +#include "DisplayManager.h" + +#include +#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 + diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/DisplayManager.h b/exercises/18_GPS_Field_QA/lib/field_qa/DisplayManager.h new file mode 100644 index 0000000..93dec34 --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/field_qa/DisplayManager.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#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 + diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/GnssManager.cpp b/exercises/18_GPS_Field_QA/lib/field_qa/GnssManager.cpp new file mode 100644 index 0000000..2269bd4 --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/field_qa/GnssManager.cpp @@ -0,0 +1,488 @@ +#include "GnssManager.h" + +#include +#include +#include +#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 diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/GnssManager.h b/exercises/18_GPS_Field_QA/lib/field_qa/GnssManager.h new file mode 100644 index 0000000..8b3d204 --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/field_qa/GnssManager.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#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 diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/GnssTypes.cpp b/exercises/18_GPS_Field_QA/lib/field_qa/GnssTypes.cpp new file mode 100644 index 0000000..f5c26fa --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/field_qa/GnssTypes.cpp @@ -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 + diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/GnssTypes.h b/exercises/18_GPS_Field_QA/lib/field_qa/GnssTypes.h new file mode 100644 index 0000000..6c0faa3 --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/field_qa/GnssTypes.h @@ -0,0 +1,78 @@ +#pragma once + +#include + +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 diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/RunStats.cpp b/exercises/18_GPS_Field_QA/lib/field_qa/RunStats.cpp new file mode 100644 index 0000000..189a921 --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/field_qa/RunStats.cpp @@ -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 + diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/RunStats.h b/exercises/18_GPS_Field_QA/lib/field_qa/RunStats.h new file mode 100644 index 0000000..0f25f54 --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/field_qa/RunStats.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#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 + diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/StorageManager.cpp b/exercises/18_GPS_Field_QA/lib/field_qa/StorageManager.cpp new file mode 100644 index 0000000..874fd8e --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/field_qa/StorageManager.cpp @@ -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 diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/StorageManager.h b/exercises/18_GPS_Field_QA/lib/field_qa/StorageManager.h new file mode 100644 index 0000000..f529354 --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/field_qa/StorageManager.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#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 diff --git a/exercises/18_GPS_Field_QA/platformio.ini b/exercises/18_GPS_Field_QA/platformio.ini new file mode 100644 index 0000000..d46b4a6 --- /dev/null +++ b/exercises/18_GPS_Field_QA/platformio.ini @@ -0,0 +1,104 @@ +; 20260405 ChatGPT +; Exercise 18_GPS_Field_QA + +[platformio] +default_envs = amy + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +board_build.partitions = default_8MB.csv +monitor_speed = 115200 +extra_scripts = pre:scripts/set_build_epoch.py +lib_deps = + Wire + olikraus/U8g2@^2.36.4 + lewisxhe/XPowersLib@0.3.3 + +build_flags = + -I ../../shared/boards + -I ../../external/microReticulum_Firmware + -D BOARD_MODEL=BOARD_TBEAM_S_V1 + -D OLED_SDA=17 + -D OLED_SCL=18 + -D OLED_ADDR=0x3C + -D GPS_RX_PIN=9 + -D GPS_TX_PIN=8 + -D GPS_WAKEUP_PIN=7 + -D GPS_1PPS_PIN=6 + -D GPS_L76K + -D NODE_SLOT_COUNT=7 + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + +[env:amy] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"AMY\" + -D NODE_LABEL=\"Amy\" + -D NODE_SHORT=\"A\" + -D NODE_SLOT_INDEX=0 + -D GNSS_CHIP_NAME=\"L76K\" + +[env:bob] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"BOB\" + -D NODE_LABEL=\"Bob\" + -D NODE_SHORT=\"B\" + -D NODE_SLOT_INDEX=1 + -D GNSS_CHIP_NAME=\"L76K\" + +[env:cy] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"CY\" + -D NODE_LABEL=\"Cy\" + -D NODE_SHORT=\"C\" + -D NODE_SLOT_INDEX=2 + -D GNSS_CHIP_NAME=\"L76K\" + +[env:dan] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"DAN\" + -D NODE_LABEL=\"Dan\" + -D NODE_SHORT=\"D\" + -D NODE_SLOT_INDEX=3 + -D GNSS_CHIP_NAME=\"L76K\" + +[env:ed] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"ED\" + -D NODE_LABEL=\"Ed\" + -D NODE_SHORT=\"E\" + -D NODE_SLOT_INDEX=4 + -D GNSS_CHIP_NAME=\"L76K\" + +[env:flo] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"FLO\" + -D NODE_LABEL=\"Flo\" + -D NODE_SHORT=\"F\" + -D NODE_SLOT_INDEX=5 + -D GNSS_CHIP_NAME=\"L76K\" + +[env:guy] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"GUY\" + -D NODE_LABEL=\"Guy\" + -D NODE_SHORT=\"G\" + -D NODE_SLOT_INDEX=6 + -D GNSS_CHIP_NAME=\"MAX-M10S\" + -D GPS_UBLOX diff --git a/exercises/18_GPS_Field_QA/scripts/set_build_epoch.py b/exercises/18_GPS_Field_QA/scripts/set_build_epoch.py new file mode 100644 index 0000000..b3f9cd9 --- /dev/null +++ b/exercises/18_GPS_Field_QA/scripts/set_build_epoch.py @@ -0,0 +1,13 @@ +import time +Import("env") + +epoch = int(time.time()) +utc_tag = time.strftime("%Y%m%d_%H%M%S_z", time.gmtime(epoch)) + +env.Append( + CPPDEFINES=[ + ("FW_BUILD_EPOCH", str(epoch)), + ("FW_BUILD_UTC", '\"%s\"' % utc_tag), + ] +) + diff --git a/exercises/18_GPS_Field_QA/src/main.cpp b/exercises/18_GPS_Field_QA/src/main.cpp new file mode 100644 index 0000000..3734838 --- /dev/null +++ b/exercises/18_GPS_Field_QA/src/main.cpp @@ -0,0 +1,649 @@ +// 20260405 ChatGPT +// Exercise 18_GPS_Field_QA + +#include +#include +#include +#include +#include + +#include "Config.h" +#include "DisplayManager.h" +#include "GnssManager.h" +#include "RunStats.h" +#include "StorageManager.h" +#include "tbeam_supreme_adapter.h" + +using namespace field_qa; + +namespace { + +struct RtcDateTime { + uint16_t year; + uint8_t month; + uint8_t day; + uint8_t hour; + uint8_t minute; + uint8_t second; +}; + +XPowersLibInterface* g_pmu = nullptr; +DisplayManager g_display; +GnssManager g_gnss; +StorageManager g_storage; +RunStats g_stats; +WebServer g_server(80); + +char g_runId[48] = {0}; +char g_bootTimestampUtc[32] = {0}; +char g_serialLine[128] = {0}; +char g_apSsid[32] = {0}; +size_t g_serialLineLen = 0; +bool g_loggingEnabled = false; +bool g_periodicSerialEnabled = false; +bool g_storageReady = false; +bool g_storageMounted = false; +bool g_webReady = false; +size_t g_logFileCount = 0; +uint32_t g_lastSampleMs = 0; +uint32_t g_lastFlushMs = 0; +uint32_t g_lastDisplayMs = 0; +uint32_t g_lastStatusMs = 0; + +uint8_t fromBcd(uint8_t b) { + return ((b >> 4U) * 10U) + (b & 0x0FU); +} + +bool rtcRead(RtcDateTime& out, bool& lowVoltageFlag) { + Wire1.beginTransmission(RTC_I2C_ADDR); + Wire1.write(0x02); + if (Wire1.endTransmission(false) != 0) { + return false; + } + + const uint8_t need = 7; + uint8_t got = Wire1.requestFrom((int)RTC_I2C_ADDR, (int)need); + if (got != need) { + return false; + } + + uint8_t sec = Wire1.read(); + uint8_t min = Wire1.read(); + uint8_t hour = Wire1.read(); + uint8_t day = Wire1.read(); + (void)Wire1.read(); + uint8_t month = Wire1.read(); + uint8_t year = Wire1.read(); + + lowVoltageFlag = (sec & 0x80U) != 0; + out.second = fromBcd(sec & 0x7FU); + out.minute = fromBcd(min & 0x7FU); + out.hour = fromBcd(hour & 0x3FU); + out.day = fromBcd(day & 0x3FU); + out.month = fromBcd(month & 0x1FU); + uint8_t yy = fromBcd(year); + bool century = (month & 0x80U) != 0; + out.year = century ? (1900U + yy) : (2000U + yy); + return true; +} + +bool readBootTimestampFromRtc(char* isoOut, size_t isoOutSize, char* runIdOut, size_t runIdOutSize) { + RtcDateTime now{}; + bool low = false; + if (!rtcRead(now, low)) { + return false; + } + snprintf(isoOut, + isoOutSize, + "%04u-%02u-%02uT%02u:%02u:%02uZ", + (unsigned)now.year, + (unsigned)now.month, + (unsigned)now.day, + (unsigned)now.hour, + (unsigned)now.minute, + (unsigned)now.second); + snprintf(runIdOut, + runIdOutSize, + "%04u%02u%02u_%02u%02u%02u_%s", + (unsigned)now.year, + (unsigned)now.month, + (unsigned)now.day, + (unsigned)now.hour, + (unsigned)now.minute, + (unsigned)now.second, + kBoardId); + return true; +} + +void formatUtcNowFallback(char* out, size_t outSize) { + const uint32_t sec = millis() / 1000U; + const uint32_t hh = sec / 3600U; + const uint32_t mm = (sec % 3600U) / 60U; + const uint32_t ss = sec % 60U; + snprintf(out, outSize, "uptime_%02lu%02lu%02lu", (unsigned long)hh, (unsigned long)mm, (unsigned long)ss); +} + +void setBootTimestampFromSample(const GnssSample& sample) { + if (g_bootTimestampUtc[0] != '\0' || !sample.validTime) { + return; + } + snprintf(g_bootTimestampUtc, + sizeof(g_bootTimestampUtc), + "%04u-%02u-%02uT%02u:%02u:%02uZ", + (unsigned)sample.year, + (unsigned)sample.month, + (unsigned)sample.day, + (unsigned)sample.hour, + (unsigned)sample.minute, + (unsigned)sample.second); +} + +void makeRunId(const GnssSample* sample) { + if (sample && sample->validTime) { + snprintf(g_runId, + sizeof(g_runId), + "%04u%02u%02u_%02u%02u%02u_%s", + (unsigned)sample->year, + (unsigned)sample->month, + (unsigned)sample->day, + (unsigned)sample->hour, + (unsigned)sample->minute, + (unsigned)sample->second, + kBoardId); + } else { + char stamp[24]; + formatUtcNowFallback(stamp, sizeof(stamp)); + snprintf(g_runId, sizeof(g_runId), "%s_%s", stamp, kBoardId); + } +} + +void printProvenance() { + Serial.printf("exercise=%s\n", kExerciseName); + Serial.printf("version=%s\n", kFirmwareVersion); + Serial.printf("board_id=%s\n", kBoardId); + Serial.printf("gnss_chip=%s\n", kGnssChip); + Serial.printf("detected_chip=%s\n", g_gnss.detectedChipName()); + Serial.printf("storage=%s\n", kStorageName); + Serial.printf("sample_period_ms=%lu\n", (unsigned long)kSamplePeriodMs); + Serial.printf("log_period_ms=%lu\n", (unsigned long)kLogFlushPeriodMs); + Serial.printf("run_id=%s\n", g_runId); + Serial.printf("web_ssid=%s\n", g_apSsid); + Serial.printf("web_url=http://192.168.%u.1/\n", (unsigned)kLogApIpOctet); +} + +void printSummary() { + const String currentPath = String(g_storage.currentPath()); + File rootDir = SPIFFS.open("/"); + const bool rootDirOk = rootDir && rootDir.isDirectory(); + Serial.println("summary:"); + Serial.printf("build=%s\n", kFirmwareVersion); + Serial.printf("run_id=%s\n", g_runId); + Serial.printf("elapsed_ms=%lu\n", (unsigned long)g_stats.elapsedMs(millis())); + Serial.printf("ttff_ms=%lu\n", (unsigned long)g_stats.ttffMs()); + Serial.printf("longest_no_fix_ms=%lu\n", (unsigned long)g_stats.longestNoFixMs()); + Serial.printf("storage_ready=%s\n", g_storageReady ? "yes" : "no"); + Serial.printf("storage_mounted=%s\n", g_storageMounted ? "yes" : "no"); + Serial.printf("storage_error=%s\n", g_storage.lastError()); + Serial.printf("storage_log_dir=%s\n", kLogDir); + Serial.printf("log_file=%s\n", g_storage.currentPath()); + Serial.printf("storage_file_open=%s\n", g_storage.fileOpen() ? "yes" : "no"); + Serial.printf("storage_path_len=%u\n", (unsigned)currentPath.length()); + Serial.printf("storage_path_exists=%s\n", + (!currentPath.isEmpty() && SPIFFS.exists(currentPath)) ? "yes" : "no"); + Serial.printf("storage_root_dir=%s\n", rootDirOk ? "yes" : "no"); + Serial.printf("storage_total_bytes=%u\n", (unsigned)SPIFFS.totalBytes()); + Serial.printf("storage_used_bytes=%u\n", (unsigned)SPIFFS.usedBytes()); + Serial.printf("storage_buffered_bytes=%u\n", (unsigned)g_storage.bufferedBytes()); + Serial.printf("storage_log_count=%u\n", (unsigned)g_logFileCount); + Serial.printf("storage_auto_log_limit=%u\n", (unsigned)kMaxLogFilesBeforePause); + Serial.printf("web_ready=%s\n", g_webReady ? "yes" : "no"); + Serial.printf("web_url=http://192.168.%u.1/\n", (unsigned)kLogApIpOctet); +} + +void printStatusLine(const GnssSample& sample) { + char ts[24]; + if (sample.validTime) { + snprintf(ts, + sizeof(ts), + "%04u%02u%02u_%02u%02u%02uZ", + (unsigned)sample.year, + (unsigned)sample.month, + (unsigned)sample.day, + (unsigned)sample.hour, + (unsigned)sample.minute, + (unsigned)sample.second); + } else { + strlcpy(ts, "NO_UTC", sizeof(ts)); + } + Serial.printf("%s board=%s chip=%s fix=%s used=%d view=%d hdop=%s lat=%s lon=%s alt=%s q=%s\n", + ts, + kBoardId, + g_gnss.detectedChipName(), + fixTypeToString(sample.fixType), + sample.satsUsed < 0 ? 0 : sample.satsUsed, + sample.satsInView < 0 ? 0 : sample.satsInView, + sample.validHdop ? String(sample.hdop, 1).c_str() : "", + sample.validLocation ? String(sample.latitude, 5).c_str() : "", + sample.validLocation ? String(sample.longitude, 5).c_str() : "", + sample.validAltitude ? String(sample.altitudeM, 1).c_str() : "", + qualityClassForSample(sample)); +} + +void handleCommand(const char* line) { + if (!line || line[0] == '\0') { + return; + } + Serial.printf("-->%s\n", line); + if (strcasecmp(line, "status") == 0) { + GnssSample sample = g_gnss.makeSample(); + sample.longestNoFixMs = g_stats.longestNoFixMs(); + sample.ttffMs = g_stats.ttffMs(); + printStatusLine(sample); + Serial.printf("storage_ready=%s\n", g_storageReady ? "yes" : "no"); + Serial.printf("storage_mounted=%s\n", g_storageMounted ? "yes" : "no"); + Serial.printf("storage_error=%s\n", g_storage.lastError()); + Serial.printf("log_file=%s\n", g_storage.currentPath()); + Serial.printf("storage_log_count=%u\n", (unsigned)g_logFileCount); + Serial.printf("periodic_serial=%s\n", g_periodicSerialEnabled ? "on" : "off"); + Serial.printf("web_ready=%s\n", g_webReady ? "yes" : "no"); + Serial.printf("web_url=http://192.168.%u.1/\n", (unsigned)kLogApIpOctet); + } else if (strcasecmp(line, "summary") == 0) { + printSummary(); + } else if (strcasecmp(line, "start") == 0) { + if (g_storageReady) { + g_loggingEnabled = true; + Serial.println("logging already active"); + } else if (!g_storageMounted) { + Serial.println("storage not mounted"); + } else if (g_storage.startLog(g_runId, g_bootTimestampUtc)) { + g_storageReady = true; + g_loggingEnabled = true; + g_logFileCount = g_storage.logFileCount(); + Serial.println("logging started"); + } else { + Serial.printf("logging start failed: %s\n", g_storage.lastError()); + } + } else if (strcasecmp(line, "stop") == 0) { + g_loggingEnabled = false; + g_storage.flush(); + Serial.println("logging stopped"); + } else if (strcasecmp(line, "quiet") == 0) { + g_periodicSerialEnabled = false; + Serial.println("periodic serial output disabled"); + } else if (strcasecmp(line, "verbose") == 0) { + g_periodicSerialEnabled = true; + Serial.println("periodic serial output enabled"); + } else if (strcasecmp(line, "flush") == 0) { + g_storage.flush(); + Serial.println("log buffer flushed"); + } else if (strcasecmp(line, "ls") == 0) { + g_storage.flush(); + g_storage.listFiles(Serial); + } else if (strncasecmp(line, "cat ", 4) == 0) { + g_storage.flush(); + g_storage.catFile(Serial, line + 4); + } else if (strcasecmp(line, "erase_logs") == 0) { + g_storage.eraseLogs(Serial); + g_logFileCount = g_storage.logFileCount(); + } else { + Serial.println("commands: status quiet verbose flush start stop summary ls cat erase_logs"); + } +} + +String htmlEscape(const String& in) { + String out; + out.reserve(in.length() + 16); + for (size_t i = 0; i < in.length(); ++i) { + char c = in[i]; + if (c == '&') out += "&"; + else if (c == '<') out += "<"; + else if (c == '>') out += ">"; + else if (c == '"') out += """; + else out += c; + } + return out; +} + +String urlEncode(const String& in) { + static const char* hex = "0123456789ABCDEF"; + String out; + out.reserve(in.length() * 3); + for (size_t i = 0; i < in.length(); ++i) { + uint8_t c = (uint8_t)in[i]; + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~') { + out += (char)c; + } else { + out += '%'; + out += hex[(c >> 4) & 0x0F]; + out += hex[c & 0x0F]; + } + } + return out; +} + +String normalizeLogPath(const String& name) { + if (name.isEmpty() || name.indexOf("..") >= 0) { + return ""; + } + if (name[0] == '/') { + return name; + } + return String("/") + name; +} + +void handleWebIndex() { + g_storage.flush(); + String html; + html.reserve(4096); + html += "GPSQA "; + html += kBoardId; + html += ""; + html += "

GPSQA "; + html += kBoardId; + html += "

"; + html += "

Run ID: "; + html += htmlEscape(String(g_runId)); + html += "
Build: "; + html += htmlEscape(String(kFirmwareVersion)); + html += "
Boot UTC: "; + html += htmlEscape(String(g_bootTimestampUtc[0] != '\0' ? g_bootTimestampUtc : "UNKNOWN")); + html += "
Board: "; + html += htmlEscape(String(kBoardId)); + html += "
GNSS configured: "; + html += htmlEscape(String(kGnssChip)); + html += "
GNSS detected: "; + html += htmlEscape(String(g_gnss.detectedChipName())); + html += "
Storage mounted: "; + html += g_storageMounted ? "yes" : "no"; + html += "
Storage ready: "; + html += g_storageReady ? "yes" : "no"; + html += "
Storage error: "; + html += htmlEscape(String(g_storage.lastError())); + html += "
Current log: "; + html += htmlEscape(String(g_storage.currentPath())); + html += "

    "; + + if (!g_storageMounted) { + html += "
  • storage not mounted
  • "; + } else { + File dir = SPIFFS.open("/"); + if (!dir || !dir.isDirectory()) { + html += "
  • root directory unavailable
  • "; + } else { + File file = dir.openNextFile(); + if (!file) { + html += "
  • (no files)
  • "; + } + while (file) { + String name = file.name(); + if (name.endsWith(".csv") || name.endsWith(".tsv")) { + html += "
  • "; + html += htmlEscape(name); + html += " ("; + html += String((unsigned)file.size()); + html += " bytes)
  • "; + } + file = dir.openNextFile(); + } + } + } + + html += "
"; + g_server.send(200, "text/html; charset=utf-8", html); +} + +void handleWebDownload() { + g_storage.flush(); + if (!g_server.hasArg("name")) { + g_server.send(400, "text/plain", "missing name"); + return; + } + String fullPath = normalizeLogPath(g_server.arg("name")); + if (fullPath.isEmpty()) { + g_server.send(400, "text/plain", "invalid name"); + return; + } + File file = SPIFFS.open(fullPath, FILE_READ); + if (!file) { + g_server.send(404, "text/plain", "not found"); + return; + } + String downloadName = file.name(); + int slash = downloadName.lastIndexOf('/'); + if (slash >= 0) { + downloadName.remove(0, slash + 1); + } + g_server.sendHeader("Content-Disposition", String("attachment; filename=\"") + downloadName + "\""); + g_server.setContentLength(file.size()); + g_server.send(200, "text/csv; charset=utf-8", ""); + WiFiClient client = g_server.client(); + uint8_t buffer[512]; + while (file.available()) { + size_t readBytes = file.read(buffer, sizeof(buffer)); + if (readBytes == 0) { + break; + } + client.write(buffer, readBytes); + } + file.close(); +} + +void handleWebDebugRead() { + g_storage.flush(); + if (!g_server.hasArg("name")) { + g_server.send(400, "text/plain", "missing name\n"); + return; + } + String fullPath = normalizeLogPath(g_server.arg("name")); + if (fullPath.isEmpty()) { + g_server.send(400, "text/plain", "invalid name\n"); + return; + } + File file = SPIFFS.open(fullPath, FILE_READ); + if (!file) { + g_server.send(404, "text/plain", String("open failed: ") + fullPath + "\n"); + return; + } + + String out; + out.reserve(512); + out += "path="; + out += fullPath; + out += "\nsize="; + out += String((unsigned)file.size()); + out += "\navailable_before="; + out += String((unsigned)file.available()); + out += "\n"; + + char buf[129]; + size_t count = file.readBytes(buf, sizeof(buf) - 1); + buf[count] = '\0'; + out += "read_bytes="; + out += String((unsigned)count); + out += "\navailable_after="; + out += String((unsigned)file.available()); + out += "\npreview_begin\n"; + out += String(buf); + out += "\npreview_end\n"; + file.close(); + g_server.send(200, "text/plain; charset=utf-8", out); +} + +void handleWebRaw() { + g_storage.flush(); + if (!g_server.hasArg("name")) { + g_server.send(400, "text/plain", "missing name\n"); + return; + } + String fullPath = normalizeLogPath(g_server.arg("name")); + if (fullPath.isEmpty()) { + g_server.send(400, "text/plain", "invalid name\n"); + return; + } + File file = SPIFFS.open(fullPath, FILE_READ); + if (!file) { + g_server.send(404, "text/plain", "not found\n"); + return; + } + String body; + body.reserve(file.size() + 1); + while (file.available()) { + int c = file.read(); + if (c < 0) { + break; + } + body += (char)c; + } + file.close(); + g_server.send(200, "text/plain; charset=utf-8", body); +} + +void startWebServer() { + snprintf(g_apSsid, sizeof(g_apSsid), "%s%s", kLogApPrefix, kBoardId); + WiFi.mode(WIFI_AP); + IPAddress ip(192, 168, kLogApIpOctet, 1); + IPAddress gw(192, 168, kLogApIpOctet, 1); + IPAddress nm(255, 255, 255, 0); + WiFi.softAPConfig(ip, gw, nm); + if (strlen(kLogApPassword) > 0) { + WiFi.softAP(g_apSsid, kLogApPassword); + } else { + WiFi.softAP(g_apSsid, nullptr); + } + + g_server.on("/", HTTP_GET, handleWebIndex); + g_server.on("/download", HTTP_GET, handleWebDownload); + g_server.on("/debug_read", HTTP_GET, handleWebDebugRead); + g_server.on("/raw", HTTP_GET, handleWebRaw); + g_server.begin(); + g_webReady = true; +} + +void pollSerialConsole() { + while (Serial.available() > 0) { + int c = Serial.read(); + if (c < 0) { + continue; + } + if (c == '\r' || c == '\n') { + if (g_serialLineLen > 0) { + g_serialLine[g_serialLineLen] = '\0'; + handleCommand(g_serialLine); + g_serialLineLen = 0; + } + continue; + } + if (g_serialLineLen + 1 < sizeof(g_serialLine)) { + g_serialLine[g_serialLineLen++] = (char)c; + } else { + g_serialLineLen = 0; + } + } +} + +void sampleAndMaybeLog() { + GnssSample sample = g_gnss.makeSample(); + g_stats.updateFromSample(sample, millis()); + sample.ttffMs = g_stats.ttffMs(); + sample.longestNoFixMs = g_stats.longestNoFixMs(); + + setBootTimestampFromSample(sample); + if (g_runId[0] == '\0') { + makeRunId(&sample); + } + if (g_bootTimestampUtc[0] == '\0') { + strlcpy(g_bootTimestampUtc, "UNKNOWN", sizeof(g_bootTimestampUtc)); + } + + if (g_loggingEnabled && g_storageReady) { + SatelliteInfo sats[kMaxSatellites]; + size_t satCount = g_gnss.copySatellites(sats, kMaxSatellites); + g_storage.appendSampleTsv(sample, g_runId, g_bootTimestampUtc); + g_storage.appendSatelliteTsv(sample, sats, satCount, g_runId, g_bootTimestampUtc); + } + + if (g_periodicSerialEnabled && (uint32_t)(millis() - g_lastStatusMs) >= kStatusPeriodMs) { + g_lastStatusMs = millis(); + printStatusLine(sample); + } + if ((uint32_t)(millis() - g_lastDisplayMs) >= kDisplayPeriodMs) { + g_lastDisplayMs = millis(); + g_display.showSample(sample, g_stats); + } +} + +} // namespace + +void setup() { + Serial.begin(115200); + delay(kSerialDelayMs); + + Serial.println(); + Serial.println("=================================================="); + Serial.println("Exercise 18: GPS Field QA"); + Serial.println("=================================================="); + + if (!tbeam_supreme::initPmuForPeripherals(g_pmu, &Serial)) { + Serial.println("WARNING: PMU init failed"); + } + + g_display.begin(); + g_display.showBoot("Booting...", kBoardId); + + g_stats.begin(millis()); + g_gnss.begin(); + (void)g_gnss.probeAtStartup(Serial); + startWebServer(); + + if (!readBootTimestampFromRtc(g_bootTimestampUtc, sizeof(g_bootTimestampUtc), g_runId, sizeof(g_runId))) { + makeRunId(nullptr); + strlcpy(g_bootTimestampUtc, "UNKNOWN", sizeof(g_bootTimestampUtc)); + } + g_storageMounted = false; + g_storageReady = false; + g_storageMounted = g_storage.mount(); + g_storageMounted = g_storage.mounted(); + g_logFileCount = g_storage.logFileCount(); + if (!g_storageMounted) { + Serial.printf("ERROR: SPIFFS mount failed: %s\n", g_storage.lastError()); + g_display.showError("SPIFFS mount failed"); + } else if (g_logFileCount <= kMaxLogFilesBeforePause) { + g_storageReady = g_storage.startLog(g_runId, g_bootTimestampUtc); + if (g_storageReady) { + g_loggingEnabled = true; + } else { + Serial.printf("ERROR: log start failed: %s\n", g_storage.lastError()); + } + } else { + Serial.printf("INFO: auto logging paused, log count %u exceeds limit %u\n", + (unsigned)g_logFileCount, + (unsigned)kMaxLogFilesBeforePause); + } + + printProvenance(); + g_display.showBoot("Survey mode", g_loggingEnabled ? "Logging active" : "Logging paused"); + + g_lastSampleMs = millis(); + g_lastFlushMs = millis(); + g_lastDisplayMs = 0; + g_lastStatusMs = 0; +} + +void loop() { + pollSerialConsole(); + g_gnss.poll(); + g_server.handleClient(); + + const uint32_t now = millis(); + if ((uint32_t)(now - g_lastSampleMs) >= kSamplePeriodMs) { + g_lastSampleMs = now; + sampleAndMaybeLog(); + } + if (g_storageReady && (uint32_t)(now - g_lastFlushMs) >= kLogFlushPeriodMs) { + g_lastFlushMs = now; + g_storage.flush(); + } +}