diff --git a/.gitignore b/.gitignore index 94818e5..ae506da 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ /hold/ .platformio_local/ +.codex diff --git a/exercises/09_GPS_Time/README.md b/exercises/09_GPS_Time/README.md index a8a1a81..f3e2889 100644 --- a/exercises/09_GPS_Time/README.md +++ b/exercises/09_GPS_Time/README.md @@ -1,4 +1,4 @@ -## Exercise 09: GPS Time (L76K) +## Exercise 09: GPS Time (L76K + UBLOX) This exercise boots the T-Beam Supreme and verifies GPS behavior at startup. @@ -13,11 +13,10 @@ Implemented behavior: 1. Initializes PMU, OLED, and SD startup watcher (same startup SD path used in Exercise 08). 2. Probes GPS at startup for NMEA traffic, module identity, satellite count, and UTC time availability. - Uses explicit GPS UART pins and an active startup probe (multi-baud + common GPS query commands), aligned with the approach validated in Exercise 10. -3. If L76K is detected, normal GPS-time flow continues. -4. If L76K is not detected and Quectel-style module text is detected, OLED shows a hard TODO error: - - Quectel detected - - L76K required - - Quectel support is TODO +3. Supports both module profiles via `platformio.ini` build flags: + - `node_a` / `node_b`: `GPS_L76K` + - `node_c`: `GPS_UBLOX` +4. If detected module data conflicts with the selected node profile, OLED shows a `GPS module mismatch` error. 5. Every minute: - If GPS UTC is valid: shows GPS UTC time and satellites on OLED. - If satellites are seen but UTC is not valid yet: shows that condition and RTC time. @@ -31,8 +30,12 @@ Implemented behavior: Notes: - GPS time displayed is UTC from NMEA RMC with valid status. -- Satellite count uses best available from GGA/GSV. +- Satellite count uses best available from GGA/GSA/GSV. - RTC fallback reads PCF8563 via Wire1. +- For UBLOX hardware use `-e node_c`. +- The UBLOX MAX-M10S path is given a longer startup window than L76K because cold starts are slower, especially if backup power/orbit data are unavailable. +- On T-Beam Supreme, `GPS_WAKEUP_PIN=7` is relevant for the L76K variant; the UBLOX MAX-M10S does not use that wake pin in the same way. +- For fastest UBLOX reacquisition, test with the 18650 attached so the GNSS backup domain can preserve assistance state across resets/power cycles. ## Build diff --git a/exercises/09_GPS_Time/platformio.ini b/exercises/09_GPS_Time/platformio.ini index b4d48d9..96344b6 100644 --- a/exercises/09_GPS_Time/platformio.ini +++ b/exercises/09_GPS_Time/platformio.ini @@ -40,3 +40,9 @@ build_flags = build_flags = ${env.build_flags} -D NODE_LABEL=\"B\" + +[env:node_c] +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"C\" + -D GPS_UBLOX \ No newline at end of file diff --git a/exercises/09_GPS_Time/src/main.cpp b/exercises/09_GPS_Time/src/main.cpp index 22d81d7..02d3f39 100644 --- a/exercises/09_GPS_Time/src/main.cpp +++ b/exercises/09_GPS_Time/src/main.cpp @@ -3,6 +3,7 @@ // $HeadURL$ #include +#include #include #include @@ -30,8 +31,12 @@ #endif static const uint32_t kSerialDelayMs = 5000; -static const uint32_t kGpsStartupProbeMs = 20000; static const uint32_t kMinuteMs = 60000; +static const uint32_t kGpsDiagnosticLogMs = 15000; +static const char* kGpsLogDir = "/gpsdiag"; +static const char* kGpsLogPath = "/gpsdiag/current.log"; +static const char* kBuildDate = __DATE__; +static const char* kBuildTime = __TIME__; static XPowersLibInterface* g_pmu = nullptr; static StartupSdManager g_sd(Serial); @@ -40,15 +45,34 @@ static HardwareSerial g_gpsSerial(1); static uint32_t g_logSeq = 0; static uint32_t g_lastMinuteReportMs = 0; +static uint32_t g_lastGpsDiagnosticLogMs = 0; static uint32_t g_gpsBaud = GPS_BAUD; +static int g_gpsRxPin = GPS_RX_PIN; +static int g_gpsTxPin = GPS_TX_PIN; +static bool g_spiffsReady = false; +static bool g_ubloxConfigAttempted = false; +static bool g_ubloxConfigured = false; +static bool g_ubloxIsM10 = false; static bool g_prevHadSatellites = false; static bool g_prevHadValidUtc = false; static bool g_satellitesAcquiredAnnounced = false; static bool g_timeAcquiredAnnounced = false; +static uint8_t g_lastDrawnSatsUsed = 255; +static uint8_t g_lastDrawnSatsView = 255; +static bool g_lastDrawnValidUtc = false; +static bool g_haveLastDrawnState = false; +static uint32_t g_lastDisplayRefreshMs = 0; static char g_gpsLine[128]; static size_t g_gpsLineLen = 0; +static char g_serialLine[128]; +static size_t g_serialLineLen = 0; +static uint8_t g_rawLogGgaCount = 0; +static uint8_t g_rawLogGsaCount = 0; +static uint8_t g_rawLogGsvCount = 0; +static uint8_t g_rawLogRmcCount = 0; +static uint8_t g_rawLogPubxCount = 0; enum class GpsModuleKind : uint8_t { UNKNOWN = 0, @@ -56,6 +80,14 @@ enum class GpsModuleKind : uint8_t { UBLOX }; +#if defined(GPS_UBLOX) +static const GpsModuleKind kExpectedGpsModule = GpsModuleKind::UBLOX; +#elif defined(GPS_L76K) +static const GpsModuleKind kExpectedGpsModule = GpsModuleKind::L76K; +#else +static const GpsModuleKind kExpectedGpsModule = GpsModuleKind::UNKNOWN; +#endif + struct RtcDateTime { uint16_t year; uint8_t month; @@ -66,13 +98,18 @@ struct RtcDateTime { }; struct GpsState { - GpsModuleKind module = GpsModuleKind::UNKNOWN; + GpsModuleKind module = kExpectedGpsModule; bool sawAnySentence = false; uint8_t satsUsed = 0; uint8_t satsInView = 0; + uint8_t satsUsedWindowMax = 0; + uint8_t satsInViewWindowMax = 0; + uint32_t satsUsedWindowMs = 0; + uint32_t satsInViewWindowMs = 0; bool hasValidUtc = false; + uint32_t utcFixMs = 0; uint16_t utcYear = 0; uint8_t utcMonth = 0; uint8_t utcDay = 0; @@ -82,6 +119,355 @@ struct GpsState { }; static GpsState g_gps; +static const uint32_t kSatelliteWindowMs = 2000; +static const uint32_t kDisplayRefreshMinMs = 1000; +static const uint32_t kFixFreshMs = 5000; + +static String gpsModuleToString(GpsModuleKind kind); +static GpsModuleKind activeGpsModule(); +static uint8_t bestSatelliteCount(); +static uint8_t displayedSatsUsed(); +static uint8_t displayedSatsInView(); +static bool displayHasFreshUtc(); +static String formatRtcNow(); + +static bool ensureGpsLogDirectory() { + if (!g_spiffsReady) { + return false; + } + if (SPIFFS.exists(kGpsLogDir)) { + return true; + } + return SPIFFS.mkdir(kGpsLogDir); +} + +static bool gpsDiagAppendLine(const char* line) { + if (!g_spiffsReady || !line) { + return false; + } + File file = SPIFFS.open(kGpsLogPath, FILE_APPEND); + if (!file) { + return false; + } + file.print(line); + file.print("\r\n"); + file.close(); + return true; +} + +static void formatGpsSnapshot(char* out, size_t outSize, const char* event) { + if (!out || outSize == 0) { + return; + } + + const uint8_t sats = bestSatelliteCount(); + const char* ev = event ? event : "sample"; + if (g_gps.hasValidUtc) { + snprintf(out, + outSize, + "ms=%lu event=%s module=%s nmea=%s sats_used=%u sats_view=%u sats_best=%u utc=%04u-%02u-%02uT%02u:%02u:%02u rx=%d tx=%d baud=%lu", + (unsigned long)millis(), + ev, + gpsModuleToString(activeGpsModule()).c_str(), + g_gps.sawAnySentence ? "yes" : "no", + (unsigned)g_gps.satsUsed, + (unsigned)g_gps.satsInView, + (unsigned)sats, + (unsigned)g_gps.utcYear, + (unsigned)g_gps.utcMonth, + (unsigned)g_gps.utcDay, + (unsigned)g_gps.utcHour, + (unsigned)g_gps.utcMinute, + (unsigned)g_gps.utcSecond, + g_gpsRxPin, + g_gpsTxPin, + (unsigned long)g_gpsBaud); + } else { + String rtc = formatRtcNow(); + snprintf(out, + outSize, + "ms=%lu event=%s module=%s nmea=%s sats_used=%u sats_view=%u sats_best=%u utc=NO rtc=\"%s\" rx=%d tx=%d baud=%lu", + (unsigned long)millis(), + ev, + gpsModuleToString(activeGpsModule()).c_str(), + g_gps.sawAnySentence ? "yes" : "no", + (unsigned)g_gps.satsUsed, + (unsigned)g_gps.satsInView, + (unsigned)sats, + rtc.c_str(), + g_gpsRxPin, + g_gpsTxPin, + (unsigned long)g_gpsBaud); + } +} + +static void appendGpsSnapshot(const char* event) { + char line[256]; + formatGpsSnapshot(line, sizeof(line), event); + (void)gpsDiagAppendLine(line); +} + +static String buildStampShort() { + char buf[32]; + snprintf(buf, sizeof(buf), "%s %.5s", kBuildDate, kBuildTime); + return String(buf); +} + +static void maybeLogRawSentence(const char* type, const char* sentence) { + if (!type || !sentence || !g_spiffsReady) { + return; + } + + uint8_t* counter = nullptr; + if (strcmp(type, "GGA") == 0) { + counter = &g_rawLogGgaCount; + } else if (strcmp(type, "GSA") == 0) { + counter = &g_rawLogGsaCount; + } else if (strcmp(type, "GSV") == 0) { + counter = &g_rawLogGsvCount; + } else if (strcmp(type, "RMC") == 0) { + counter = &g_rawLogRmcCount; + } else if (strcmp(type, "PUBX") == 0) { + counter = &g_rawLogPubxCount; + } + + if (!counter || *counter >= 12) { + return; + } + (*counter)++; + + char line[220]; + snprintf(line, + sizeof(line), + "ms=%lu event=raw_%s idx=%u sentence=%s", + (unsigned long)millis(), + type, + (unsigned)*counter, + sentence); + (void)gpsDiagAppendLine(line); +} + +static void clearGpsSerialInput() { + g_gpsLineLen = 0; + while (g_gpsSerial.available() > 0) { + (void)g_gpsSerial.read(); + } +} + +static void ubxChecksum(uint8_t* message, size_t length) { + uint8_t ckA = 0; + uint8_t ckB = 0; + for (size_t i = 2; i < length - 2; ++i) { + ckA = (uint8_t)((ckA + message[i]) & 0xFF); + ckB = (uint8_t)((ckB + ckA) & 0xFF); + } + message[length - 2] = ckA; + message[length - 1] = ckB; +} + +static size_t makeUbxPacket(uint8_t* out, + size_t outSize, + uint8_t classId, + uint8_t msgId, + const uint8_t* payload, + uint16_t payloadSize) { + if (!out || outSize < (size_t)payloadSize + 8U) { + return 0; + } + out[0] = 0xB5; + out[1] = 0x62; + out[2] = classId; + out[3] = msgId; + out[4] = (uint8_t)(payloadSize & 0xFF); + out[5] = (uint8_t)((payloadSize >> 8) & 0xFF); + for (uint16_t i = 0; i < payloadSize; ++i) { + out[6 + i] = payload ? payload[i] : 0; + } + out[6 + payloadSize] = 0; + out[7 + payloadSize] = 0; + ubxChecksum(out, payloadSize + 8U); + return (size_t)payloadSize + 8U; +} + +static bool waitForUbxAck(uint8_t classId, uint8_t msgId, uint32_t waitMs) { + uint8_t ack[10] = {0xB5, 0x62, 0x05, 0x01, 0x02, 0x00, classId, msgId, 0x00, 0x00}; + ubxChecksum(ack, sizeof(ack)); + uint8_t ackPos = 0; + uint32_t deadline = millis() + waitMs; + + while ((int32_t)(deadline - millis()) > 0) { + if (g_gpsSerial.available() <= 0) { + delay(2); + continue; + } + uint8_t b = (uint8_t)g_gpsSerial.read(); + if (b == ack[ackPos]) { + ackPos++; + if (ackPos == sizeof(ack)) { + return true; + } + } else { + ackPos = (b == ack[0]) ? 1 : 0; + } + } + return false; +} + +static int waitForUbxPayload(uint8_t* buffer, + uint16_t bufferSize, + uint8_t classId, + uint8_t msgId, + uint32_t waitMs) { + uint16_t framePos = 0; + uint16_t needRead = 0; + uint32_t deadline = millis() + waitMs; + + while ((int32_t)(deadline - millis()) > 0) { + if (g_gpsSerial.available() <= 0) { + delay(2); + continue; + } + + int c = g_gpsSerial.read(); + switch (framePos) { + case 0: + framePos = (c == 0xB5) ? 1 : 0; + break; + case 1: + framePos = (c == 0x62) ? 2 : 0; + break; + case 2: + framePos = (c == classId) ? 3 : 0; + break; + case 3: + framePos = (c == msgId) ? 4 : 0; + break; + case 4: + needRead = (uint16_t)c; + framePos = 5; + break; + case 5: + needRead |= (uint16_t)(c << 8); + if (needRead == 0 || needRead >= bufferSize) { + framePos = 0; + break; + } + if (g_gpsSerial.readBytes(buffer, needRead) != needRead) { + framePos = 0; + break; + } + if (g_gpsSerial.available() >= 2) { + (void)g_gpsSerial.read(); + (void)g_gpsSerial.read(); + } + return (int)needRead; + default: + framePos = 0; + break; + } + } + + return 0; +} + +static bool detectUbloxM10() { + uint8_t packet[8]; + uint8_t payload[256] = {0}; + size_t len = makeUbxPacket(packet, sizeof(packet), 0x0A, 0x04, nullptr, 0); + if (len == 0) { + return false; + } + + clearGpsSerialInput(); + g_gpsSerial.write(packet, len); + int payloadLen = waitForUbxPayload(payload, sizeof(payload), 0x0A, 0x04, 1200); + if (payloadLen < 40) { + appendGpsSnapshot("ubx_monver_timeout"); + return false; + } + + char hwVersion[11] = {0}; + memcpy(hwVersion, payload + 30, 10); + char line[160]; + snprintf(line, sizeof(line), "ms=%lu event=ubx_monver hw=%s", (unsigned long)millis(), hwVersion); + (void)gpsDiagAppendLine(line); + + if (strncmp(hwVersion, "000A0000", 8) == 0) { + return true; + } + + for (int pos = 40; pos + 30 <= payloadLen; pos += 30) { + if (strncmp((const char*)(payload + pos), "PROTVER=", 8) == 0) { + int prot = atoi((const char*)(payload + pos + 8)); + snprintf(line, sizeof(line), "ms=%lu event=ubx_monver prot=%d", (unsigned long)millis(), prot); + (void)gpsDiagAppendLine(line); + if (prot >= 27) { + return true; + } + } + } + + return false; +} + +static bool sendUbxValset(uint8_t classId, + uint8_t msgId, + const uint8_t* payload, + uint16_t payloadLen, + uint32_t ackMs, + const char* eventName) { + uint8_t packet[96]; + size_t len = makeUbxPacket(packet, sizeof(packet), classId, msgId, payload, payloadLen); + if (len == 0) { + return false; + } + clearGpsSerialInput(); + g_gpsSerial.write(packet, len); + bool ok = waitForUbxAck(classId, msgId, ackMs); + char line[160]; + snprintf(line, + sizeof(line), + "ms=%lu event=%s ack=%s", + (unsigned long)millis(), + eventName ? eventName : "ubx_cfg", + ok ? "yes" : "no"); + (void)gpsDiagAppendLine(line); + delay(150); + return ok; +} + +static bool configureUbloxM10() { + static const uint8_t kValsetDisableTxtRam[] = {0x00, 0x01, 0x00, 0x00, 0x07, 0x00, 0x92, 0x20, 0x03}; + static const uint8_t kValsetDisableTxtBbr[] = {0x00, 0x02, 0x00, 0x00, 0x07, 0x00, 0x92, 0x20, 0x03}; + static const uint8_t kValsetEnableNmeaRam[] = {0x00, 0x01, 0x00, 0x00, 0xbb, 0x00, 0x91, 0x20, 0x01, 0xac, 0x00, 0x91, 0x20, 0x01}; + static const uint8_t kValsetEnableNmeaBbr[] = {0x00, 0x02, 0x00, 0x00, 0xbb, 0x00, 0x91, 0x20, 0x01, 0xac, 0x00, 0x91, 0x20, 0x01}; + static const uint8_t kSave10[] = {0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}; + + bool ok = true; + ok &= sendUbxValset(0x06, 0x8A, kValsetDisableTxtRam, sizeof(kValsetDisableTxtRam), 300, "ubx_m10_disable_txt_ram"); + ok &= sendUbxValset(0x06, 0x8A, kValsetDisableTxtBbr, sizeof(kValsetDisableTxtBbr), 300, "ubx_m10_disable_txt_bbr"); + ok &= sendUbxValset(0x06, 0x8A, kValsetEnableNmeaBbr, sizeof(kValsetEnableNmeaBbr), 400, "ubx_m10_enable_nmea_bbr"); + ok &= sendUbxValset(0x06, 0x8A, kValsetEnableNmeaRam, sizeof(kValsetEnableNmeaRam), 400, "ubx_m10_enable_nmea_ram"); + ok &= sendUbxValset(0x06, 0x09, kSave10, sizeof(kSave10), 800, "ubx_m10_save"); + appendGpsSnapshot(ok ? "ubx_m10_configured" : "ubx_m10_config_failed"); + return ok; +} + +static void maybeConfigureUblox() { + if (g_ubloxConfigAttempted || kExpectedGpsModule != GpsModuleKind::UBLOX) { + return; + } + g_ubloxConfigAttempted = true; + appendGpsSnapshot("ubx_config_attempt"); + + g_ubloxIsM10 = detectUbloxM10(); + if (!g_ubloxIsM10) { + appendGpsSnapshot("ubx_non_m10_or_unknown"); + return; + } + + g_ubloxConfigured = configureUbloxM10(); +} static void logf(const char* fmt, ...) { char msg[220]; @@ -197,9 +583,7 @@ static void detectModuleFromText(const char* text) { } if (t.indexOf("QUECTEL") >= 0 || t.indexOf("2021-10-20") >= 0 || t.indexOf("2021/10/20") >= 0) { - if (g_gps.module != GpsModuleKind::L76K) { - g_gps.module = GpsModuleKind::UBLOX; - } + g_gps.module = GpsModuleKind::L76K; } } @@ -210,6 +594,10 @@ static void parseGga(char* fields[], int count) { int sats = atoi(fields[7]); if (sats >= 0 && sats <= 255) { g_gps.satsUsed = (uint8_t)sats; + if ((uint8_t)sats > g_gps.satsUsedWindowMax) { + g_gps.satsUsedWindowMax = (uint8_t)sats; + } + g_gps.satsUsedWindowMs = millis(); } } @@ -220,6 +608,16 @@ static void parseGsv(char* fields[], int count) { int sats = atoi(fields[3]); if (sats >= 0 && sats <= 255) { g_gps.satsInView = (uint8_t)sats; + if ((uint8_t)sats > g_gps.satsInViewWindowMax) { + g_gps.satsInViewWindowMax = (uint8_t)sats; + } + g_gps.satsInViewWindowMs = millis(); + } +} + +static void parseGsa(char* fields[], int count) { + if (count <= 3) { + return; } } @@ -256,6 +654,7 @@ static void parseRmc(char* fields[], int count) { g_gps.utcMonth = mo; g_gps.utcYear = (uint16_t)(2000U + yy); g_gps.hasValidUtc = true; + g_gps.utcFixMs = millis(); } static void parseTxt(char* fields[], int count) { @@ -265,11 +664,34 @@ static void parseTxt(char* fields[], int count) { detectModuleFromText(fields[4]); } +static int 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; +} + static void processNmeaLine(char* line) { if (!line || line[0] != '$') { return; } g_gps.sawAnySentence = true; + char rawLine[128]; + strncpy(rawLine, line, sizeof(rawLine) - 1); + rawLine[sizeof(rawLine) - 1] = '\0'; char* star = strchr(line, '*'); if (star) { @@ -277,26 +699,28 @@ static void processNmeaLine(char* line) { } char* fields[24] = {0}; - int count = 0; - char* saveptr = nullptr; - char* tok = strtok_r(line, ",", &saveptr); - while (tok && count < 24) { - fields[count++] = tok; - tok = strtok_r(nullptr, ",", &saveptr); - } + int count = splitCsvPreserveEmpty(line, fields, 24); if (count <= 0 || !fields[0]) { return; } const char* header = fields[0]; + if (strcmp(header, "$PUBX") == 0) { + g_gps.module = GpsModuleKind::UBLOX; + maybeLogRawSentence("PUBX", rawLine); + return; + } size_t n = strlen(header); if (n < 6) { return; } const char* type = header + (n - 3); + maybeLogRawSentence(type, rawLine); 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) { @@ -329,21 +753,145 @@ static void pollGpsSerial() { } } -static void startGpsUart(uint32_t baud) { +static void showGpsLogHelp() { + Serial.println("Command list:"); + Serial.println(" help - show command menu"); + Serial.println(" stat - show current GPS log file info"); + Serial.println(" list - list files in /gpsdiag"); + Serial.println(" read - dump current GPS log"); + Serial.println(" clear - erase current GPS log"); +} + +static void gpsLogStat() { + Serial.printf("SPIFFS: %s\r\n", g_spiffsReady ? "ready" : "not ready"); + Serial.printf("Path: %s\r\n", kGpsLogPath); + if (!g_spiffsReady || !SPIFFS.exists(kGpsLogPath)) { + Serial.println("Current GPS log does not exist"); + return; + } + File file = SPIFFS.open(kGpsLogPath, FILE_READ); + if (!file) { + Serial.println("Unable to open current GPS log"); + return; + } + Serial.printf("Size: %u bytes\r\n", (unsigned)file.size()); + file.close(); + Serial.printf("Flash total=%u used=%u free=%u\r\n", + (unsigned)SPIFFS.totalBytes(), + (unsigned)SPIFFS.usedBytes(), + (unsigned)(SPIFFS.totalBytes() - SPIFFS.usedBytes())); +} + +static void gpsLogList() { + if (!g_spiffsReady) { + Serial.println("SPIFFS not ready"); + return; + } + File dir = SPIFFS.open(kGpsLogDir); + if (!dir || !dir.isDirectory()) { + Serial.printf("Unable to open %s\r\n", kGpsLogDir); + return; + } + Serial.printf("Files in %s:\r\n", kGpsLogDir); + File file = dir.openNextFile(); + while (file) { + Serial.printf(" %s (%u bytes)\r\n", file.name(), (unsigned)file.size()); + file = dir.openNextFile(); + } +} + +static void gpsLogRead() { + if (!g_spiffsReady || !SPIFFS.exists(kGpsLogPath)) { + Serial.println("Current GPS log is not available"); + return; + } + File file = SPIFFS.open(kGpsLogPath, FILE_READ); + if (!file) { + Serial.println("Unable to read current GPS log"); + return; + } + Serial.printf("Reading %s:\r\n", kGpsLogPath); + while (file.available()) { + Serial.write(file.read()); + } + if (file.size() > 0) { + Serial.println(); + } + file.close(); +} + +static void gpsLogClear() { + if (!g_spiffsReady) { + Serial.println("SPIFFS not ready"); + return; + } + File file = SPIFFS.open(kGpsLogPath, FILE_WRITE); + if (!file) { + Serial.println("Unable to clear current GPS log"); + return; + } + file.close(); + Serial.printf("Cleared %s\r\n", kGpsLogPath); +} + +static void processSerialCommand(const char* line) { + if (!line || line[0] == '\0') { + return; + } + Serial.printf("-->%s\r\n", line); + if (strcasecmp(line, "help") == 0) { + showGpsLogHelp(); + } else if (strcasecmp(line, "stat") == 0) { + gpsLogStat(); + } else if (strcasecmp(line, "list") == 0) { + gpsLogList(); + } else if (strcasecmp(line, "read") == 0) { + gpsLogRead(); + } else if (strcasecmp(line, "clear") == 0) { + gpsLogClear(); + } else { + Serial.println("Unknown command (help for list)"); + } +} + +static 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'; + processSerialCommand(g_serialLine); + g_serialLineLen = 0; + } + continue; + } + if (g_serialLineLen + 1 < sizeof(g_serialLine)) { + g_serialLine[g_serialLineLen++] = (char)c; + } else { + g_serialLineLen = 0; + } + } +} + +static void startGpsUart(uint32_t baud, int rxPin, int txPin) { g_gpsSerial.end(); delay(20); g_gpsSerial.setRxBufferSize(1024); - g_gpsSerial.begin(baud, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN); + g_gpsSerial.begin(baud, SERIAL_8N1, rxPin, txPin); g_gpsBaud = baud; + g_gpsRxPin = rxPin; + g_gpsTxPin = txPin; } static bool collectGpsTraffic(uint32_t windowMs, bool updateSd) { uint32_t start = millis(); - size_t bytesSeen = 0; + bool sawBytes = false; while ((uint32_t)(millis() - start) < windowMs) { - while (g_gpsSerial.available() > 0) { - (void)g_gpsSerial.read(); - bytesSeen++; + if (g_gpsSerial.available() > 0) { + sawBytes = true; } pollGpsSerial(); if (updateSd) { @@ -351,12 +899,12 @@ static bool collectGpsTraffic(uint32_t windowMs, bool updateSd) { } delay(2); } - return bytesSeen > 0 || g_gps.sawAnySentence; + return sawBytes || g_gps.sawAnySentence; } -static bool probeGpsAtBaud(uint32_t baud) { - startGpsUart(baud); - logf("Probing GPS at %lu baud...", (unsigned long)baud); +static bool probeGpsAtBaud(uint32_t baud, int rxPin, int txPin) { + startGpsUart(baud, rxPin, txPin); + logf("Probing GPS at %lu baud on RX=%d TX=%d...", (unsigned long)baud, rxPin, txPin); if (collectGpsTraffic(700, true)) { return true; } @@ -365,6 +913,7 @@ static bool probeGpsAtBaud(uint32_t baud) { g_gpsSerial.write("$PCAS06,0*1B\r\n"); g_gpsSerial.write("$PMTK605*31\r\n"); g_gpsSerial.write("$PQTMVERNO*58\r\n"); + g_gpsSerial.write("$PUBX,00*33\r\n"); g_gpsSerial.write("$PMTK353,1,1,1,1,1*2A\r\n"); g_gpsSerial.write("$PMTK314,0,1,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0*29\r\n"); @@ -373,32 +922,94 @@ static bool probeGpsAtBaud(uint32_t baud) { static void initialGpsProbe() { const uint32_t bauds[] = {GPS_BAUD, 115200, 38400, 57600, 19200}; - for (size_t i = 0; i < sizeof(bauds) / sizeof(bauds[0]); ++i) { - if (probeGpsAtBaud(bauds[i])) { - logf("GPS traffic detected at %lu baud", (unsigned long)g_gpsBaud); - return; + int pinCandidates[2][2] = { + {GPS_RX_PIN, GPS_TX_PIN}, + {34, 12}, // Legacy T-Beam UBLOX mapping. + }; + size_t pinCount = 1; + if (kExpectedGpsModule == GpsModuleKind::UBLOX && + !(GPS_RX_PIN == 34 && GPS_TX_PIN == 12)) { + pinCount = 2; + } + + for (size_t p = 0; p < pinCount; ++p) { + int rxPin = pinCandidates[p][0]; + int txPin = pinCandidates[p][1]; + for (size_t i = 0; i < sizeof(bauds) / sizeof(bauds[0]); ++i) { + if (probeGpsAtBaud(bauds[i], rxPin, txPin)) { + logf("GPS traffic detected at %lu baud on RX=%d TX=%d", + (unsigned long)g_gpsBaud, g_gpsRxPin, g_gpsTxPin); + return; + } } } logf("No GPS traffic detected during startup probe"); } +static uint32_t startupProbeWindowMs() { + return (kExpectedGpsModule == GpsModuleKind::UBLOX) ? 45000U : 20000U; +} + +static GpsModuleKind activeGpsModule() { + if (g_gps.module != GpsModuleKind::UNKNOWN) { + return g_gps.module; + } + return kExpectedGpsModule; +} + static uint8_t bestSatelliteCount() { - return (g_gps.satsUsed > g_gps.satsInView) ? g_gps.satsUsed : g_gps.satsInView; + uint32_t now = millis(); + + if ((uint32_t)(now - g_gps.satsUsedWindowMs) > kSatelliteWindowMs) { + g_gps.satsUsedWindowMax = g_gps.satsUsed; + } + if ((uint32_t)(now - g_gps.satsInViewWindowMs) > kSatelliteWindowMs) { + g_gps.satsInViewWindowMax = g_gps.satsInView; + } + + uint8_t used = (g_gps.satsUsedWindowMax > g_gps.satsUsed) ? g_gps.satsUsedWindowMax : g_gps.satsUsed; + uint8_t inView = (g_gps.satsInViewWindowMax > g_gps.satsInView) ? g_gps.satsInViewWindowMax : g_gps.satsInView; + return (used > inView) ? used : inView; +} + +static uint8_t displayedSatsUsed() { + if ((uint32_t)(millis() - g_gps.satsUsedWindowMs) > kFixFreshMs) { + return 0; + } + return g_gps.satsUsed; +} + +static uint8_t displayedSatsInView() { + if ((uint32_t)(millis() - g_gps.satsInViewWindowMs) > kFixFreshMs) { + return 0; + } + return g_gps.satsInView; +} + +static bool displayHasFreshUtc() { + return g_gps.hasValidUtc && (uint32_t)(millis() - g_gps.utcFixMs) <= kFixFreshMs; } static bool isUnsupportedGpsMode() { - return g_gps.module == GpsModuleKind::UBLOX; + GpsModuleKind active = activeGpsModule(); + if (kExpectedGpsModule == GpsModuleKind::UNKNOWN || active == GpsModuleKind::UNKNOWN) { + return false; + } + return active != kExpectedGpsModule; } static void reportStatusToSerial() { - uint8_t sats = bestSatelliteCount(); - logf("GPS module: %s", gpsModuleToString(g_gps.module).c_str()); + uint8_t satsUsed = displayedSatsUsed(); + uint8_t satsView = displayedSatsInView(); + logf("GPS module active: %s", gpsModuleToString(activeGpsModule()).c_str()); + logf("GPS module expected: %s", gpsModuleToString(kExpectedGpsModule).c_str()); logf("GPS sentences seen: %s", g_gps.sawAnySentence ? "yes" : "no"); - logf("GPS satellites: used=%u in-view=%u best=%u", - (unsigned)g_gps.satsUsed, - (unsigned)g_gps.satsInView, - (unsigned)sats); - logf("GPS can provide time from satellites: %s", g_gps.hasValidUtc ? "YES" : "NO"); + logf("GPS satellites: used=%u in-view=%u recent-best=%u", + (unsigned)satsUsed, + (unsigned)satsView, + (unsigned)bestSatelliteCount()); + logf("GPS can provide time from satellites: %s", displayHasFreshUtc() ? "YES" : "NO"); + appendGpsSnapshot("status"); } static void maybeAnnounceGpsTransitions() { @@ -406,22 +1017,28 @@ static void maybeAnnounceGpsTransitions() { return; } - uint8_t sats = bestSatelliteCount(); + uint8_t satsUsed = displayedSatsUsed(); + uint8_t satsView = displayedSatsInView(); + uint8_t sats = satsUsed > 0 ? satsUsed : satsView; bool hasSats = sats > 0; - bool hasUtc = g_gps.hasValidUtc; + bool hasUtc = displayHasFreshUtc(); if (!g_satellitesAcquiredAnnounced && !g_prevHadSatellites && hasSats) { String rtc = formatRtcNow(); char l2[28]; - snprintf(l2, sizeof(l2), "Satellites: %u", (unsigned)sats); - oledShowLines("GPS acquired", l2, "Satellite lock found", "Waiting for UTC...", rtc.c_str()); + char l3[28]; + snprintf(l2, sizeof(l2), "Used: %u", (unsigned)satsUsed); + snprintf(l3, sizeof(l3), "View: %u", (unsigned)satsView); + oledShowLines("GPS acquired", l2, l3, "Waiting for UTC...", rtc.c_str()); logf("Transition: satellites acquired (%u)", (unsigned)sats); + appendGpsSnapshot("satellites_acquired"); g_satellitesAcquiredAnnounced = true; } if (!g_timeAcquiredAnnounced && !g_prevHadValidUtc && hasUtc) { char line2[40]; char line3[28]; + char line4[28]; snprintf(line2, sizeof(line2), "%04u-%02u-%02u %02u:%02u:%02u", @@ -431,9 +1048,11 @@ static void maybeAnnounceGpsTransitions() { (unsigned)g_gps.utcHour, (unsigned)g_gps.utcMinute, (unsigned)g_gps.utcSecond); - snprintf(line3, sizeof(line3), "Satellites: %u", (unsigned)sats); - oledShowLines("GPS UTC acquired", line2, line3, ("Source: " + gpsModuleToString(g_gps.module)).c_str()); + snprintf(line3, sizeof(line3), "Used:%u View:%u", (unsigned)satsUsed, (unsigned)satsView); + snprintf(line4, sizeof(line4), "Source: %s", gpsModuleToString(activeGpsModule()).c_str()); + oledShowLines("GPS UTC acquired", line2, line3, line4); logf("Transition: GPS UTC acquired: %s", line2); + appendGpsSnapshot("utc_acquired"); g_timeAcquiredAnnounced = true; } @@ -443,15 +1062,22 @@ static void maybeAnnounceGpsTransitions() { static void drawMinuteStatus() { if (isUnsupportedGpsMode()) { - oledShowLines("GPS module mismatch", "UBLOX detected", "L76K required", "TODO: implement", "UBLOX support"); - logf("GPS module mismatch: UBLOX detected but this exercise currently supports only L76K (TODO)"); + oledShowLines("GPS module mismatch", + ("Expected: " + gpsModuleToString(kExpectedGpsModule)).c_str(), + ("Detected: " + gpsModuleToString(activeGpsModule())).c_str(), + "Check node profile"); + logf("GPS module mismatch: expected=%s detected=%s", + gpsModuleToString(kExpectedGpsModule).c_str(), + gpsModuleToString(activeGpsModule()).c_str()); return; } - uint8_t sats = bestSatelliteCount(); - if (g_gps.hasValidUtc) { + uint8_t satsUsed = displayedSatsUsed(); + uint8_t satsView = displayedSatsInView(); + if (displayHasFreshUtc()) { char line2[40]; char line3[28]; + char line4[28]; snprintf(line2, sizeof(line2), "%04u-%02u-%02u %02u:%02u:%02u", @@ -461,24 +1087,54 @@ static void drawMinuteStatus() { (unsigned)g_gps.utcHour, (unsigned)g_gps.utcMinute, (unsigned)g_gps.utcSecond); - snprintf(line3, sizeof(line3), "Satellites: %u", (unsigned)sats); - oledShowLines("GPS time (UTC)", line2, line3, ("Source: " + gpsModuleToString(g_gps.module)).c_str()); - logf("GPS time (UTC): %s satellites=%u", line2, (unsigned)sats); + snprintf(line3, sizeof(line3), "Used:%u View:%u", (unsigned)satsUsed, (unsigned)satsView); + snprintf(line4, sizeof(line4), "Source: %s", gpsModuleToString(activeGpsModule()).c_str()); + oledShowLines("GPS time (UTC)", line2, line3, line4); + logf("GPS time (UTC): %s used=%u view=%u", line2, (unsigned)satsUsed, (unsigned)satsView); return; } String rtc = formatRtcNow(); - if (sats > 0) { + if (satsUsed > 0 || satsView > 0) { char line2[28]; - snprintf(line2, sizeof(line2), "Satellites: %u", (unsigned)sats); - oledShowLines("GPS signal detected", line2, "GPS UTC not ready", "yet, using RTC", rtc.c_str()); - logf("Satellites detected (%u) but GPS UTC not ready. %s", (unsigned)sats, rtc.c_str()); + char line3[28]; + snprintf(line2, sizeof(line2), "Used: %u", (unsigned)satsUsed); + snprintf(line3, sizeof(line3), "View: %u", (unsigned)satsView); + oledShowLines("GPS signal detected", line2, line3, "GPS UTC not ready", rtc.c_str()); + logf("Satellites detected (used=%u view=%u) but GPS UTC not ready. %s", + (unsigned)satsUsed, + (unsigned)satsView, + rtc.c_str()); } else { oledShowLines("Unable to acquire", "satellites", "Take me outside so I", "can see satellites", rtc.c_str()); logf("Unable to acquire satellites. %s", rtc.c_str()); } } +static bool shouldRefreshDisplay() { + uint32_t now = millis(); + if (g_lastDisplayRefreshMs != 0 && (uint32_t)(now - g_lastDisplayRefreshMs) < kDisplayRefreshMinMs) { + return false; + } + uint8_t satsUsed = displayedSatsUsed(); + uint8_t satsView = displayedSatsInView(); + bool hasUtc = displayHasFreshUtc(); + if (!g_haveLastDrawnState) { + return true; + } + return satsUsed != g_lastDrawnSatsUsed || + satsView != g_lastDrawnSatsView || + hasUtc != g_lastDrawnValidUtc; +} + +static void markDisplayStateDrawn() { + g_lastDrawnSatsUsed = displayedSatsUsed(); + g_lastDrawnSatsView = displayedSatsInView(); + g_lastDrawnValidUtc = displayHasFreshUtc(); + g_haveLastDrawnState = true; + g_lastDisplayRefreshMs = millis(); +} + void setup() { Serial.begin(115200); delay(kSerialDelayMs); @@ -486,15 +1142,33 @@ void setup() { Serial.println("\r\n=================================================="); Serial.println("Exercise 09: GPS Time"); Serial.println("=================================================="); + Serial.printf("Build: %s %s\r\n", kBuildDate, kBuildTime); if (!tbeam_supreme::initPmuForPeripherals(g_pmu, &Serial)) { logf("PMU init failed"); } + g_spiffsReady = SPIFFS.begin(true); + if (!g_spiffsReady) { + logf("SPIFFS mount failed"); + } else if (!ensureGpsLogDirectory()) { + logf("GPS log directory create/open failed"); + } else { + File file = SPIFFS.open(kGpsLogPath, FILE_WRITE); + if (file) { + file.println("Exercise 09 GPS diagnostics"); + file.printf("Build: %s %s\r\n", kBuildDate, kBuildTime); + file.close(); + } else { + logf("GPS log file open failed: %s", kGpsLogPath); + } + } + Wire.begin(OLED_SDA, OLED_SCL); g_oled.setI2CAddress(OLED_ADDR << 1); g_oled.begin(); - oledShowLines("GPS Time exercise", "Booting..."); + String buildStamp = buildStampShort(); + oledShowLines("09_GPS_Time", buildStamp.c_str(), "Booting..."); SdWatcherConfig sdCfg{}; if (!g_sd.begin(sdCfg, nullptr)) { @@ -510,47 +1184,74 @@ void setup() { pinMode(GPS_1PPS_PIN, INPUT); #endif - startGpsUart(GPS_BAUD); - logf("GPS UART started: RX=%d TX=%d baud=%lu", GPS_RX_PIN, GPS_TX_PIN, (unsigned long)g_gpsBaud); + startGpsUart(GPS_BAUD, GPS_RX_PIN, GPS_TX_PIN); + logf("GPS UART started: RX=%d TX=%d baud=%lu", g_gpsRxPin, g_gpsTxPin, (unsigned long)g_gpsBaud); + appendGpsSnapshot("uart_started"); initialGpsProbe(); + appendGpsSnapshot("startup_probe_complete"); + maybeConfigureUblox(); oledShowLines("GPS startup probe", "Checking satellites", "and GPS time..."); + uint32_t probeWindowMs = startupProbeWindowMs(); + if (kExpectedGpsModule == GpsModuleKind::UBLOX) { + logf("UBLOX startup window: %lu ms (allowing cold start acquisition)", + (unsigned long)probeWindowMs); + } + uint32_t probeStart = millis(); uint32_t lastProbeUiMs = 0; - while ((uint32_t)(millis() - probeStart) < kGpsStartupProbeMs) { + while ((uint32_t)(millis() - probeStart) < probeWindowMs) { + pollSerialConsole(); pollGpsSerial(); g_sd.update(); uint32_t now = millis(); + if ((uint32_t)(now - g_lastGpsDiagnosticLogMs) >= kGpsDiagnosticLogMs) { + g_lastGpsDiagnosticLogMs = now; + appendGpsSnapshot("startup_wait"); + } if ((uint32_t)(now - lastProbeUiMs) >= 1000) { lastProbeUiMs = now; char l3[28]; char l4[30]; char l5[24]; - snprintf(l3, sizeof(l3), "Sats: %u", (unsigned)bestSatelliteCount()); - snprintf(l4, sizeof(l4), "Module: %s", gpsModuleToString(g_gps.module).c_str()); - snprintf(l5, sizeof(l5), "NMEA:%s %lu", g_gps.sawAnySentence ? "yes" : "no", (unsigned long)g_gpsBaud); + snprintf(l3, sizeof(l3), "Used:%u View:%u", (unsigned)displayedSatsUsed(), (unsigned)displayedSatsInView()); + snprintf(l4, sizeof(l4), "Module: %s", gpsModuleToString(activeGpsModule()).c_str()); + snprintf(l5, sizeof(l5), "NMEA:%s %d/%d", g_gps.sawAnySentence ? "yes" : "no", g_gpsRxPin, g_gpsTxPin); oledShowLines("GPS startup probe", "Checking satellites", l3, l4, l5); } delay(10); } reportStatusToSerial(); - g_prevHadSatellites = (bestSatelliteCount() > 0); - g_prevHadValidUtc = g_gps.hasValidUtc; + g_prevHadSatellites = (displayedSatsUsed() > 0 || displayedSatsInView() > 0); + g_prevHadValidUtc = displayHasFreshUtc(); drawMinuteStatus(); + markDisplayStateDrawn(); g_lastMinuteReportMs = millis(); + g_lastGpsDiagnosticLogMs = millis(); } void loop() { + pollSerialConsole(); pollGpsSerial(); g_sd.update(); maybeAnnounceGpsTransitions(); uint32_t now = millis(); + if (shouldRefreshDisplay()) { + drawMinuteStatus(); + markDisplayStateDrawn(); + } + if ((uint32_t)(now - g_lastGpsDiagnosticLogMs) >= kGpsDiagnosticLogMs) { + g_lastGpsDiagnosticLogMs = now; + appendGpsSnapshot("periodic"); + } if ((uint32_t)(now - g_lastMinuteReportMs) >= kMinuteMs) { g_lastMinuteReportMs = now; drawMinuteStatus(); + markDisplayStateDrawn(); + appendGpsSnapshot("minute_status"); } } diff --git a/exercises/10_Simple_GPS/platformio.ini b/exercises/10_Simple_GPS/platformio.ini index 02b2f71..3422f89 100644 --- a/exercises/10_Simple_GPS/platformio.ini +++ b/exercises/10_Simple_GPS/platformio.ini @@ -39,3 +39,9 @@ build_flags = build_flags = ${env.build_flags} -D NODE_LABEL=\"B\" + +[env:node_c] +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"C\" + -D GPS_UBLOX \ No newline at end of file diff --git a/exercises/15_RAM/src/main.cpp b/exercises/15_RAM/src/main.cpp index 17eb519..7d535c1 100644 --- a/exercises/15_RAM/src/main.cpp +++ b/exercises/15_RAM/src/main.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #ifndef NODE_LABEL #define NODE_LABEL "RAM" @@ -21,10 +22,11 @@ static U8G2_SH1106_128X64_NONAME_F_HW_I2C g_oled(U8G2_R0, U8X8_PIN_NONE); -static const char *kTmpPath = "/tmp/volatile.txt"; -static const size_t kTmpFileCapacity = 4096; +static const char *kTmpPath = "/tmp/AMY_output.log"; +static const size_t kTmpFileCapacity = 32768; static char g_tmpFileBuffer[kTmpFileCapacity]; static size_t g_tmpFileSize = 0; +static unsigned g_tmpLineNumber = 0; static void oledShowLines(const char *l1, const char *l2 = nullptr, @@ -47,6 +49,51 @@ static size_t getAvailableRamBytes() return ESP.getFreeHeap(); } +static void getTimestamp(char *out, size_t outSize) +{ + const time_t now = time(nullptr); + if (now > 1700000000) { + struct tm tmNow; + localtime_r(&now, &tmNow); + snprintf(out, outSize, "%04d-%02d-%02d %02d:%02d:%02d", + tmNow.tm_year + 1900, + tmNow.tm_mon + 1, + tmNow.tm_mday, + tmNow.tm_hour, + tmNow.tm_min, + tmNow.tm_sec); + return; + } + + const uint32_t sec = millis() / 1000; + const uint32_t hh = sec / 3600; + const uint32_t mm = (sec % 3600) / 60; + const uint32_t ss = sec % 60; + snprintf(out, outSize, "uptime %02u:%02u:%02u", (unsigned)hh, (unsigned)mm, (unsigned)ss); +} + +static void appendTimestampLine() +{ + char timestamp[32]; + getTimestamp(timestamp, sizeof(timestamp)); + + char line[96]; + const int written = snprintf(line, sizeof(line), "%u, %s\r\n", g_tmpLineNumber + 1, timestamp); + if (written <= 0) { + return; + } + + const size_t lineLen = (size_t)written; + if (g_tmpFileSize + lineLen > kTmpFileCapacity - 1) { + Serial.println("Warning: /tmp log full, stopping writes"); + return; + } + + memcpy(g_tmpFileBuffer + g_tmpFileSize, line, lineLen); + g_tmpFileSize += lineLen; + g_tmpLineNumber++; +} + static void printRamStatus() { const size_t freeBytes = getAvailableRamBytes(); @@ -65,7 +112,7 @@ static void printRamStatus() char line4[32]; snprintf(line4, sizeof(line4), "Total: %u KB", (unsigned)(totalBytes / 1024U)); char line5[32]; - snprintf(line5, sizeof(line5), "Max alloc: %u KB", (unsigned)(maxAlloc / 1024U)); + snprintf(line5, sizeof(line5), "Lines: %u", (unsigned)g_tmpLineNumber); oledShowLines(line1, line2, line3, line4, line5); } @@ -85,6 +132,7 @@ static void printTmpFileStat() { Serial.printf("Path: %s\r\n", kTmpPath); Serial.printf("Size: %u bytes\r\n", (unsigned)g_tmpFileSize); + Serial.printf("Lines: %u\r\n", (unsigned)g_tmpLineNumber); Serial.printf("Capacity: %u bytes\r\n", (unsigned)kTmpFileCapacity); } @@ -219,5 +267,6 @@ void loop() } lastMs = now; + appendTimestampLine(); printRamStatus(); } diff --git a/exercises/16_PSRAM/README.md b/exercises/16_PSRAM/README.md new file mode 100644 index 0000000..06d8f39 --- /dev/null +++ b/exercises/16_PSRAM/README.md @@ -0,0 +1,32 @@ +# Exercise 16: PSRAM + +This exercise demonstrates usage of PSRAM (Pseudo SRAM) on an ESP32-S3 board, alongside regular RAM metrics. + +Behavior: +- Reports heap and PSRAM statistics every second over serial. +- Shows live heap and PSRAM status on the OLED display (both on same line). +- Allows you to write/append/read/clear data in a PSRAM-backed buffer (up to ~2MB). +- Designed as an extension of Exercise 15_RAM to explore larger volatile storage. + +Note: the exercise now targets a PSRAM-enabled ESP32-S3 board definition (`freenove_esp32_s3_wroom`). This board profile has 8MB flash + 8MB PSRAM, matching the T-Beam Supreme specifications. If your hardware differs, adjust accordingly. + +Sources: +- LilyGo T-Beam SUPREME datasheet/wiki: https://wiki.lilygo.cc/get_started/en/LoRa_GPS/T-Beam-SUPREME/T-Beam-SUPREME.html +- PlatformIO board definition: https://docs.platformio.org/page/boards/espressif32/freenove_esp32_s3_wroom.html +- Local PlatformIO board metadata: ~/.platformio/platforms/espressif32/boards/freenove_esp32_s3_wroom.json + +Build and upload: + +```bash +cd /usr/local/src/microreticulum/microReticulumTbeam/exercises/16_PSRAM +pio run -e amy -t upload +pio device monitor -b 115200 +``` + +Commands: +- `help` - show command menu +- `stat` - show PSRAM buffer state +- `read` - read PSRAM buffer contents +- `clear` - clear PSRAM buffer +- `write ` - write text to PSRAM buffer +- `append ` - append text to PSRAM buffer diff --git a/exercises/16_PSRAM/platformio.ini b/exercises/16_PSRAM/platformio.ini new file mode 100644 index 0000000..785b26c --- /dev/null +++ b/exercises/16_PSRAM/platformio.ini @@ -0,0 +1,54 @@ +; 20260403 ChatGPT +; Exercise 16_PSRAM + +[platformio] +default_envs = amy + +[env] +platform = espressif32 +framework = arduino +board = freenove_esp32_s3_wroom +monitor_speed = 115200 +extra_scripts = pre:scripts/set_build_epoch.py +lib_deps = + Wire + olikraus/U8g2@^2.36.4 + +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 ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + +board_build.flash_mode = qio +board_build.psram = 1 +board_build.psram_type = spi +board_build.arduino.memory_type = qio_qspi + +[env:amy] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"AMY\" + +[env:bob] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"BOB\" + +[env:cy] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"CY\" + +[env:dan] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"DAN\" diff --git a/exercises/16_PSRAM/scripts/set_build_epoch.py b/exercises/16_PSRAM/scripts/set_build_epoch.py new file mode 100644 index 0000000..3011129 --- /dev/null +++ b/exercises/16_PSRAM/scripts/set_build_epoch.py @@ -0,0 +1,12 @@ +import time +Import("env") + +epoch = int(time.time()) +utc_tag = time.strftime("%Y%m%d_%H%M%S_z", time.gmtime(epoch)) + +env.Append( + CPPDEFINES=[ + ("FW_BUILD_EPOCH", str(epoch)), + ("FW_BUILD_UTC", '\\"%s\\"' % utc_tag), + ] +) diff --git a/exercises/16_PSRAM/src/main.cpp b/exercises/16_PSRAM/src/main.cpp new file mode 100644 index 0000000..ce3afa2 --- /dev/null +++ b/exercises/16_PSRAM/src/main.cpp @@ -0,0 +1,342 @@ +// 20260403 ChatGPT +// Exercise 16_PSRAM - Extended Exercise 15_RAM with PSRAM support + +#include +#include +#include +#include +#include + +#ifndef NODE_LABEL +#define NODE_LABEL "PSRAM" +#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 + +static U8G2_SH1106_128X64_NONAME_F_HW_I2C g_oled(U8G2_R0, U8X8_PIN_NONE); + +static const size_t kTmpFileCapacity = 2097152; // 2MB in PSRAM +static char *g_tmpFileBuffer = nullptr; +static size_t g_tmpFileSize = 0; +static unsigned g_tmpLineNumber = 0; + +static void oledShowLines(const char *l1, + const char *l2 = nullptr, + const char *l3 = nullptr, + const char *l4 = nullptr, + const char *l5 = nullptr) +{ + g_oled.clearBuffer(); + g_oled.setFont(u8g2_font_5x8_tf); + if (l1) g_oled.drawUTF8(0, 12, l1); + if (l2) g_oled.drawUTF8(0, 24, l2); + if (l3) g_oled.drawUTF8(0, 36, l3); + if (l4) g_oled.drawUTF8(0, 48, l4); + if (l5) g_oled.drawUTF8(0, 60, l5); + g_oled.sendBuffer(); +} + +static size_t getAvailableRamBytes() +{ + return ESP.getFreeHeap(); +} + +static size_t getTotalRamBytes() +{ + return ESP.getHeapSize(); +} + +static size_t getPSRAMFreeBytes() +{ + size_t freeBytes = heap_caps_get_free_size(MALLOC_CAP_SPIRAM); + if (freeBytes == 0 && ESP.getFreePsram() > 0) { + freeBytes = ESP.getFreePsram(); + } + return freeBytes; +} + +static size_t getPSRAMTotalBytes() +{ + size_t totalBytes = heap_caps_get_total_size(MALLOC_CAP_SPIRAM); + if (totalBytes == 0 && ESP.getPsramSize() > 0) { + totalBytes = ESP.getPsramSize(); + } + return totalBytes; +} + +static void getTimestamp(char *out, size_t outSize) +{ + const time_t now = time(nullptr); + if (now > 1700000000) { + struct tm tmNow; + localtime_r(&now, &tmNow); + snprintf(out, outSize, "%04d-%02d-%02d %02d:%02d:%02d", + tmNow.tm_year + 1900, + tmNow.tm_mon + 1, + tmNow.tm_mday, + tmNow.tm_hour, + tmNow.tm_min, + tmNow.tm_sec); + return; + } + + const uint32_t sec = millis() / 1000; + const uint32_t hh = sec / 3600; + const uint32_t mm = (sec % 3600) / 60; + const uint32_t ss = sec % 60; + snprintf(out, outSize, "uptime %02u:%02u:%02u", (unsigned)hh, (unsigned)mm, (unsigned)ss); +} + +static void appendTimestampLine() +{ + if (!g_tmpFileBuffer) return; + + char timestamp[32]; + getTimestamp(timestamp, sizeof(timestamp)); + + char line[96]; + const int written = snprintf(line, sizeof(line), "%u, %s\r\n", g_tmpLineNumber + 1, timestamp); + if (written <= 0) { + return; + } + + const size_t lineLen = (size_t)written; + if (g_tmpFileSize + lineLen > kTmpFileCapacity - 1) { + Serial.println("Warning: PSRAM log full, stopping writes"); + return; + } + + memcpy(g_tmpFileBuffer + g_tmpFileSize, line, lineLen); + g_tmpFileSize += lineLen; + g_tmpLineNumber++; +} + +static void printRamStatus() +{ + const size_t freeRam = getAvailableRamBytes(); + const size_t totalRam = getTotalRamBytes(); + const size_t maxAllocRam = ESP.getMaxAllocHeap(); + + const size_t freePSRAM = getPSRAMFreeBytes(); + const size_t totalPSRAM = getPSRAMTotalBytes(); + + Serial.printf("RAM total=%u free=%u maxAlloc=%u | PSRAM total=%u free=%u\r\n", + (unsigned)totalRam, (unsigned)freeRam, (unsigned)maxAllocRam, + (unsigned)totalPSRAM, (unsigned)freePSRAM); + + char line1[32]; + char line2[32]; + char line3[32]; + char line4[32]; + char line5[32]; + + snprintf(line1, sizeof(line1), "Exercise 16 PSRAM"); + snprintf(line2, sizeof(line2), "Node: %s", NODE_LABEL); + + // Display format: "Free XXXKb/8.0Mbs" + const float psramMb = totalPSRAM / (1024.0f * 1024.0f); + const size_t ramKb = freeRam / 1024U; + snprintf(line3, sizeof(line3), "Free %uKb/%.1fMbs", (unsigned)ramKb, psramMb); + + snprintf(line4, sizeof(line4), "PSRAM: %u KB", (unsigned)(freePSRAM / 1024U)); + snprintf(line5, sizeof(line5), "Lines: %u", (unsigned)g_tmpLineNumber); + + oledShowLines(line1, line2, line3, line4, line5); +} + +static void showHelp() +{ + Serial.println("PSRAM command list:"); + Serial.println(" help - show this menu"); + Serial.println(" stat - show PSRAM buffer state"); + Serial.println(" read - read PSRAM buffer contents"); + Serial.println(" clear - clear PSRAM buffer contents"); + Serial.println(" write - write text to PSRAM buffer"); + Serial.println(" append - append text to PSRAM buffer"); +} + +static void printPSRAMFileStat() +{ + Serial.printf("PSRAM Buffer Capacity: %u bytes\r\n", (unsigned)kTmpFileCapacity); + Serial.printf("Current Size: %u bytes\r\n", (unsigned)g_tmpFileSize); + Serial.printf("Lines: %u\r\n", (unsigned)g_tmpLineNumber); + Serial.printf("PSRAM Total: %u bytes (%.2f MB)\r\n", (unsigned)getPSRAMTotalBytes(), + getPSRAMTotalBytes() / (1024.0f * 1024.0f)); + Serial.printf("PSRAM Free: %u bytes\r\n", (unsigned)getPSRAMFreeBytes()); +} + +static void printPSRAMFileContents() +{ + if (!g_tmpFileBuffer) { + Serial.println("PSRAM buffer not allocated"); + return; + } + + if (g_tmpFileSize == 0) { + Serial.println("PSRAM buffer is empty"); + return; + } + + Serial.print("PSRAM contents: "); + Serial.write((const uint8_t *)g_tmpFileBuffer, g_tmpFileSize); + if (g_tmpFileBuffer[g_tmpFileSize - 1] != '\n') + Serial.println(); +} + +static void setPSRAMFileContent(const char *text) +{ + if (!g_tmpFileBuffer) { + Serial.println("Error: PSRAM buffer not allocated"); + return; + } + + if (!text) { + g_tmpFileSize = 0; + g_tmpLineNumber = 0; + return; + } + const size_t newLen = strlen(text); + if (newLen > kTmpFileCapacity - 1) { + Serial.printf("Error: content too large (%u/%u)\r\n", (unsigned)newLen, (unsigned)kTmpFileCapacity); + return; + } + memcpy(g_tmpFileBuffer, text, newLen); + g_tmpFileSize = newLen; + g_tmpLineNumber = 0; +} + +static void appendPSRAMFileContent(const char *text) +{ + if (!g_tmpFileBuffer) { + Serial.println("Error: PSRAM buffer not allocated"); + return; + } + + if (!text || text[0] == '\0') return; + const size_t textLen = strlen(text); + if (g_tmpFileSize + textLen > kTmpFileCapacity - 1) { + Serial.printf("Error: append would exceed %u bytes\r\n", (unsigned)kTmpFileCapacity); + return; + } + memcpy(g_tmpFileBuffer + g_tmpFileSize, text, textLen); + g_tmpFileSize += textLen; +} + +static void processSerialCommand(const char *line) +{ + if (!line || line[0] == '\0') return; + + char tmp[384]; + strncpy(tmp, line, sizeof(tmp) - 1); + tmp[sizeof(tmp) - 1] = '\0'; + + char *cmd = strtok(tmp, " \t\r\n"); + if (!cmd) return; + + if (strcasecmp(cmd, "help") == 0) { + showHelp(); + return; + } + + if (strcasecmp(cmd, "stat") == 0) { + printPSRAMFileStat(); + return; + } + + if (strcasecmp(cmd, "read") == 0) { + printPSRAMFileContents(); + return; + } + + if (strcasecmp(cmd, "clear") == 0) { + g_tmpFileSize = 0; + g_tmpLineNumber = 0; + Serial.println("PSRAM buffer cleared"); + return; + } + + if (strcasecmp(cmd, "write") == 0 || strcasecmp(cmd, "append") == 0) { + const char *payload = line + strlen(cmd); + while (*payload == ' ' || *payload == '\t') payload++; + if (strcasecmp(cmd, "write") == 0) + setPSRAMFileContent(payload); + else + appendPSRAMFileContent(payload); + + Serial.printf("%s: %u bytes\r\n", cmd, + (unsigned)g_tmpFileSize); + return; + } + + Serial.println("Unknown command (help for list)"); +} + +void setup() +{ + Serial.begin(115200); + delay(800); + Serial.println("Exercise 16_PSRAM boot"); + + // Boot-time PSRAM diagnostics + Serial.printf("Boot PSRAM size: %u bytes\r\n", (unsigned)ESP.getPsramSize()); + Serial.printf("Boot PSRAM free: %u bytes\r\n", (unsigned)ESP.getFreePsram()); + + // Allocate PSRAM buffer + g_tmpFileBuffer = (char *)heap_caps_malloc(kTmpFileCapacity, MALLOC_CAP_SPIRAM); + if (!g_tmpFileBuffer) { + Serial.println("ERROR: Failed to allocate PSRAM buffer!"); + oledShowLines("Exercise 16_PSRAM", "Node: " NODE_LABEL, "PSRAM alloc FAILED"); + } else { + Serial.printf("PSRAM buffer allocated: %u bytes\r\n", (unsigned)kTmpFileCapacity); + } + + Wire.begin(OLED_SDA, OLED_SCL); + g_oled.setI2CAddress(OLED_ADDR << 1); + g_oled.begin(); + oledShowLines("Exercise 16_PSRAM", "Node: " NODE_LABEL, "Booting..."); + + delay(1000); +} + +void loop() +{ + static uint32_t lastMs = 0; + const uint32_t now = millis(); + + // check serial commands at all times + static char rxLine[384]; + static size_t rxLen = 0; + + while (Serial.available()) { + int c = Serial.read(); + if (c <= 0) continue; + if (c == '\r' || c == '\n') { + if (rxLen > 0) { + rxLine[rxLen] = '\0'; + processSerialCommand(rxLine); + rxLen = 0; + } + } else if (rxLen + 1 < sizeof(rxLine)) { + rxLine[rxLen++] = (char)c; + } + } + + if (now - lastMs < 1000) { + delay(10); + return; + } + lastMs = now; + + if (g_tmpFileBuffer) { + appendTimestampLine(); + } + printRamStatus(); +} diff --git a/exercises/17_Flash/README.md b/exercises/17_Flash/README.md new file mode 100644 index 0000000..cf123c6 --- /dev/null +++ b/exercises/17_Flash/README.md @@ -0,0 +1,32 @@ +# Exercise 17_Flash + +This exercise demonstrates using Flash storage as a persistent directory-like file system on an ESP32-S3 board. + +Behavior: +- Mounts SPIFFS at boot and reports total / used / free flash space. +- Ensures a flash directory at `/flash_logs` exists. +- Creates a new log file when the device boots, based on the current timestamp: `YYYYMMDD_HHMM.log`. +- Writes a timestamped line into the new log file once per second. +- Supports console commands to inspect the current file, read it, clear it, append or rewrite it, and list stored files. +- Files persist across reboots and are stored in flash. + +Build and upload: + +```bash +cd /usr/local/src/microreticulum/microReticulumTbeam/exercises/17_Flash +pio run -e amy -t upload +pio device monitor -b 115200 +``` + +Commands: +- `help` - show command menu +- `stat` - show flash / current file status +- `list` - list files under `/flash_logs` +- `read` - read the current flash file contents +- `clear` - clear the current flash file contents +- `write ` - overwrite the current flash file with text +- `append ` - append text to the current flash file + +Notes: +- If the current timestamp file name already exists, the exercise will append a numeric suffix to keep the file unique. +- On each reboot a new file is created so persistent flash logs accumulate. diff --git a/exercises/17_Flash/platformio.ini b/exercises/17_Flash/platformio.ini new file mode 100644 index 0000000..605b6c9 --- /dev/null +++ b/exercises/17_Flash/platformio.ini @@ -0,0 +1,50 @@ +; 20260403 ChatGPT +; Exercise 17_Flash + +[platformio] +default_envs = amy + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +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 ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + +[env:amy] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"AMY\" + +[env:bob] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"BOB\" + +[env:cy] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"CY\" + +[env:dan] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"DAN\" diff --git a/exercises/17_Flash/read_partition_bin.py b/exercises/17_Flash/read_partition_bin.py new file mode 100644 index 0000000..3dc21d7 --- /dev/null +++ b/exercises/17_Flash/read_partition_bin.py @@ -0,0 +1,11 @@ +import struct +with open('AMY_test_partitions_read.bin', 'rb') as f: + data = f.read() + seq0 = struct.unpack(' seq1: + print("→ app0 is active, new uploads go to app1") + else: + print("→ app1 is active, new uploads go to app0") diff --git a/exercises/17_Flash/scripts/set_build_epoch.py b/exercises/17_Flash/scripts/set_build_epoch.py new file mode 100644 index 0000000..44b46a0 --- /dev/null +++ b/exercises/17_Flash/scripts/set_build_epoch.py @@ -0,0 +1,12 @@ +import time +Import("env") + +epoch = int(time.time()) +utc_tag = time.strftime("%Y%m%d_%H%M%S_z", time.gmtime(epoch)) + +env.Append( + CPPDEFINES=[ + ("FW_BUILD_EPOCH", str(epoch)), + ("FW_BUILD_UTC", '\"%s\"' % utc_tag), + ] +) diff --git a/exercises/17_Flash/show_partition_table.py b/exercises/17_Flash/show_partition_table.py new file mode 100644 index 0000000..3bf3a7e --- /dev/null +++ b/exercises/17_Flash/show_partition_table.py @@ -0,0 +1,26 @@ +import struct + +with open('partitions_backup.bin', 'rb') as f: + data = f.read() + +print("Name | Type | SubType | Offset | Size | Flags") +print("-" * 75) + +for i in range(0, len(data), 32): + entry = data[i:i+32] + if len(entry) < 32: + break + + magic = struct.unpack(' +#include +#include +#include +#include + +#include "tbeam_supreme_adapter.h" + +#ifndef NODE_LABEL +#define NODE_LABEL "FLASH" +#endif + +#ifndef RTC_I2C_ADDR +#define RTC_I2C_ADDR 0x51 +#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 + +static U8G2_SH1106_128X64_NONAME_F_HW_I2C g_oled(U8G2_R0, U8X8_PIN_NONE); +static const char *kFlashDir = "/flash_logs"; +static char g_currentFilePath[64] = {0}; +static File g_flashFile; +static unsigned g_flashLineNumber = 0; +static XPowersLibInterface* g_pmu = nullptr; +static bool g_hasRtc = false; +static bool g_rtcLowVoltage = false; + +struct RtcDateTime { + uint16_t year; + uint8_t month; + uint8_t day; + uint8_t hour; + uint8_t minute; + uint8_t second; + uint8_t weekday; +}; + +static void oledShowLines(const char *l1, + const char *l2 = nullptr, + const char *l3 = nullptr, + const char *l4 = nullptr, + const char *l5 = nullptr) +{ + g_oled.clearBuffer(); + g_oled.setFont(u8g2_font_5x8_tf); + if (l1) g_oled.drawUTF8(0, 12, l1); + if (l2) g_oled.drawUTF8(0, 24, l2); + if (l3) g_oled.drawUTF8(0, 36, l3); + if (l4) g_oled.drawUTF8(0, 48, l4); + if (l5) g_oled.drawUTF8(0, 60, l5); + g_oled.sendBuffer(); +} + +static size_t getFlashTotalBytes() +{ + return SPIFFS.totalBytes(); +} + +static size_t getFlashUsedBytes() +{ + return SPIFFS.usedBytes(); +} + +static size_t getFlashFreeBytes() +{ + const size_t total = getFlashTotalBytes(); + const size_t used = getFlashUsedBytes(); + return total > used ? total - used : 0; +} + +static uint8_t toBcd(uint8_t v) { + return ((v / 10U) << 4U) | (v % 10U); +} + +static uint8_t fromBcd(uint8_t b) { + return ((b >> 4U) * 10U) + (b & 0x0FU); +} + +static bool isRtcDateTimeValid(const RtcDateTime& dt) { + if (dt.year < 2020 || dt.year > 2099) return false; + if (dt.month < 1 || dt.month > 12) return false; + if (dt.day < 1 || dt.day > 31) return false; + if (dt.hour > 23 || dt.minute > 59 || dt.second > 59) return false; + return true; +} + +static 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(); + uint8_t weekday = 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.weekday = fromBcd(weekday & 0x07U); + out.month = fromBcd(month & 0x1FU); + uint8_t yy = fromBcd(year); + bool century = (month & 0x80U) != 0; + out.year = century ? (1900U + yy) : (2000U + yy); + + return true; +} + +static bool initRtc() { + if (!tbeam_supreme::initPmuForPeripherals(g_pmu, &Serial)) { + Serial.println("RTC init: PMU/i2c init failed"); + return false; + } + RtcDateTime now{}; + if (!rtcRead(now, g_rtcLowVoltage) || !isRtcDateTimeValid(now)) { + Serial.println("RTC init: no valid time available"); + return false; + } + g_hasRtc = true; + Serial.printf("RTC init: %04u-%02u-%02u %02u:%02u:%02u%s\r\n", + (unsigned)now.year, (unsigned)now.month, (unsigned)now.day, + (unsigned)now.hour, (unsigned)now.minute, (unsigned)now.second, + g_rtcLowVoltage ? " [LOW_BATT]" : ""); + return true; +} + +static bool getRtcTimestamp(char *out, size_t outSize) { + if (!g_hasRtc) { + return false; + } + RtcDateTime now{}; + bool low = false; + if (!rtcRead(now, low) || !isRtcDateTimeValid(now)) { + return false; + } + g_rtcLowVoltage = low; + snprintf(out, outSize, "%04u-%02u-%02u %02u:%02u:%02u", + now.year, + now.month, + now.day, + now.hour, + now.minute, + now.second); + return true; +} + +static void getTimestamp(char *out, size_t outSize) +{ + if (getRtcTimestamp(out, outSize)) { + return; + } + + const time_t now = time(nullptr); + if (now > 1700000000) { + struct tm tmNow; + localtime_r(&now, &tmNow); + snprintf(out, outSize, "%04d-%02d-%02d %02d:%02d:%02d", + tmNow.tm_year + 1900, + tmNow.tm_mon + 1, + tmNow.tm_mday, + tmNow.tm_hour, + tmNow.tm_min, + tmNow.tm_sec); + return; + } + + const uint32_t sec = millis() / 1000; + const uint32_t hh = sec / 3600; + const uint32_t mm = (sec % 3600) / 60; + const uint32_t ss = sec % 60; + snprintf(out, outSize, "uptime %02u:%02u:%02u", (unsigned)hh, (unsigned)mm, (unsigned)ss); +} + +static void getFilenameTimestamp(char *out, size_t outSize) +{ + if (g_hasRtc) { + RtcDateTime now{}; + bool low = false; + if (rtcRead(now, low) && isRtcDateTimeValid(now)) { + snprintf(out, outSize, "%04u%02u%02u_%02u%02u", + now.year, + now.month, + now.day, + now.hour, + now.minute); + return; + } + } + + const time_t now = time(nullptr); + if (now > 1700000000) { + struct tm tmNow; + localtime_r(&now, &tmNow); + snprintf(out, outSize, "%04d%02d%02d_%02d%02d", + tmNow.tm_year + 1900, + tmNow.tm_mon + 1, + tmNow.tm_mday, + tmNow.tm_hour, + tmNow.tm_min); + return; + } + + const uint32_t sec = millis() / 1000; + const uint32_t hh = sec / 3600; + const uint32_t mm = (sec % 3600) / 60; + const uint32_t ss = sec % 60; + snprintf(out, outSize, "uptime_%02u%02u%02u", (unsigned)hh, (unsigned)mm, (unsigned)ss); +} + +static String getNewFlashFilePath() +{ + char baseName[64]; + getFilenameTimestamp(baseName, sizeof(baseName)); + + char candidate[96]; + snprintf(candidate, sizeof(candidate), "%s/%s.log", kFlashDir, baseName); + if (!SPIFFS.exists(candidate)) { + return String(candidate); + } + + int suffix = 1; + do { + snprintf(candidate, sizeof(candidate), "%s/%s-%d.log", kFlashDir, baseName, suffix); + suffix += 1; + } while (SPIFFS.exists(candidate)); + + return String(candidate); +} + +static bool ensureFlashDirectory() +{ + if (SPIFFS.exists(kFlashDir)) { + return true; + } + if (!SPIFFS.mkdir(kFlashDir)) { + Serial.printf("Warning: failed to create %s\r\n", kFlashDir); + return false; + } + return true; +} + +static bool openCurrentFlashFile(bool truncate = false) +{ + if (g_flashFile) { + g_flashFile.close(); + } + + if (truncate) { + g_flashFile = SPIFFS.open(g_currentFilePath, FILE_WRITE); + } else { + g_flashFile = SPIFFS.open(g_currentFilePath, FILE_APPEND); + } + + if (!g_flashFile) { + Serial.printf("ERROR: cannot open %s\r\n", g_currentFilePath); + return false; + } + return true; +} + +static bool createFlashLogFile() +{ + if (!ensureFlashDirectory()) { + return false; + } + + String path = getNewFlashFilePath(); + path.toCharArray(g_currentFilePath, sizeof(g_currentFilePath)); + + g_flashFile = SPIFFS.open(g_currentFilePath, FILE_WRITE); + if (!g_flashFile) { + Serial.printf("ERROR: could not create %s\r\n", g_currentFilePath); + return false; + } + + const char *header = "FLASH log file created\r\n"; + g_flashFile.print(header); + g_flashFile.flush(); + g_flashLineNumber = 0; + return true; +} + +static void appendFlashTimestampLine() +{ + if (!g_flashFile) { + return; + } + + char timestamp[32]; + getTimestamp(timestamp, sizeof(timestamp)); + + char line[96]; + const int written = snprintf(line, sizeof(line), "%u, %s\r\n", g_flashLineNumber + 1, timestamp); + if (written <= 0) { + return; + } + + const size_t lineLen = (size_t)written; + if (g_flashFile.write(reinterpret_cast(line), lineLen) != lineLen) { + Serial.println("Warning: flash write failed"); + return; + } + g_flashFile.flush(); + g_flashLineNumber += 1; +} + +static void printFlashStatus() +{ + const size_t total = getFlashTotalBytes(); + const size_t used = getFlashUsedBytes(); + const size_t freeBytes = getFlashFreeBytes(); + + Serial.printf("FLASH total=%u used=%u free=%u\r\n", + (unsigned)total, (unsigned)used, (unsigned)freeBytes); + + char line1[32]; + char line2[32]; + char line3[32]; + char line4[32]; + char line5[32]; + + snprintf(line1, sizeof(line1), "Exercise 17 Flash"); + snprintf(line2, sizeof(line2), "Node: %s", NODE_LABEL); + snprintf(line3, sizeof(line3), "Free: %u KB", (unsigned)(freeBytes / 1024U)); + snprintf(line4, sizeof(line4), "Used: %u KB", (unsigned)(used / 1024U)); + snprintf(line5, sizeof(line5), "Lines: %u", (unsigned)g_flashLineNumber); + + oledShowLines(line1, line2, line3, line4, line5); +} + +static void showHelp() +{ + Serial.println("Flash command list:"); + Serial.println(" help - show this menu"); + Serial.println(" stat - show flash/file state"); + Serial.println(" rtc - show RTC time status"); + Serial.println(" list - list files in /flash_logs"); + Serial.println(" read - read current flash file"); + Serial.println(" clear - clear current flash file"); + Serial.println(" write - overwrite current flash file"); + Serial.println(" append - append text to current flash file"); +} + +static void printFlashFileStat() +{ + Serial.printf("Current file: %s\r\n", g_currentFilePath); + if (!SPIFFS.exists(g_currentFilePath)) { + Serial.println("Current file missing"); + return; + } + + File file = SPIFFS.open(g_currentFilePath, FILE_READ); + if (!file) { + Serial.println("Unable to open current file for stats"); + return; + } + + Serial.printf("Size: %u bytes\r\n", (unsigned)file.size()); + Serial.printf("Lines written: %u\r\n", (unsigned)g_flashLineNumber); + file.close(); +} + +static void printFlashFileContents() +{ + if (!SPIFFS.exists(g_currentFilePath)) { + Serial.println("Current flash file does not exist"); + return; + } + + File file = SPIFFS.open(g_currentFilePath, FILE_READ); + if (!file) { + Serial.println("Unable to open current flash file"); + return; + } + + if (file.size() == 0) { + Serial.println("Current flash file is empty"); + file.close(); + return; + } + + Serial.print("Flash file contents: "); + while (file.available()) { + Serial.write(file.read()); + } + if (file.size() > 0) { + Serial.println(); + } + file.close(); +} + +static void clearFlashFileContents() +{ + if (!SPIFFS.exists(g_currentFilePath)) { + Serial.println("No current file to clear"); + return; + } + + if (!openCurrentFlashFile(true)) { + return; + } + g_flashFile.close(); + g_flashLineNumber = 0; + openCurrentFlashFile(false); + Serial.println("Current flash file cleared"); +} + +static void setFlashFileContent(const char *text) +{ + if (!text) { + clearFlashFileContents(); + return; + } + + File file = SPIFFS.open(g_currentFilePath, FILE_WRITE); + if (!file) { + Serial.println("Unable to overwrite current flash file"); + return; + } + + file.print(text); + file.close(); + openCurrentFlashFile(false); + g_flashLineNumber = 0; +} + +static void appendFlashFileContent(const char *text) +{ + if (!text || text[0] == '\0') { + return; + } + + if (!openCurrentFlashFile(false)) { + return; + } + + g_flashFile.print(text); + g_flashFile.flush(); +} + +static void listFlashFiles() +{ + File dir = SPIFFS.open(kFlashDir); + if (!dir || !dir.isDirectory()) { + Serial.printf("Unable to list files in %s\r\n", kFlashDir); + return; + } + + Serial.printf("Files in %s:\r\n", kFlashDir); + File file = dir.openNextFile(); + while (file) { + Serial.printf(" %s (%u bytes)\r\n", file.name(), (unsigned)file.size()); + file = dir.openNextFile(); + } + dir.close(); +} + +static void processSerialCommand(const char *line) +{ + if (!line || line[0] == '\0') return; + + char tmp[384]; + strncpy(tmp, line, sizeof(tmp) - 1); + tmp[sizeof(tmp) - 1] = '\0'; + + char *cmd = strtok(tmp, " \t\r\n"); + if (!cmd) return; + + if (strcasecmp(cmd, "help") == 0) { + showHelp(); + return; + } + + if (strcasecmp(cmd, "stat") == 0) { + printFlashFileStat(); + return; + } + + if (strcasecmp(cmd, "rtc") == 0) { + if (g_hasRtc) { + char ts[32]; + if (getRtcTimestamp(ts, sizeof(ts))) { + Serial.printf("RTC now: %s\r\n", ts); + if (g_rtcLowVoltage) { + Serial.println("RTC low-voltage flag is set"); + } + } else { + Serial.println("RTC present but time read failed"); + } + } else { + Serial.println("RTC unavailable"); + } + return; + } + + if (strcasecmp(cmd, "list") == 0) { + listFlashFiles(); + return; + } + + if (strcasecmp(cmd, "read") == 0) { + printFlashFileContents(); + return; + } + + if (strcasecmp(cmd, "clear") == 0) { + clearFlashFileContents(); + return; + } + + if (strcasecmp(cmd, "write") == 0 || strcasecmp(cmd, "append") == 0) { + const char *payload = line + strlen(cmd); + while (*payload == ' ' || *payload == '\t') payload++; + if (strcasecmp(cmd, "write") == 0) + setFlashFileContent(payload); + else + appendFlashFileContent(payload); + + Serial.printf("%s: %s\r\n", cmd, payload); + return; + } + + Serial.println("Unknown command (help for list)"); +} + +void setup() +{ + Serial.begin(115200); + delay(800); + Serial.println("Exercise 17_Flash boot"); + + initRtc(); + + if (!SPIFFS.begin(true)) { + Serial.println("ERROR: SPIFFS mount failed"); + oledShowLines("Exercise 17_Flash", "Node: " NODE_LABEL, "SPIFFS mount FAILED"); + } else { + Serial.println("SPIFFS mounted successfully"); + if (createFlashLogFile()) { + Serial.printf("Current flash file: %s\r\n", g_currentFilePath); + } + } + + Wire.begin(OLED_SDA, OLED_SCL); + g_oled.setI2CAddress(OLED_ADDR << 1); + g_oled.begin(); + oledShowLines("Exercise 17_Flash", "Node: " NODE_LABEL, "Booting..."); + + delay(1000); +} + +void loop() +{ + static uint32_t lastMs = 0; + const uint32_t now = millis(); + + static char rxLine[384]; + static size_t rxLen = 0; + + while (Serial.available()) { + int c = Serial.read(); + if (c <= 0) continue; + if (c == '\r' || c == '\n') { + if (rxLen > 0) { + rxLine[rxLen] = '\0'; + processSerialCommand(rxLine); + rxLen = 0; + } + } else if (rxLen + 1 < sizeof(rxLine)) { + rxLine[rxLen++] = (char)c; + } + } + + if (now - lastMs < 1000) { + delay(10); + return; + } + lastMs = now; + + if (g_flashFile) { + appendFlashTimestampLine(); + } + printFlashStatus(); +}