diff --git a/.gitignore b/.gitignore
index ae506da..77d82c0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,12 @@
.platformio_local/
.codex
+
+
+exercises/09_GPS_Time/Doxyfile
+exercises/09_GPS_Time/Doxyfile_ORIG
+exercises/09_GPS_Time/html/
+exercises/18_GPS_Field_QA/lib/field_qa/BatteryMonitor.cpp
+exercises/18_GPS_Field_QA/lib/field_qa/BatteryMonitor.h
+exercises/AMY.log
+exercises/AMY_purged.log
diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/Config.h b/exercises/18_GPS_Field_QA/lib/field_qa/Config.h
index c15297a..3546029 100644
--- a/exercises/18_GPS_Field_QA/lib/field_qa/Config.h
+++ b/exercises/18_GPS_Field_QA/lib/field_qa/Config.h
@@ -46,6 +46,10 @@
#define FW_BUILD_UTC unknown
#endif
+#ifndef LOG_AP_IP_OCTET
+#define LOG_AP_IP_OCTET 23
+#endif
+
#define FIELD_QA_STR_INNER(x) #x
#define FIELD_QA_STR(x) FIELD_QA_STR_INNER(x)
@@ -59,7 +63,7 @@ static constexpr const char* kStorageName = "SD";
static constexpr const char* kLogDir = "/logs";
static constexpr const char* kLogApPrefix = "GPSQA-";
static constexpr const char* kLogApPassword = "";
-static constexpr uint8_t kLogApIpOctet = 23;
+static constexpr uint8_t kLogApIpOctet = LOG_AP_IP_OCTET;
static constexpr uint32_t kSerialDelayMs = 4000;
static constexpr uint32_t kSamplePeriodMs = 1000;
static constexpr uint32_t kLogFlushPeriodMs = 10000;
diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/DisplayManager.cpp b/exercises/18_GPS_Field_QA/lib/field_qa/DisplayManager.cpp
index 4a3cc42..d529263 100644
--- a/exercises/18_GPS_Field_QA/lib/field_qa/DisplayManager.cpp
+++ b/exercises/18_GPS_Field_QA/lib/field_qa/DisplayManager.cpp
@@ -21,6 +21,8 @@ void DisplayManager::begin() {
Wire.begin(OLED_SDA, OLED_SCL);
m_oled.setI2CAddress(OLED_ADDR << 1);
m_oled.begin();
+ m_oled.setPowerSave(0);
+ m_powerSave = false;
}
void DisplayManager::drawLines(const char* l1,
@@ -40,15 +42,24 @@ void DisplayManager::drawLines(const char* l1,
m_oled.sendBuffer();
}
-void DisplayManager::showBoot(const char* line2, const char* line3) {
- drawLines(kExerciseName, kFirmwareVersion, line2, line3);
+void DisplayManager::showBoot(const char* line2, const char* line3, const char* line4, const char* batteryText) {
+ char header[24];
+ if (batteryText && batteryText[0] != '\0') {
+ snprintf(header, sizeof(header), "%s %s", kExerciseName, batteryText);
+ drawLines(header, kFirmwareVersion, line2, line3, line4);
+ } else {
+ drawLines(kExerciseName, kFirmwareVersion, line2, line3, line4);
+ }
}
void DisplayManager::showError(const char* line1, const char* line2) {
drawLines(kExerciseName, "ERROR", line1, line2);
}
-void DisplayManager::showSample(const GnssSample& sample, const RunStats& stats, bool recording) {
+void DisplayManager::showSample(const GnssSample& sample,
+ const RunStats& stats,
+ bool recording,
+ const char* batteryText) {
char l1[24];
char l2[20];
char l3[20];
@@ -56,7 +67,11 @@ void DisplayManager::showSample(const GnssSample& sample, const RunStats& stats,
char l5[20];
char l6[20];
- snprintf(l1, sizeof(l1), "%s", recording ? "*RECORDING" : "Halted");
+ snprintf(l1,
+ sizeof(l1),
+ "%s %s",
+ recording ? "*RECORDING" : "Halted",
+ (batteryText && batteryText[0] != '\0') ? batteryText : "--.-v");
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) {
@@ -69,4 +84,16 @@ void DisplayManager::showSample(const GnssSample& sample, const RunStats& stats,
drawLines(l1, l2, l3, l4, l5, l6);
}
+void DisplayManager::setPowerSave(bool enabled) {
+ if (m_powerSave == enabled) {
+ return;
+ }
+ m_oled.setPowerSave(enabled ? 1 : 0);
+ m_powerSave = enabled;
+}
+
+bool DisplayManager::powerSave() const {
+ return m_powerSave;
+}
+
} // 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
index 2859675..5c66596 100644
--- a/exercises/18_GPS_Field_QA/lib/field_qa/DisplayManager.h
+++ b/exercises/18_GPS_Field_QA/lib/field_qa/DisplayManager.h
@@ -10,9 +10,14 @@ namespace field_qa {
class DisplayManager {
public:
void begin();
- void showBoot(const char* line2, const char* line3 = nullptr);
+ void showBoot(const char* line2,
+ const char* line3 = nullptr,
+ const char* line4 = nullptr,
+ const char* batteryText = nullptr);
void showError(const char* line1, const char* line2 = nullptr);
- void showSample(const GnssSample& sample, const RunStats& stats, bool recording);
+ void showSample(const GnssSample& sample, const RunStats& stats, bool recording, const char* batteryText);
+ void setPowerSave(bool enabled);
+ bool powerSave() const;
private:
void drawLines(const char* l1,
@@ -23,6 +28,7 @@ class DisplayManager {
const char* l6 = nullptr);
U8G2_SH1106_128X64_NONAME_F_HW_I2C m_oled{U8G2_R0, U8X8_PIN_NONE};
+ bool m_powerSave = 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
index d368233..8a4471a 100644
--- a/exercises/18_GPS_Field_QA/lib/field_qa/StorageManager.cpp
+++ b/exercises/18_GPS_Field_QA/lib/field_qa/StorageManager.cpp
@@ -265,13 +265,14 @@ bool StorageManager::appendLine(const String& line) {
return appendBytes(record.c_str(), record.length());
}
-void StorageManager::appendSampleCsv(const GnssSample& sample,
+bool StorageManager::appendSampleCsv(const GnssSample& sample,
uint32_t sampleSeq,
uint32_t msSinceRunStart,
const char* runId,
const char* bootTimestampUtc) {
if (!m_file) {
- return;
+ m_lastError = "log file not open";
+ return false;
}
if (m_file.size() == 0) {
writeHeader(runId, bootTimestampUtc);
@@ -350,10 +351,10 @@ void StorageManager::appendSampleCsv(const GnssSample& sample,
line += kLogFieldDelimiter;
line += String(sample.longestNoFixMs);
line += ",,,,,,,";
- (void)appendLine(line);
+ return appendLine(line);
}
-void StorageManager::appendSatelliteCsv(const GnssSample& sample,
+bool StorageManager::appendSatelliteCsv(const GnssSample& sample,
uint32_t sampleSeq,
uint32_t msSinceRunStart,
const SatelliteInfo* satellites,
@@ -361,7 +362,10 @@ void StorageManager::appendSatelliteCsv(const GnssSample& sample,
const char* runId,
const char* bootTimestampUtc) {
if (!satellites || satelliteCount == 0 || !m_file) {
- return;
+ if (!m_file) {
+ m_lastError = "log file not open";
+ }
+ return false;
}
if (m_file.size() == 0) {
writeHeader(runId, bootTimestampUtc);
@@ -458,26 +462,30 @@ void StorageManager::appendSatelliteCsv(const GnssSample& sample,
line += String(sat.snr);
line += kLogFieldDelimiter;
line += sat.usedInSolution ? "1" : "0";
- (void)appendLine(line);
+ if (!appendLine(line)) {
+ return false;
+ }
}
+ return true;
}
-void StorageManager::flush() {
+bool StorageManager::flush() {
if (!m_file) {
- return;
+ return false;
}
if (m_bufferLengths[m_activeBuffer] > 0) {
m_bufferPending[m_activeBuffer] = true;
}
if (!writePendingBuffer()) {
- return;
+ return false;
}
m_file.flush();
+ return true;
}
void StorageManager::close() {
- flush();
+ (void)flush();
if (m_file) {
m_file.close();
}
diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/StorageManager.h b/exercises/18_GPS_Field_QA/lib/field_qa/StorageManager.h
index 5acf8cf..9645284 100644
--- a/exercises/18_GPS_Field_QA/lib/field_qa/StorageManager.h
+++ b/exercises/18_GPS_Field_QA/lib/field_qa/StorageManager.h
@@ -17,19 +17,19 @@ class StorageManager {
bool fileOpen() const;
size_t bufferedBytes() const;
size_t logFileCount() const;
- void appendSampleCsv(const GnssSample& sample,
+ bool appendSampleCsv(const GnssSample& sample,
uint32_t sampleSeq,
uint32_t msSinceRunStart,
const char* runId,
const char* bootTimestampUtc);
- void appendSatelliteCsv(const GnssSample& sample,
+ bool appendSatelliteCsv(const GnssSample& sample,
uint32_t sampleSeq,
uint32_t msSinceRunStart,
const SatelliteInfo* satellites,
size_t satelliteCount,
const char* runId,
const char* bootTimestampUtc);
- void flush();
+ bool flush();
void close();
void listFiles(Stream& out);
void catFile(Stream& out, const char* path);
diff --git a/exercises/18_GPS_Field_QA/platformio.ini b/exercises/18_GPS_Field_QA/platformio.ini
index 49c13ea..2b8980b 100644
--- a/exercises/18_GPS_Field_QA/platformio.ini
+++ b/exercises/18_GPS_Field_QA/platformio.ini
@@ -40,6 +40,7 @@ build_flags =
-D NODE_LABEL=\"Amy\"
-D NODE_SHORT=\"A\"
-D NODE_SLOT_INDEX=0
+ -D LOG_AP_IP_OCTET=23
-D GNSS_CHIP_NAME=\"L76K\"
[env:bob]
@@ -50,6 +51,7 @@ build_flags =
-D NODE_LABEL=\"Bob\"
-D NODE_SHORT=\"B\"
-D NODE_SLOT_INDEX=1
+ -D LOG_AP_IP_OCTET=24
-D GNSS_CHIP_NAME=\"L76K\"
[env:cy]
@@ -60,6 +62,7 @@ build_flags =
-D NODE_LABEL=\"Cy\"
-D NODE_SHORT=\"C\"
-D NODE_SLOT_INDEX=2
+ -D LOG_AP_IP_OCTET=25
-D GNSS_CHIP_NAME=\"L76K\"
[env:dan]
@@ -70,6 +73,7 @@ build_flags =
-D NODE_LABEL=\"Dan\"
-D NODE_SHORT=\"D\"
-D NODE_SLOT_INDEX=3
+ -D LOG_AP_IP_OCTET=26
-D GNSS_CHIP_NAME=\"L76K\"
[env:ed]
@@ -80,6 +84,7 @@ build_flags =
-D NODE_LABEL=\"Ed\"
-D NODE_SHORT=\"E\"
-D NODE_SLOT_INDEX=4
+ -D LOG_AP_IP_OCTET=27
-D GNSS_CHIP_NAME=\"L76K\"
[env:flo]
@@ -90,6 +95,7 @@ build_flags =
-D NODE_LABEL=\"Flo\"
-D NODE_SHORT=\"F\"
-D NODE_SLOT_INDEX=5
+ -D LOG_AP_IP_OCTET=28
-D GNSS_CHIP_NAME=\"L76K\"
[env:guy]
@@ -100,5 +106,6 @@ build_flags =
-D NODE_LABEL=\"Guy\"
-D NODE_SHORT=\"G\"
-D NODE_SLOT_INDEX=6
+ -D LOG_AP_IP_OCTET=29
-D GNSS_CHIP_NAME=\"MAX-M10S\"
-D GPS_UBLOX
diff --git a/exercises/18_GPS_Field_QA/src/main.cpp b/exercises/18_GPS_Field_QA/src/main.cpp
index 3b515d8..5c7b179 100644
--- a/exercises/18_GPS_Field_QA/src/main.cpp
+++ b/exercises/18_GPS_Field_QA/src/main.cpp
@@ -5,11 +5,13 @@
#include Recent Events
";
+ const size_t start = (g_eventLogCount < kEventLogCapacity) ? 0 : g_eventLogHead;
+ for (size_t i = 0; i < g_eventLogCount; ++i) {
+ const size_t idx = (start + i) % kEventLogCapacity;
+ html += htmlEscape(String(g_eventLog[idx]));
+ html += "\n";
+ }
+ html += "";
+}
+
String htmlEscape(const String& in) {
String out;
out.reserve(in.length() + 16);
@@ -151,6 +212,8 @@ void printSummary() {
Serial.printf("storage_used_bytes=%u\n", g_storageMounted ? (unsigned)SD.usedBytes() : 0U);
Serial.printf("storage_buffered_bytes=%u\n", (unsigned)g_storage.bufferedBytes());
Serial.printf("storage_log_count=%u\n", (unsigned)g_logFileCount);
+ Serial.printf("halt_reason=%s\n", g_lastHaltReason);
+ Serial.printf("battery_voltage=%s\n", g_battery.voltageText());
Serial.printf("web_ready=%s\n", g_webReady ? "yes" : "no");
Serial.printf("web_url=http://192.168.%u.1/\n", (unsigned)kLogApIpOctet);
}
@@ -207,7 +270,11 @@ bool ensureStorageReady() {
g_sampleSeq = 0;
g_runStartMs = millis();
g_loggingEnabled = true;
+ setLastHaltReason("none");
+ g_displayAutoSleepEnabled = true;
+ wakeDisplay();
g_logFileCount = g_storage.logFileCount();
+ recordEvent("logging_started path=%s", g_storage.currentPath());
Serial.printf("logging started: %s\n", g_storage.currentPath());
return true;
}
@@ -216,10 +283,14 @@ void stopLoggingCleanly(const char* reason) {
if (!g_loggingEnabled && !g_storageReady) {
return;
}
- g_storage.flush();
+ (void)g_storage.flush();
g_storage.close();
g_storageReady = false;
g_loggingEnabled = false;
+ setLastHaltReason(reason);
+ g_displayAutoSleepEnabled = true;
+ wakeDisplay();
+ recordEvent("logging_stopped reason=%s", g_lastHaltReason);
if (reason && reason[0] != '\0') {
Serial.printf("logging stopped: %s\n", reason);
} else {
@@ -228,16 +299,36 @@ void stopLoggingCleanly(const char* reason) {
g_buttonStopMessageUntilMs = millis() + kButtonStopMessageMs;
}
+void stopLoggingForStorageFailure(const char* phase) {
+ g_storageReady = false;
+ g_loggingEnabled = false;
+ setLastHaltReason("sd_write_failed");
+ g_displayAutoSleepEnabled = true;
+ wakeDisplay();
+ recordEvent("sd_write_failed phase=%s err=%s",
+ phase ? phase : "unknown",
+ g_storage.lastError()[0] ? g_storage.lastError() : "none");
+ Serial.printf("ERROR: sd_write_failed phase=%s err=%s\n",
+ phase ? phase : "unknown",
+ g_storage.lastError()[0] ? g_storage.lastError() : "none");
+ g_buttonStopMessageUntilMs = millis() + kButtonStopMessageMs;
+}
+
bool rescanSdCard() {
- g_storage.flush();
+ recordEvent("sd_rescan requested");
+ (void)g_storage.flush();
g_storage.close();
g_storageReady = false;
g_loggingEnabled = false;
const bool mounted = g_sd.forceRemount();
g_storageMounted = g_sd.isMounted();
+ wakeDisplay();
if (mounted) {
+ recordEvent("sd_rescan mounted");
g_sd.printCardInfo();
(void)ensureStorageReady();
+ } else {
+ recordEvent("sd_rescan failed");
}
return mounted;
}
@@ -246,6 +337,10 @@ void pollStopButton() {
const uint32_t now = millis();
const bool pressed = (digitalRead(BUTTON_PIN) == LOW);
+ if (pressed) {
+ wakeDisplay();
+ }
+
if (g_buttonConfirmActive && (int32_t)(now - g_buttonConfirmDeadlineMs) >= 0) {
g_buttonConfirmActive = false;
}
@@ -254,7 +349,7 @@ void pollStopButton() {
g_buttonPressedMs = now;
g_buttonHoldHandled = false;
if (g_buttonConfirmActive && g_loggingEnabled) {
- stopLoggingCleanly("button confirm");
+ stopLoggingCleanly("button_confirm");
g_buttonConfirmActive = false;
}
} else if (pressed && !g_buttonHoldHandled && !g_buttonConfirmActive && g_loggingEnabled &&
@@ -262,7 +357,8 @@ void pollStopButton() {
g_buttonHoldHandled = true;
g_buttonConfirmActive = true;
g_buttonConfirmDeadlineMs = now + kButtonConfirmWindowMs;
- g_display.showBoot("Stop recording?", "Press again in 3s");
+ recordEvent("button_prompt stop_recording");
+ g_display.showBoot("Stop recording?", "Press again in 3s", nullptr, g_battery.voltageText());
}
if (!pressed && g_buttonPrevPressed) {
@@ -276,11 +372,14 @@ void handleSdStateTransitions() {
g_sd.update();
if (g_sd.consumeMountedEvent()) {
g_storageMounted = true;
+ recordEvent("sd_mounted");
Serial.println("SD mounted");
g_sd.printCardInfo();
(void)ensureStorageReady();
}
if (g_sd.consumeRemovedEvent()) {
+ setLastHaltReason("sd_removed");
+ recordEvent("sd_removed");
Serial.println("SD removed");
g_storageMounted = false;
g_storage.close();
@@ -314,6 +413,9 @@ void attemptClockDiscipline(const GnssSample& sample) {
setRunIdentityFromClock(disciplinedUtc);
g_clockDisciplined = true;
+ g_displayAutoSleepEnabled = true;
+ wakeDisplay();
+ recordEvent("clock_disciplined run_id=%s", g_runId);
Serial.printf("RTC disciplined to GPS: %s", g_bootTimestampUtc);
if (hadPriorRtc) {
Serial.printf(" drift=%+llds", (long long)driftSeconds);
@@ -324,6 +426,7 @@ void attemptClockDiscipline(const GnssSample& sample) {
}
void sampleAndMaybeLog() {
+ g_battery.update(millis());
GnssSample sample = g_gnss.makeSample();
g_stats.updateFromSample(sample, millis());
sample.ttffMs = g_stats.ttffMs();
@@ -336,8 +439,12 @@ void sampleAndMaybeLog() {
const uint32_t msSinceRunStart = millis() - g_runStartMs;
SatelliteInfo sats[kMaxSatellites];
const size_t satCount = g_gnss.copySatellites(sats, kMaxSatellites);
- g_storage.appendSampleCsv(sample, sampleSeq, msSinceRunStart, g_runId, g_bootTimestampUtc);
- g_storage.appendSatelliteCsv(sample, sampleSeq, msSinceRunStart, sats, satCount, g_runId, g_bootTimestampUtc);
+ if (!g_storage.appendSampleCsv(sample, sampleSeq, msSinceRunStart, g_runId, g_bootTimestampUtc)) {
+ stopLoggingForStorageFailure("append_sample");
+ } else if (satCount > 0 &&
+ !g_storage.appendSatelliteCsv(sample, sampleSeq, msSinceRunStart, sats, satCount, g_runId, g_bootTimestampUtc)) {
+ stopLoggingForStorageFailure("append_satellite");
+ }
}
if (g_periodicSerialEnabled && (uint32_t)(millis() - g_lastStatusMs) >= kStatusPeriodMs) {
@@ -348,16 +455,20 @@ void sampleAndMaybeLog() {
g_lastDisplayMs = millis();
if (g_clockDisciplined) {
if (g_buttonConfirmActive) {
- g_display.showBoot("Stop recording?", "Press again in 3s");
+ g_display.showBoot("Stop recording?", "Press again in 3s", nullptr, g_battery.voltageText());
} else if ((uint32_t)(millis() - g_buttonStopMessageUntilMs) < kButtonStopMessageMs) {
- g_display.showBoot("Halted", "Safe to power off");
- } else {
- g_display.showSample(sample, g_stats, g_loggingEnabled);
+ g_display.showBoot("Halted", g_lastHaltReason, "Safe to power off", g_battery.voltageText());
+ } else if (!g_display.powerSave()) {
+ g_display.showSample(sample, g_stats, g_loggingEnabled, g_battery.voltageText());
}
} else {
- g_display.showBoot("Waiting for GPS UTC", sample.validTime ? "Awaiting PPS" : "No valid time yet");
+ g_display.showBoot("Waiting for GPS UTC",
+ sample.validTime ? "Awaiting PPS" : "No valid time yet",
+ nullptr,
+ g_battery.voltageText());
}
}
+ updateDisplayPowerState();
}
void buildFileTreeHtml(String& html, const char* path) {
@@ -430,7 +541,7 @@ bool normalizeWebPath(const String& input, String& out) {
}
void handleWebIndex() {
- g_storage.flush();
+ (void)g_storage.flush();
String html;
html.reserve(8192);
html += "
Current log: ";
html += htmlEscape(String(g_storage.currentPath()));
+ html += "
Halt reason: ";
+ html += htmlEscape(String(g_lastHaltReason));
+ html += "
Battery: ";
+ html += htmlEscape(String(g_battery.voltageText()));
html += "
status "; html += "flush "; @@ -470,12 +585,13 @@ void handleWebIndex() { html += "
Web commands also accept query forms like ";
html += "/cmd?erase=/logs/20260406_093912_CY.csv