// 20260217 ChatGPT // $Id$ // $HeadURL$ #include #include #include #include #include "StartupSdManager.h" #include "tbeam_supreme_adapter.h" #ifndef OLED_SDA #define OLED_SDA 17 #endif #ifndef OLED_SCL #define OLED_SCL 18 #endif #ifndef OLED_ADDR #define OLED_ADDR 0x3C #endif #ifndef RTC_I2C_ADDR #define RTC_I2C_ADDR 0x51 #endif #ifndef GPS_BAUD #define GPS_BAUD 9600 #endif #ifndef FILE_APPEND #define FILE_APPEND FILE_WRITE #endif #ifndef FW_BUILD_EPOCH #define FW_BUILD_EPOCH 0 #endif #ifndef FW_BUILD_UTC #define FW_BUILD_UTC "unknown" #endif static const uint32_t kSerialDelayMs = 5000; static const uint32_t kLoopMsDiscipline = 60000; static const uint32_t kNoTimeDelayMs = 30000; static const uint32_t kGpsStartupProbeMs = 20000; static const uint32_t kPpsWaitTimeoutMs = 1500; static XPowersLibInterface* g_pmu = nullptr; static StartupSdManager g_sd(Serial); static U8G2_SH1106_128X64_NONAME_F_HW_I2C g_oled(U8G2_R0, /* reset=*/U8X8_PIN_NONE); static HardwareSerial g_gpsSerial(1); static uint32_t g_logSeq = 0; static uint32_t g_nextDisciplineMs = 0; static bool g_gpsPathReady = false; static char g_gpsLine[128]; static size_t g_gpsLineLen = 0; static volatile uint32_t g_ppsEdgeCount = 0; struct DateTime { uint16_t year; uint8_t month; uint8_t day; uint8_t hour; uint8_t minute; uint8_t second; }; struct GpsState { bool sawAnySentence = false; bool hasValidUtc = false; bool hasValidPosition = false; bool hasValidAltitude = false; uint8_t satsUsed = 0; uint8_t satsInView = 0; float hdop = -1.0f; float altitudeM = 0.0f; double latitudeDeg = 0.0; double longitudeDeg = 0.0; DateTime utc{}; uint32_t lastUtcMs = 0; }; static GpsState g_gps; static void logf(const char* fmt, ...) { char msg[240]; va_list args; va_start(args, fmt); vsnprintf(msg, sizeof(msg), fmt, args); va_end(args); Serial.printf("[%10lu][%06lu] %s\r\n", (unsigned long)millis(), (unsigned long)g_logSeq++, msg); } 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 uint8_t toBcd(uint8_t v) { return (uint8_t)(((v / 10U) << 4U) | (v % 10U)); } static uint8_t fromBcd(uint8_t b) { return (uint8_t)(((b >> 4U) * 10U) + (b & 0x0FU)); } static bool rtcRead(DateTime& out, bool& lowVoltageFlag) { Wire1.beginTransmission(RTC_I2C_ADDR); Wire1.write(0x02); if (Wire1.endTransmission(false) != 0) { return false; } const uint8_t need = 7; uint8_t got = Wire1.requestFrom((int)RTC_I2C_ADDR, (int)need); if (got != need) { return false; } uint8_t sec = Wire1.read(); uint8_t min = Wire1.read(); uint8_t hour = Wire1.read(); uint8_t day = Wire1.read(); (void)Wire1.read(); uint8_t month = Wire1.read(); uint8_t year = Wire1.read(); lowVoltageFlag = (sec & 0x80U) != 0; out.second = fromBcd(sec & 0x7FU); out.minute = fromBcd(min & 0x7FU); out.hour = fromBcd(hour & 0x3FU); out.day = fromBcd(day & 0x3FU); out.month = fromBcd(month & 0x1FU); uint8_t yy = fromBcd(year); bool century = (month & 0x80U) != 0; out.year = century ? (1900U + yy) : (2000U + yy); return true; } static bool rtcWrite(const DateTime& dt) { Wire1.beginTransmission(RTC_I2C_ADDR); Wire1.write(0x02); Wire1.write(toBcd(dt.second & 0x7FU)); Wire1.write(toBcd(dt.minute)); Wire1.write(toBcd(dt.hour)); Wire1.write(toBcd(dt.day)); Wire1.write(0x00); uint8_t monthReg = toBcd(dt.month); if (dt.year < 2000U) { monthReg |= 0x80U; } Wire1.write(monthReg); Wire1.write(toBcd((uint8_t)(dt.year % 100U))); return Wire1.endTransmission() == 0; } static bool isLeapYear(uint16_t y) { return ((y % 4U) == 0U && (y % 100U) != 0U) || ((y % 400U) == 0U); } static uint8_t daysInMonth(uint16_t year, uint8_t month) { static const uint8_t kDays[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; if (month == 2) { return (uint8_t)(isLeapYear(year) ? 29 : 28); } if (month >= 1 && month <= 12) { return kDays[month - 1]; } return 30; } static bool isValidDateTime(const DateTime& dt) { if (dt.year < 2000U || dt.year > 2099U) return false; if (dt.month < 1 || dt.month > 12) return false; if (dt.day < 1 || dt.day > daysInMonth(dt.year, dt.month)) return false; if (dt.hour > 23 || dt.minute > 59 || dt.second > 59) return false; return true; } static int64_t daysFromCivil(int y, unsigned m, unsigned d) { y -= (m <= 2); const int era = (y >= 0 ? y : y - 399) / 400; const unsigned yoe = (unsigned)(y - era * 400); const unsigned doy = (153 * (m + (m > 2 ? (unsigned)-3 : 9)) + 2) / 5 + d - 1; const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; return era * 146097 + (int)doe - 719468; } static int64_t toEpochSeconds(const DateTime& dt) { int64_t days = daysFromCivil((int)dt.year, dt.month, dt.day); return days * 86400LL + (int64_t)dt.hour * 3600LL + (int64_t)dt.minute * 60LL + (int64_t)dt.second; } static bool fromEpochSeconds(int64_t sec, DateTime& out) { if (sec < 0) { return false; } int64_t days = sec / 86400LL; int64_t rem = sec % 86400LL; if (rem < 0) { rem += 86400LL; days -= 1; } out.hour = (uint8_t)(rem / 3600LL); rem %= 3600LL; out.minute = (uint8_t)(rem / 60LL); out.second = (uint8_t)(rem % 60LL); days += 719468; const int era = (days >= 0 ? days : days - 146096) / 146097; const unsigned doe = (unsigned)(days - era * 146097); const unsigned yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; int y = (int)yoe + era * 400; const unsigned doy = doe - (365 * yoe + yoe / 4 - yoe / 100); const unsigned mp = (5 * doy + 2) / 153; const unsigned d = doy - (153 * mp + 2) / 5 + 1; const unsigned m = mp + (mp < 10 ? 3 : (unsigned)-9); y += (m <= 2); out.year = (uint16_t)y; out.month = (uint8_t)m; out.day = (uint8_t)d; return isValidDateTime(out); } static void addOneSecond(DateTime& dt) { int64_t t = toEpochSeconds(dt); DateTime out{}; if (fromEpochSeconds(t + 1, out)) { dt = out; } } static void formatUtcCompact(const DateTime& dt, char* out, size_t outLen) { snprintf(out, outLen, "%04u%02u%02u_%02u%02u%02u_z", (unsigned)dt.year, (unsigned)dt.month, (unsigned)dt.day, (unsigned)dt.hour, (unsigned)dt.minute, (unsigned)dt.second); } static void formatUtcHuman(const DateTime& dt, char* out, size_t outLen) { snprintf(out, outLen, "%04u-%02u-%02u %02u:%02u:%02u UTC", (unsigned)dt.year, (unsigned)dt.month, (unsigned)dt.day, (unsigned)dt.hour, (unsigned)dt.minute, (unsigned)dt.second); } static bool parseUInt2(const char* s, uint8_t& out) { if (!s || !isdigit((unsigned char)s[0]) || !isdigit((unsigned char)s[1])) { return false; } out = (uint8_t)((s[0] - '0') * 10 + (s[1] - '0')); return true; } static bool parseNmeaCoordToDecimal(const char* raw, const char* hemi, bool isLat, double& outDeg) { if (!raw || !hemi || raw[0] == '\0' || hemi[0] == '\0') { return false; } // NMEA uses ddmm.mmmm (lat) and dddmm.mmmm (lon), with leading zeros preserved. // Parse from string slices so longitudes like 071xx.xxxx do not collapse to 7xx.xxxx. int degDigits = isLat ? 2 : 3; size_t n = strlen(raw); if (n <= (size_t)degDigits + 2) { return false; } for (int i = 0; i < degDigits; ++i) { if (!isdigit((unsigned char)raw[i])) { return false; } } char degBuf[4] = {0}; memcpy(degBuf, raw, degDigits); int deg = atoi(degBuf); const char* minPtr = raw + degDigits; double minutes = atof(minPtr); if (minutes < 0.0 || minutes >= 60.0) { return false; } double dec = (double)deg + (minutes / 60.0); char h = (char)toupper((unsigned char)hemi[0]); if (h == 'S' || h == 'W') { dec = -dec; } else if (h != 'N' && h != 'E') { return false; } outDeg = dec; return true; } static void parseGga(char* fields[], int count) { if (count <= 7) { return; } const char* latRaw = (count > 2) ? fields[2] : nullptr; const char* latHem = (count > 3) ? fields[3] : nullptr; const char* lonRaw = (count > 4) ? fields[4] : nullptr; const char* lonHem = (count > 5) ? fields[5] : nullptr; int sats = atoi(fields[7]); if (sats >= 0 && sats <= 255) { g_gps.satsUsed = (uint8_t)sats; } if (count > 8 && fields[8] && fields[8][0] != '\0') { g_gps.hdop = (float)atof(fields[8]); } if (count > 9 && fields[9] && fields[9][0] != '\0') { g_gps.altitudeM = (float)atof(fields[9]); g_gps.hasValidAltitude = true; } // Position fallback from GGA so we still log coordinates if RMC position is missing. double lat = 0.0; double lon = 0.0; if (parseNmeaCoordToDecimal(latRaw, latHem, true, lat) && parseNmeaCoordToDecimal(lonRaw, lonHem, false, lon)) { g_gps.latitudeDeg = lat; g_gps.longitudeDeg = lon; g_gps.hasValidPosition = true; } } static void parseGsv(char* fields[], int count) { if (count <= 3) { return; } int sats = atoi(fields[3]); if (sats >= 0 && sats <= 255) { g_gps.satsInView = (uint8_t)sats; } } static void parseRmc(char* fields[], int count) { if (count <= 9) { return; } const char* utc = fields[1]; const char* status = fields[2]; const char* latRaw = fields[3]; const char* latHem = fields[4]; const char* lonRaw = fields[5]; const char* lonHem = fields[6]; const char* date = fields[9]; if (!status || status[0] != 'A') { return; } if (!utc || strlen(utc) < 6 || !date || strlen(date) < 6) { return; } uint8_t hh = 0, mm = 0, ss = 0; uint8_t dd = 0, mo = 0, yy = 0; if (!parseUInt2(utc + 0, hh) || !parseUInt2(utc + 2, mm) || !parseUInt2(utc + 4, ss)) { return; } if (!parseUInt2(date + 0, dd) || !parseUInt2(date + 2, mo) || !parseUInt2(date + 4, yy)) { return; } g_gps.utc.hour = hh; g_gps.utc.minute = mm; g_gps.utc.second = ss; g_gps.utc.day = dd; g_gps.utc.month = mo; g_gps.utc.year = (uint16_t)(2000U + yy); g_gps.hasValidUtc = true; g_gps.lastUtcMs = millis(); double lat = 0.0; double lon = 0.0; if (parseNmeaCoordToDecimal(latRaw, latHem, true, lat) && parseNmeaCoordToDecimal(lonRaw, lonHem, false, lon)) { g_gps.latitudeDeg = lat; g_gps.longitudeDeg = lon; g_gps.hasValidPosition = true; } } static void processNmeaLine(char* line) { if (!line || line[0] != '$') { return; } g_gps.sawAnySentence = true; char* star = strchr(line, '*'); if (star) { *star = '\0'; } 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); } if (count <= 0 || !fields[0]) { return; } const char* header = fields[0]; size_t n = strlen(header); if (n < 6) { return; } const char* type = header + (n - 3); if (strcmp(type, "GGA") == 0) { parseGga(fields, count); } else if (strcmp(type, "GSV") == 0) { parseGsv(fields, count); } else if (strcmp(type, "RMC") == 0) { parseRmc(fields, count); } } static void pollGpsSerial() { while (g_gpsSerial.available() > 0) { char c = (char)g_gpsSerial.read(); if (c == '\r') { continue; } if (c == '\n') { if (g_gpsLineLen > 0) { g_gpsLine[g_gpsLineLen] = '\0'; processNmeaLine(g_gpsLine); g_gpsLineLen = 0; } continue; } if (g_gpsLineLen + 1 < sizeof(g_gpsLine)) { g_gpsLine[g_gpsLineLen++] = c; } else { g_gpsLineLen = 0; } } } static bool collectGpsTraffic(uint32_t windowMs) { uint32_t start = millis(); bool sawBytes = false; while ((uint32_t)(millis() - start) < windowMs) { if (g_gpsSerial.available() > 0) { sawBytes = true; } pollGpsSerial(); g_sd.update(); delay(2); } return sawBytes || g_gps.sawAnySentence; } static void initialGpsProbe() { logf("GPS startup probe at %u baud", (unsigned)GPS_BAUD); (void)collectGpsTraffic(kGpsStartupProbeMs); logf("GPS probe complete: nmea=%s sats_used=%u sats_view=%u utc=%s", g_gps.sawAnySentence ? "yes" : "no", (unsigned)g_gps.satsUsed, (unsigned)g_gps.satsInView, g_gps.hasValidUtc ? "yes" : "no"); } static IRAM_ATTR void onPpsEdge() { g_ppsEdgeCount++; } static uint8_t bestSatelliteCount() { return (g_gps.satsUsed > g_gps.satsInView) ? g_gps.satsUsed : g_gps.satsInView; } static bool ensureGpsLogPathReady() { if (!g_sd.isMounted()) { g_gpsPathReady = false; return false; } if (g_gpsPathReady) { return true; } if (!g_sd.ensureDirRecursive("/gps")) { logf("Could not create /gps directory"); return false; } // Touch the log file so a clean SD card is prepared before first discipline event. File f = SD.open("/gps/discipline_rtc.log", FILE_APPEND); if (!f) { logf("Could not open /gps/discipline_rtc.log for append"); return false; } f.close(); g_gpsPathReady = true; return true; } static bool appendDisciplineLog(const DateTime& gpsUtc, bool havePriorRtc, int64_t rtcMinusGpsSeconds, uint8_t sats, uint32_t utcAgeMs, uint32_t ppsEdges, char* outTs, size_t outTsLen) { if (!ensureGpsLogPathReady()) { logf("SD not mounted, skipping append to gps/discipline_rtc.log"); return false; } File f = SD.open("/gps/discipline_rtc.log", FILE_APPEND); if (!f) { logf("Could not open /gps/discipline_rtc.log for append"); return false; } char ts[32]; formatUtcCompact(gpsUtc, ts, sizeof(ts)); if (outTs && outTsLen > 0) { snprintf(outTs, outTsLen, "%s", ts); } char drift[40]; if (havePriorRtc) { snprintf(drift, sizeof(drift), "%+lld s", (long long)rtcMinusGpsSeconds); } else { snprintf(drift, sizeof(drift), "RTC_unset"); } char pos[64]; if (g_gps.hasValidPosition) { snprintf(pos, sizeof(pos), "lat=%.6f lon=%.6f", g_gps.latitudeDeg, g_gps.longitudeDeg); } else { snprintf(pos, sizeof(pos), "lat=NA lon=NA"); } char hdop[16]; if (g_gps.hdop > 0.0f) { snprintf(hdop, sizeof(hdop), "%.1f", g_gps.hdop); } else { snprintf(hdop, sizeof(hdop), "NA"); } char alt[16]; if (g_gps.hasValidAltitude) { snprintf(alt, sizeof(alt), "%.1f", g_gps.altitudeM); } else { snprintf(alt, sizeof(alt), "NA"); } char line[320]; snprintf(line, sizeof(line), "%s\t set RTC to GPS using 1PPS pulse-per-second discipline\t" "rtc-gps drift=%s; sats=%u; %s; alt_m=%s; hdop=%s; utc_age_ms=%lu; pps_edges=%lu; " "fw_epoch=%lu; fw_build_utc=%s", ts, drift, (unsigned)sats, pos, alt, hdop, (unsigned long)utcAgeMs, (unsigned long)ppsEdges, (unsigned long)FW_BUILD_EPOCH, FW_BUILD_UTC); size_t wrote = f.println(line); f.close(); if (wrote == 0) { logf("Append write failed: /gps/discipline_rtc.log"); return false; } return true; } static bool gpsUtcIsFresh() { if (!g_gps.hasValidUtc) { return false; } return (uint32_t)(millis() - g_gps.lastUtcMs) <= 2000; } static bool waitForNextPps(uint32_t timeoutMs) { uint32_t startCount = g_ppsEdgeCount; uint32_t startMs = millis(); while ((uint32_t)(millis() - startMs) < timeoutMs) { pollGpsSerial(); g_sd.update(); if (g_ppsEdgeCount != startCount) { return true; } delay(2); } return false; } static void waitWithUpdates(uint32_t delayMs) { uint32_t start = millis(); while ((uint32_t)(millis() - start) < delayMs) { pollGpsSerial(); g_sd.update(); delay(10); } } static void showNoTimeAndDelay() { uint8_t sats = bestSatelliteCount(); char l3[24]; snprintf(l3, sizeof(l3), "Satellites: %u", (unsigned)sats); oledShowLines("GPS time unavailable", "RTC NOT disciplined", l3, "Retry in 30 seconds"); logf("GPS UTC unavailable. satellites=%u. Waiting 30 seconds.", (unsigned)sats); waitWithUpdates(kNoTimeDelayMs); } static bool disciplineRtcToGps() { if (!gpsUtcIsFresh()) { showNoTimeAndDelay(); return false; } DateTime priorRtc{}; bool lowV = false; bool havePriorRtc = rtcRead(priorRtc, lowV); if (havePriorRtc && (lowV || !isValidDateTime(priorRtc))) { havePriorRtc = false; } DateTime gpsSnap = g_gps.utc; if (!waitForNextPps(kPpsWaitTimeoutMs)) { oledShowLines("GPS 1PPS missing", "RTC NOT disciplined", "Retry in 30 seconds"); logf("No 1PPS edge observed within timeout. Waiting 30 seconds."); waitWithUpdates(kNoTimeDelayMs); return false; } DateTime target = gpsSnap; addOneSecond(target); if (!rtcWrite(target)) { oledShowLines("RTC write failed", "Could not set from GPS"); logf("RTC write failed"); return false; } int64_t driftSec = 0; if (havePriorRtc) { driftSec = toEpochSeconds(priorRtc) - toEpochSeconds(target); } uint8_t sats = bestSatelliteCount(); uint32_t utcAgeMs = (uint32_t)(millis() - g_gps.lastUtcMs); uint32_t ppsEdges = g_ppsEdgeCount; char tsCompact[32]; bool logOk = appendDisciplineLog(target, havePriorRtc, driftSec, sats, utcAgeMs, ppsEdges, tsCompact, sizeof(tsCompact)); char utcLine[36]; char driftLine[36]; char logLine[36]; formatUtcHuman(target, utcLine, sizeof(utcLine)); if (havePriorRtc) { snprintf(driftLine, sizeof(driftLine), "rtc-gps drift: %+lld s", (long long)driftSec); } else { snprintf(driftLine, sizeof(driftLine), "rtc-gps drift: RTC_unset"); } snprintf(logLine, sizeof(logLine), "Log:%s sats:%u", logOk ? "ok" : "fail", (unsigned)sats); oledShowLines("RTC disciplined to GPS", utcLine, driftLine, logLine, tsCompact); logf("RTC disciplined to GPS with 1PPS. %s drift=%+llds lowV=%s", utcLine, (long long)driftSec, lowV ? "yes" : "no"); return true; } void setup() { Serial.begin(115200); delay(kSerialDelayMs); Serial.println("\r\n=================================================="); Serial.println("Exercise 11: Set RTC to GPS with 1PPS discipline"); Serial.println("=================================================="); if (!tbeam_supreme::initPmuForPeripherals(g_pmu, &Serial)) { logf("PMU init failed"); } Wire.begin(OLED_SDA, OLED_SCL); g_oled.setI2CAddress(OLED_ADDR << 1); g_oled.begin(); oledShowLines("Exercise 11", "RTC <- GPS (1PPS)", "Booting..."); SdWatcherConfig sdCfg{}; if (!g_sd.begin(sdCfg, nullptr)) { logf("SD startup manager begin() failed"); } (void)ensureGpsLogPathReady(); #ifdef GPS_WAKEUP_PIN pinMode(GPS_WAKEUP_PIN, INPUT); #endif #ifdef GPS_1PPS_PIN pinMode(GPS_1PPS_PIN, INPUT); attachInterrupt(digitalPinToInterrupt(GPS_1PPS_PIN), onPpsEdge, RISING); #endif g_gpsSerial.setRxBufferSize(1024); g_gpsSerial.begin(GPS_BAUD, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN); logf("GPS UART started: RX=%d TX=%d baud=%u", GPS_RX_PIN, GPS_TX_PIN, (unsigned)GPS_BAUD); oledShowLines("GPS startup probe", "Checking UTC + 1PPS"); initialGpsProbe(); g_nextDisciplineMs = millis(); } void loop() { pollGpsSerial(); g_sd.update(); if (g_sd.consumeMountedEvent()) { g_gpsPathReady = false; (void)ensureGpsLogPathReady(); } if (g_sd.consumeRemovedEvent()) { g_gpsPathReady = false; } uint32_t now = millis(); if ((int32_t)(now - g_nextDisciplineMs) >= 0) { bool ok = disciplineRtcToGps(); g_nextDisciplineMs = now + (ok ? kLoopMsDiscipline : kNoTimeDelayMs); } delay(5); }