/ firmware / src / networking / sntp.cpp
sntp.cpp
  1  #include "sntp.h"
  2  #include "../services/rtc.h"
  3  
  4  #include <atomic>
  5  #include <Arduino.h>
  6  #include <time.h>
  7  #include "esp_sntp.h"
  8  
  9  static std::atomic<bool> synced = false;
 10  static std::atomic<uint32_t> synced_epoch = 0;
 11  
 12  static void on_time_sync(struct timeval *tv) {
 13    (void)tv;
 14    time_t now_utc;
 15    time(&now_utc);
 16    synced_epoch.store((uint32_t)now_utc, std::memory_order_release);
 17    synced.store(true, std::memory_order_release);
 18    Serial.printf("[ntp] synced, epoch=%lu\n", (unsigned long)now_utc);
 19  }
 20  
 21  bool networking::sntp::sync() {
 22    synced.store(false, std::memory_order_relaxed);
 23    synced_epoch.store(0, std::memory_order_relaxed);
 24  
 25    setenv("TZ", config::sntp::TIME_ZONE, 1);
 26    tzset();
 27  
 28    sntp_set_time_sync_notification_cb(on_time_sync);
 29    configTzTime(config::sntp::TIME_ZONE, config::sntp::SERVER_1, config::sntp::SERVER_2);
 30  
 31    uint32_t start = millis();
 32    while (!synced.load(std::memory_order_acquire) && (millis() - start) < config::sntp::SYNC_TIMEOUT_MS) {
 33      vTaskDelay(pdMS_TO_TICKS(100));
 34    }
 35  
 36    uint32_t synced_epoch_value = synced_epoch.load(std::memory_order_acquire);
 37    if (synced.load(std::memory_order_acquire) && synced_epoch_value > 0) {
 38      services::rtc::setEpoch(synced_epoch_value);
 39      Serial.printf("[ntp] local time: %s\n", networking::sntp::accessLocalTimeString());
 40    } else {
 41      Serial.println(F("[ntp] sync timeout — using RTC time"));
 42    }
 43  
 44    return synced;
 45  }
 46  
 47  bool networking::sntp::isSynced() {
 48    return synced.load(std::memory_order_acquire);
 49  }
 50  
 51  const char *networking::sntp::accessLocalTimeString() {
 52    static char buf[32];
 53    struct tm timeinfo;
 54    if (!getLocalTime(&timeinfo, 0)) {
 55      snprintf(buf, sizeof(buf), "(no time)");
 56      return buf;
 57    }
 58    strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S %Z", &timeinfo);
 59    return buf;
 60  }
 61  
 62  uint32_t networking::sntp::accessUTCEpoch() {
 63    time_t now_utc;
 64    time(&now_utc);
 65    return (uint32_t)now_utc;
 66  }
 67  
 68  // ─────────────────────────────────────────────────────────────────────────────
 69  //  Tests
 70  // ─────────────────────────────────────────────────────────────────────────────
 71  #ifdef PIO_UNIT_TESTING
 72  
 73  
 74  #include "sntp.h"
 75  #include <networking/wifi.h>
 76  #include "services/rtc.h"
 77  #include <testing/utils.h>
 78  
 79  
 80  namespace networking::sntp { void test(void); }
 81  
 82  static WifiNvsSnapshot saved;
 83  static void save_nvs(void) { wifi_nvs_save(&saved); }
 84  static void restore_nvs(void) { wifi_nvs_restore(&saved); }
 85  
 86  static void test_sntp_config_defaults(void) {
 87    GIVEN("the NTP configuration");
 88    THEN("all required fields are set");
 89  
 90    TEST_ASSERT_NOT_NULL_MESSAGE(config::sntp::SERVER_1,
 91      "device: NTP server must not be NULL");
 92    TEST_ASSERT_NOT_EMPTY_MESSAGE(config::sntp::SERVER_1,
 93      "device: NTP server must not be empty");
 94    TEST_ASSERT_NOT_NULL_MESSAGE(config::sntp::TIME_ZONE,
 95      "device: timezone must not be NULL");
 96    TEST_ASSERT_NOT_EMPTY_MESSAGE(config::sntp::TIME_ZONE,
 97      "device: timezone must not be empty");
 98    TEST_ASSERT_GREATER_THAN_UINT32_MESSAGE(0, config::sntp::SYNC_TIMEOUT_MS,
 99      "device: sync timeout must be > 0");
100  
101  }
102  
103  static void test_sntp_not_synced_before_connect(void) {
104    GIVEN("WiFi has not connected yet");
105    THEN("NTP reports not synced");
106  
107    TEST_ASSERT_FALSE_MESSAGE(networking::sntp::isSynced(),
108      "device: NTP should not be synced before wifi_connect");
109  
110  }
111  
112  static void test_sntp_syncs_and_updates_rtc(void) {
113    GIVEN("WiFi is connected");
114    WHEN("NTP sync completes");
115    THEN("the RTC is updated to match");
116  
117    save_nvs();
118  
119    networking::wifi::sta::initialize();
120    if (!networking::wifi::sta::connect()) {
121      restore_nvs();
122      TEST_IGNORE_MESSAGE("skipped — no WiFi connection");
123      return;
124    }
125  
126    services::rtc::initialize();
127    uint32_t rtc_before = services::rtc::accessEpoch();
128  
129    bool synced = networking::sntp::sync();
130    if (!synced) {
131      restore_nvs();
132      TEST_IGNORE_MESSAGE("skipped — NTP sync timed out");
133      return;
134    }
135  
136    TEST_MESSAGE(networking::sntp::accessLocalTimeString());
137  
138    uint32_t rtc_after = services::rtc::accessEpoch();
139    uint32_t ntp_epoch = networking::sntp::accessUTCEpoch();
140  
141    TEST_ASSERT_UINT32_WITHIN_MESSAGE(2, ntp_epoch, rtc_after,
142      "device: RTC epoch diverges from NTP by more than 2 seconds");
143  
144    uint32_t drift_before = (rtc_before > ntp_epoch) ? rtc_before - ntp_epoch : ntp_epoch - rtc_before;
145    uint32_t drift_after  = (rtc_after > ntp_epoch)  ? rtc_after - ntp_epoch  : ntp_epoch - rtc_after;
146    TEST_ASSERT_LESS_OR_EQUAL_UINT32_MESSAGE(drift_before, drift_after,
147      "device: RTC did not get closer to NTP after sync — ds3231_set_epoch may not have been called");
148  
149    TEST_ASSERT_GREATER_THAN_UINT32_MESSAGE(1735689600, ntp_epoch,
150      "device: NTP epoch is before 2025 — sync may have failed");
151  
152    restore_nvs();
153  }
154  
155  void networking::sntp::test(void) {
156    MODULE("NTP");
157    RUN_TEST(test_sntp_config_defaults);
158    RUN_TEST(test_sntp_not_synced_before_connect);
159    RUN_TEST(test_sntp_syncs_and_updates_rtc);
160  }
161  
162  #endif