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:
parent
a26b248138
commit
02721701a0
15 changed files with 2243 additions and 0 deletions
30
exercises/18_GPS_Field_QA/README.md
Normal file
30
exercises/18_GPS_Field_QA/README.md
Normal file
|
|
@ -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 <filename>`
|
||||
- `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.
|
||||
76
exercises/18_GPS_Field_QA/lib/field_qa/Config.h
Normal file
76
exercises/18_GPS_Field_QA/lib/field_qa/Config.h
Normal 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
|
||||
73
exercises/18_GPS_Field_QA/lib/field_qa/DisplayManager.cpp
Normal file
73
exercises/18_GPS_Field_QA/lib/field_qa/DisplayManager.cpp
Normal 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
|
||||
|
||||
29
exercises/18_GPS_Field_QA/lib/field_qa/DisplayManager.h
Normal file
29
exercises/18_GPS_Field_QA/lib/field_qa/DisplayManager.h
Normal 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
|
||||
|
||||
488
exercises/18_GPS_Field_QA/lib/field_qa/GnssManager.cpp
Normal file
488
exercises/18_GPS_Field_QA/lib/field_qa/GnssManager.cpp
Normal 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
|
||||
54
exercises/18_GPS_Field_QA/lib/field_qa/GnssManager.h
Normal file
54
exercises/18_GPS_Field_QA/lib/field_qa/GnssManager.h
Normal 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
|
||||
41
exercises/18_GPS_Field_QA/lib/field_qa/GnssTypes.cpp
Normal file
41
exercises/18_GPS_Field_QA/lib/field_qa/GnssTypes.cpp
Normal 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
|
||||
|
||||
78
exercises/18_GPS_Field_QA/lib/field_qa/GnssTypes.h
Normal file
78
exercises/18_GPS_Field_QA/lib/field_qa/GnssTypes.h
Normal 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
|
||||
53
exercises/18_GPS_Field_QA/lib/field_qa/RunStats.cpp
Normal file
53
exercises/18_GPS_Field_QA/lib/field_qa/RunStats.cpp
Normal 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
|
||||
|
||||
27
exercises/18_GPS_Field_QA/lib/field_qa/RunStats.h
Normal file
27
exercises/18_GPS_Field_QA/lib/field_qa/RunStats.h
Normal 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
|
||||
|
||||
481
exercises/18_GPS_Field_QA/lib/field_qa/StorageManager.cpp
Normal file
481
exercises/18_GPS_Field_QA/lib/field_qa/StorageManager.cpp
Normal 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
|
||||
47
exercises/18_GPS_Field_QA/lib/field_qa/StorageManager.h
Normal file
47
exercises/18_GPS_Field_QA/lib/field_qa/StorageManager.h
Normal 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
|
||||
104
exercises/18_GPS_Field_QA/platformio.ini
Normal file
104
exercises/18_GPS_Field_QA/platformio.ini
Normal file
|
|
@ -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
|
||||
13
exercises/18_GPS_Field_QA/scripts/set_build_epoch.py
Normal file
13
exercises/18_GPS_Field_QA/scripts/set_build_epoch.py
Normal file
|
|
@ -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),
|
||||
]
|
||||
)
|
||||
|
||||
649
exercises/18_GPS_Field_QA/src/main.cpp
Normal file
649
exercises/18_GPS_Field_QA/src/main.cpp
Normal file
|
|
@ -0,0 +1,649 @@
|
|||
// 20260405 ChatGPT
|
||||
// Exercise 18_GPS_Field_QA
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <SPIFFS.h>
|
||||
#include <Wire.h>
|
||||
#include <WiFi.h>
|
||||
#include <WebServer.h>
|
||||
|
||||
#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 <filename> 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 += "<!doctype html><html><head><meta charset='utf-8'><title>GPSQA ";
|
||||
html += kBoardId;
|
||||
html += "</title></head><body>";
|
||||
html += "<h1>GPSQA ";
|
||||
html += kBoardId;
|
||||
html += "</h1>";
|
||||
html += "<p>Run ID: ";
|
||||
html += htmlEscape(String(g_runId));
|
||||
html += "<br>Build: ";
|
||||
html += htmlEscape(String(kFirmwareVersion));
|
||||
html += "<br>Boot UTC: ";
|
||||
html += htmlEscape(String(g_bootTimestampUtc[0] != '\0' ? g_bootTimestampUtc : "UNKNOWN"));
|
||||
html += "<br>Board: ";
|
||||
html += htmlEscape(String(kBoardId));
|
||||
html += "<br>GNSS configured: ";
|
||||
html += htmlEscape(String(kGnssChip));
|
||||
html += "<br>GNSS detected: ";
|
||||
html += htmlEscape(String(g_gnss.detectedChipName()));
|
||||
html += "<br>Storage mounted: ";
|
||||
html += g_storageMounted ? "yes" : "no";
|
||||
html += "<br>Storage ready: ";
|
||||
html += g_storageReady ? "yes" : "no";
|
||||
html += "<br>Storage error: ";
|
||||
html += htmlEscape(String(g_storage.lastError()));
|
||||
html += "<br>Current log: ";
|
||||
html += htmlEscape(String(g_storage.currentPath()));
|
||||
html += "</p><ul>";
|
||||
|
||||
if (!g_storageMounted) {
|
||||
html += "<li>storage not mounted</li>";
|
||||
} else {
|
||||
File dir = SPIFFS.open("/");
|
||||
if (!dir || !dir.isDirectory()) {
|
||||
html += "<li>root directory unavailable</li>";
|
||||
} else {
|
||||
File file = dir.openNextFile();
|
||||
if (!file) {
|
||||
html += "<li>(no files)</li>";
|
||||
}
|
||||
while (file) {
|
||||
String name = file.name();
|
||||
if (name.endsWith(".csv") || name.endsWith(".tsv")) {
|
||||
html += "<li><a href='/download?name=";
|
||||
html += urlEncode(name);
|
||||
html += "'>";
|
||||
html += htmlEscape(name);
|
||||
html += "</a> (";
|
||||
html += String((unsigned)file.size());
|
||||
html += " bytes)</li>";
|
||||
}
|
||||
file = dir.openNextFile();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html += "</ul></body></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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue