From 15a5dbe006019cdf61f93244d90059d91f00f646 Mon Sep 17 00:00:00 2001 From: John Poole Date: Tue, 7 Apr 2026 20:48:12 -0700 Subject: [PATCH] had OLED sleep ability and better SD card mount monitoring and ring buffer for error messages --- .gitignore | 9 + .../18_GPS_Field_QA/lib/field_qa/Config.h | 6 +- .../lib/field_qa/DisplayManager.cpp | 35 +++- .../lib/field_qa/DisplayManager.h | 10 +- .../lib/field_qa/StorageManager.cpp | 28 +-- .../lib/field_qa/StorageManager.h | 6 +- exercises/18_GPS_Field_QA/platformio.ini | 7 + exercises/18_GPS_Field_QA/src/main.cpp | 162 +++++++++++++++--- 8 files changed, 223 insertions(+), 40 deletions(-) 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 #include #include +#include #include #include #include #include +#include "BatteryMonitor.h" #include "ClockDiscipline.h" #include "Config.h" #include "DisplayManager.h" @@ -27,6 +29,7 @@ XPowersLibInterface* g_pmu = nullptr; DisplayManager g_display; GnssManager g_gnss; ClockDiscipline g_clock; +BatteryMonitor g_battery; StorageManager g_storage; RunStats g_stats; StartupSdManager g_sd(Serial); @@ -53,6 +56,9 @@ bool g_buttonHoldHandled = false; uint32_t g_buttonPressedMs = 0; uint32_t g_buttonConfirmDeadlineMs = 0; uint32_t g_buttonStopMessageUntilMs = 0; +char g_lastHaltReason[32] = "none"; +bool g_displayAutoSleepEnabled = false; +uint32_t g_displayWakeUntilMs = 0; uint32_t g_lastSampleMs = 0; uint32_t g_lastFlushMs = 0; @@ -64,11 +70,66 @@ volatile uint32_t g_ppsEdgeCount = 0; static constexpr uint32_t kButtonHoldPromptMs = 1500; static constexpr uint32_t kButtonConfirmWindowMs = 3000; static constexpr uint32_t kButtonStopMessageMs = 4000; +static constexpr uint32_t kDisplayWakeMs = 120000; +static constexpr size_t kEventLogCapacity = 128; +static constexpr size_t kEventLogLineBytes = 96; +char g_eventLog[kEventLogCapacity][kEventLogLineBytes] = {}; +size_t g_eventLogHead = 0; +size_t g_eventLogCount = 0; void IRAM_ATTR onPpsEdge() { ++g_ppsEdgeCount; } +String htmlEscape(const String& in); + +void recordEvent(const char* fmt, ...) { + char msg[72]; + va_list args; + va_start(args, fmt); + vsnprintf(msg, sizeof(msg), fmt, args); + va_end(args); + + char line[kEventLogLineBytes]; + snprintf(line, sizeof(line), "%10lu %s", (unsigned long)millis(), msg); + strlcpy(g_eventLog[g_eventLogHead], line, sizeof(g_eventLog[g_eventLogHead])); + g_eventLogHead = (g_eventLogHead + 1) % kEventLogCapacity; + if (g_eventLogCount < kEventLogCapacity) { + ++g_eventLogCount; + } + Serial.printf("EVENT: %s\n", msg); +} + +void setLastHaltReason(const char* reason) { + strlcpy(g_lastHaltReason, (reason && reason[0] != '\0') ? reason : "none", sizeof(g_lastHaltReason)); +} + +void wakeDisplay(uint32_t durationMs = kDisplayWakeMs) { + g_display.setPowerSave(false); + g_displayWakeUntilMs = millis() + durationMs; +} + +void updateDisplayPowerState() { + if (!g_displayAutoSleepEnabled) { + g_display.setPowerSave(false); + return; + } + if ((int32_t)(millis() - g_displayWakeUntilMs) >= 0) { + g_display.setPowerSave(true); + } +} + +void appendRecentEventsHtml(String& html) { + html += "

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 += "GPSQA "; @@ -453,6 +564,10 @@ void handleWebIndex() { html += htmlEscape(String(g_storage.lastError())); html += "<br>Current log: "; html += htmlEscape(String(g_storage.currentPath())); + html += "<br>Halt reason: "; + html += htmlEscape(String(g_lastHaltReason)); + html += "<br>Battery: "; + html += htmlEscape(String(g_battery.voltageText())); html += "</p>"; html += "<p><a href='/cmd?status=1'>status</a> "; html += "<a href='/cmd?flush=1'>flush</a> "; @@ -470,12 +585,13 @@ void handleWebIndex() { html += "</ul><p>Web commands also accept query forms like "; html += "<code>/cmd?erase=/logs/20260406_093912_CY.csv</code></p>"; + appendRecentEventsHtml(html); html += "</body></html>"; g_server.send(200, "text/html; charset=utf-8", html); } void handleWebDownload() { - g_storage.flush(); + (void)g_storage.flush(); String pathArg = g_server.hasArg("path") ? g_server.arg("path") : g_server.arg("name"); String fullPath; if (!normalizeWebPath(pathArg, fullPath)) { @@ -513,7 +629,7 @@ void handleWebCommand() { String response; if (g_server.hasArg("erase")) { - g_storage.flush(); + (void)g_storage.flush(); const String path = g_server.arg("erase"); if (g_storage.eraseFile(path.c_str())) { g_storageReady = g_storage.ready(); @@ -526,7 +642,7 @@ void handleWebCommand() { response = String("erase failed: ") + g_storage.lastError(); } } else if (g_server.hasArg("erase_logs")) { - g_storage.flush(); + (void)g_storage.flush(); g_storage.eraseLogs(Serial); g_storageReady = g_storage.ready(); if (!g_storageReady) { @@ -535,8 +651,7 @@ void handleWebCommand() { g_logFileCount = g_storage.logFileCount(); response = "logs erased"; } else if (g_server.hasArg("flush")) { - g_storage.flush(); - response = "buffer flushed"; + response = g_storage.flush() ? "buffer flushed" : String("flush failed: ") + g_storage.lastError(); } else if (g_server.hasArg("stop")) { stopLoggingCleanly("web stop"); response = "logging stopped"; @@ -566,6 +681,8 @@ void handleWebCommand() { response += g_storageReady ? "yes" : "no"; response += "\nsd_state="; response += g_storageMounted ? "mounted" : "absent"; + response += "\nhalt_reason="; + response += g_lastHaltReason; } else { response = "commands: status flush start stop sd_rescan erase=<path> erase_logs=1"; } @@ -700,9 +817,11 @@ void setup() { Serial.println("WARNING: PMU init failed"); } + g_battery.begin(g_pmu); g_display.begin(); pinMode(BUTTON_PIN, INPUT_PULLUP); - g_display.showBoot("Booting...", kBoardId); + wakeDisplay(); + g_display.showBoot("Booting...", kBoardId, nullptr, g_battery.voltageText()); g_stats.begin(millis()); g_gnss.begin(); (void)g_gnss.probeAtStartup(Serial); @@ -731,8 +850,9 @@ void setup() { Serial.println("RTC invalid at boot"); } + recordEvent("boot board=%s", kBoardId); printProvenance(); - g_display.showBoot("Waiting for GPS UTC", "No log before RTC set"); + g_display.showBoot("Waiting for GPS UTC", "No log before RTC set", nullptr, g_battery.voltageText()); g_lastSampleMs = millis(); g_lastFlushMs = millis(); @@ -752,6 +872,8 @@ void loop() { } if (g_storageReady && (uint32_t)(now - g_lastFlushMs) >= kLogFlushPeriodMs) { g_lastFlushMs = now; - g_storage.flush(); + if (!g_storage.flush()) { + stopLoggingForStorageFailure("periodic_flush"); + } } }