Мы уже привыкли жить в глобальном информационном мире, где, с одной стороны, довольно важно знать точное время, а с другой стороны - легко его получить, достаточно настроить на компьютере NTP, да вот хотя бы просто выполнить команду типа ntpdate pool.ntp.org.

Но есть нюанс: со всеми этими замедлениями, блокировками и "белыми списками" больше нет никакой гарантии, что как раз в нужный момент они не заблокируют нам и NTP протокол, ведь известные мировые NTP-сервера вряд ли будут в белых списках, а использовать какие-нибудь другие - ну, вспоминается история пропадания некоторых доменных имен из НСДИ, и ввод платы "за доступ к часам точного времени", что бы под этим не подразумевалось.

В общем, спасение утопающих...
К тому же, как говорят, "любой, кто увидел Ардуино - рано или поздно делает часы или метеостанцию".
Простые часы мы делать не будем, а сделаем вполне IT-шный NTP-сервер.

За основу берем чип ESP8266, точнее его вариант ESP12-F. Сейчас они считаются устаревшими, все давно сбежали на ESP32, но на самом деле чип хороший, а его производительности тут более чем достаточно.
Если кто до сих пор не знал - он прекрасно программируется стандартными средствами Arduino, прошивка пишется на вполне обычном C++ (каким он был году в 99, без современных наворотов).

Общая идея следующая: настраиваем подключение к WiFi сети, создаем внутри NTP-сервер и раздаем точное время по запросам клиентов из локальной сети.
И сразу же вопрос - где его брать, это точное время?
На самом деле исходим из того, что какой-то доступ куда-то будет - просто непостоянный, примерно как в достославные времена диалапа - сначала нужно подключиться к интернету, потом поработать, потом отключиться.
А это значит, что можно использовать например всё тот же NTP, установить время, потом в течении какого-то периода пользоваться, потом периодически актуализировать его при возможности.

В стандартных пакетах для ESP уже есть "системное время" - его можно установить, затем оно "идёт" вместе с тиками процессора. Тот же самый принцип что и в любой ОС.
И тоже как в любой ОС - точность хода этого времени зависит от того, насколько "ОС" правильно оценивает частоту тактирования процессора, насколько стабильна эта частота, и т.д. В принципе, если просто иногда поправлять его - этого уже вполне достаточно.

В нормальных условиях для этого существует функция привязки к внешним NTP-серверам:

#include <time.h>

configTime(0, 0, "pool.ntp.org");

Раз в 3600 секунд "ОС" будет автоматически делать запрос и устанавливать время.
Когда нужно получить теущее время - запрашиваем его:

time_t now = time(nullptr);

По факту - это обычный unixtime, время с секундах с 01.01.1970.
То есть, время тут обрабатыается с точностью до секунд. Обычно этого вполне достаточно. но ведь мы хотели сервер ТОЧНОГО времени, чтобы раздавать его по NTP, а там используются сотые и тысячные доли секунды.

Поэтому прежде всего нужно сделать свой счетчик времени, с микросекундами.
Принцип простой: при установке времени сохраняется указанное точное время и текущий счетчик микросекунд процессора, при запросе к нему добавляется количество микросекунд, прошедших между установкой и запросом, и сохраняется новое время.
Таким образом, чтобы время "шло" - его надо периодически опрашивать, точность - до микросекунд, а погрешность - в пределах времени выполнения функций запроса, около 20-30 микросекунд. Неидеально, но сойдет.

#ifndef jb_time
#define jb_time

#include <stdint.h>

#define JB_TIME_MAX_AGE  600

class JbTime {
private:

  uint64_t _last ;    // last change time
  uint64_t _sec ;     // seconds
  uint32_t _usec ;    // microseconds
  uint32_t _mark ;    // mark uS for time update


public:
  bool ok;
  bool fresh;

  JbTime(){
    _sec = 0;
    _usec = 0;
    _mark = 0;
    _last = 0;
    ok = false;
    fresh = false;
  }

  inline bool old() {
    if(_sec == 0) return true;
    if((_sec - _last) > JB_TIME_MAX_AGE) return true;
    return false;
  }

  inline void copy(JbTime * src) {
    uint64_t sec;
    uint32_t usec;
    src->gettime(&sec, &usec);
    _mark = micros();
    _sec = sec;
    _usec = usec;
    ok = src->ok;
    _last = sec;
  }

    inline void settime(uint64_t sec, uint32_t usec, uint32_t mark = micros() ){
    _mark = mark;
    _sec = sec;
    _sec += usec / 1000000;
    _usec = usec % 1000000;
    _usec = usec;
    ok = true;
    _last = sec;
  }

  inline void gettime(uint64_t *o_sec, uint32_t *o_usec){
    if(ok){
      uint32_t now = micros();
      uint32_t delta = now - _mark;

      _mark = now;

      uint64_t total = (uint64_t)_usec + delta;

      _sec += total / 1000000;
      _usec = total % 1000000;

      *o_sec = _sec;
      *o_usec = _usec;
    }else{
      *o_sec = 0;
      *o_usec = 0;
    }
  }

  inline uint64_t synced(){
    return _last;
  }

};

#endif

И поскольку это класс - можно создавать несколько разных счетчиков времени, под разные цели.

Так как штатное время работало с точностью до секунд - то и запросы к NTP серверам из стандартной библиотеки выдавали результат с точностью до секунд, а нам нужно точнее.
Протокол NTP описан в RFC 5905: бинарный протокол, основанный на обмене пакетами между клиентом и сервером, где сервер передает свои данные о текущем времени.
Используется UDP, 123 порт, время считается в секундах с 1900 года.
Ключевой момент в формуле расчета сетевой задержки, так как сервер не отвечает на запрос мгновенно, и время нельзя просто так взять и получить.

Создать свой NTP-сервер тоже не очень сложно, достаточно на запрос времени отправить UDP-пакет с подготовленными данными. Время берем свое системное.

Напишем свой класс для работы с NTP, для запросов и ответов сразу:

#ifndef jb_ntp
#define jb_ntp

#include <ESP8266WiFi.h>
#include <WiFiUdp.h>
#include <time.h>
#include <JbTime.h>

#define ntpPort   123
#define NTP_UNIX_EPOCH_DIFF 2208988800UL
#define MINIMAL_UNIXTIME    1767225600UL

// Структура NTP пакета (48 байт)
struct NTPPacket {
  uint8_t li_vn_mode;      // leap indicator, version, mode
  uint8_t stratum;          // стратум сервера
  uint8_t poll;             // интервал опроса
  uint8_t precision;        // точность часов
  uint32_t rootDelay;       // задержка до корневого источника
  uint32_t rootDispersion;  // дисперсия
  uint32_t refId;           // id источника
  uint32_t refTm_s;         // время последней синхронизации (секунды)
  uint32_t refTm_f;         // время последней синхронизации (дробная часть)
  uint32_t origTm_s;        // время отправки запроса клиентом (T1) - секунды
  uint32_t origTm_f;        // время отправки запроса клиентом (T1) - дробная часть
  uint32_t rxTm_s;          // время получения запроса сервером (T2) - секунды
  uint32_t rxTm_f;          // время получения запроса сервером (T2) - дробная часть
  uint32_t txTm_s;          // время отправки ответа сервером (T3) - секунды
  uint32_t txTm_f;          // время отправки ответа сервером (T3) - дробная часть
} __attribute__((packed));

#define JB_NTPCLIENT_NOINIT     1
#define JB_NTPCLIENT_NODNS      2
#define JB_NTPCLIENT_NOSEND     3
#define JB_NTPCLIENT_NOREPLY    4
#define JB_NTPCLIENT_BADPACKET  5
#define JB_NTPCLIENT_ZEROTIME   6

class JbNTPClient {
private:

  WiFiUDP udp;

  int _error;
  bool _success;
  double _networkDelay;

  JbTime * systime;


  // преобразование 32-битного NTP времени в double (секунды + дробная часть)
  inline double ntpToDouble(uint32_t sec, uint32_t frac) {
    return sec + frac / 4294967296.0; // 2^32
  }
  inline double ntpToDouble(uint64_t sec, uint32_t frac) {
    uint32_t x_sec = (uint32_t)(sec & 0xFFFFFFFF);
    return x_sec + frac / 4294967296.0; // 2^32
  }

  // создание NTP-запроса
  void createRequest(NTPPacket &packet, uint64_t sec, uint32_t frac) {
    memset(&packet, 0, sizeof(NTPPacket));
    packet.li_vn_mode = 0x23; // LI=0, VN=4, Mode=3 (client)

    uint32_t x_sec = (uint32_t)(sec & 0xFFFFFFFF);

    packet.txTm_s = htonl(x_sec);
    packet.txTm_f = htonl(frac);

  }


public:

  JbNTPClient(JbTime * src) {
    _success = false;
    _error  = JB_NTPCLIENT_NOINIT;
    _networkDelay = 0;
    systime = src;
  }

  bool begin() {
    return udp.begin(ntpPort);
  }

  WiFiUDP port() { return udp; }

  bool success() { return _success; }
  bool error() { return _error; }
  double netDelay() { return _networkDelay; }

  // основная функция запроса к NTP-серверу
  // заполняет mytime
  bool requestTime(const char* server, JbTime * mytime) {
    _success = false;
    _error  = JB_NTPCLIENT_NOINIT;
    _networkDelay = 0;

    mytime->ok = false;

    IPAddress timeServerIP;
    if (!WiFi.hostByName(server, timeServerIP)) {
      _error  = JB_NTPCLIENT_NODNS;
      return false;
    }

    NTPPacket packet;

    // T1: время отправки пакета
    uint64_t t1_sec = 0;
    uint32_t t1_usec = 0;
    systime->gettime(&t1_sec, &t1_usec);
    t1_sec += NTP_UNIX_EPOCH_DIFF;
    uint32_t t1_frac = (uint32_t)((t1_usec * (uint64_t)0x100000000ULL) / 1000000ULL);

    createRequest(packet, t1_sec, t1_frac);

    // отправляем запрос
    udp.beginPacket(timeServerIP, ntpPort);
    udp.write((uint8_t*)&packet, sizeof(NTPPacket));
    udp.endPacket();

    // ожидаем ответ с таймаутом
    unsigned long timeout = millis() + 2000; // 2 секунды таймаут
    while (udp.parsePacket() == 0) {
      if (millis() > timeout) {
        _error  = JB_NTPCLIENT_NOREPLY;
        return false;
      }
      delay(10);
    }

    // T4: время получения ответа 
    uint64_t t4_sec;
    uint32_t t4_usec;
    systime->gettime(&t4_sec, &t4_usec);
    t4_sec += NTP_UNIX_EPOCH_DIFF;
    uint32_t t4_frac = (uint32_t)((t4_usec * (uint64_t)0x100000000ULL) / 1000000ULL);
    uint32_t t4_mark = micros();


    // читаем ответ
    int len = udp.read((uint8_t*)&packet, sizeof(NTPPacket));
    if (len < sizeof(NTPPacket)) {
      _error  = JB_NTPCLIENT_BADPACKET;
      return false;
    }

    uint8_t mode = packet.li_vn_mode & 0x07;
    if (mode != 4) {
      _error  = JB_NTPCLIENT_BADPACKET;
      return false;
    }

    // извлекаем метки времени из пакета
    uint32_t t2_sec = ntohl(packet.rxTm_s);
    uint32_t t2_frac = ntohl(packet.rxTm_f);
    uint32_t t3_sec = ntohl(packet.txTm_s);
    uint32_t t3_frac = ntohl(packet.txTm_f);

    // проверяем, что сервер вернул валидные временные метки
    if (t2_sec == 0 || t3_sec == 0) {
      _error  = JB_NTPCLIENT_ZEROTIME;
      return false;
    }

    // преобразуем все времена в double 
    double t1 = ntpToDouble(t1_sec, t1_frac);
    double t2 = ntpToDouble(t2_sec, t2_frac);
    double t3 = ntpToDouble(t3_sec, t3_frac);
    double t4 = ntpToDouble(t4_sec, t4_frac);

   // задержка сети (round-trip delay)
    _networkDelay = (t4 - t1) - (t3 - t2);

    // смещение времени (offset) по формуле RFC 5905
    double offset = ((t2 - t1) + (t3 - t4)) / 2;

    // точное текущее время в момент получения ответа (t4)
    double newtime = t4 + offset;
    // проверяем эру
    if(newtime < (MINIMAL_UNIXTIME + NTP_UNIX_EPOCH_DIFF)){
      newtime += (1ULL<<32);
    }


    // конвертируем в Unix-время (секунды с 1970) 
    uint64_t sec = (uint32_t)newtime - NTP_UNIX_EPOCH_DIFF;

    uint32_t usec = (newtime - (uint32_t)newtime) * 1000000;

    _success = true;
    _error = 0;
    mytime->settime(sec, usec, t4_mark);

    return true;
  }

  // обработка запросов клиентов если они есть
  void serve(){
    int packetSize = udp.parsePacket();
    if (packetSize) {

      NTPPacket packet;
      int len = udp.read((uint8_t*)&packet, sizeof(NTPPacket));
      if (len < sizeof(NTPPacket)) {
        return ;
      }

      // проверка режима
      uint8_t mode = packet.li_vn_mode & 0x07;
      if (mode == 3) {
        // это запрос клиента 
        if(systime->ok){
          uint64_t sec;
          uint32_t usec;
          systime->gettime(&sec, &usec);

          sec += NTP_UNIX_EPOCH_DIFF;
          uint32_t frac = (uint32_t)((usec * (uint64_t)0x100000000ULL) / 1000000ULL);

          packet.li_vn_mode = 0b00100100;
          packet.stratum    = 1;
          packet.poll       = 0;
          packet.precision  = 0xEC;
          packet.rootDelay  = 0;
          packet.rootDispersion  = 0;
          packet.refId      = 0;
          packet.refTm_s    = htonl((uint32_t)sec);
          packet.refTm_f    = htonl(frac);
          packet.origTm_s   = packet.txTm_s;
          packet.origTm_f   = packet.txTm_f;
          packet.rxTm_s    = htonl((uint32_t)sec);
          packet.rxTm_f    = htonl(frac);
          packet.txTm_s    = htonl((uint32_t)sec);
          packet.txTm_f    = htonl(frac);

          udp.beginPacket(udp.remoteIP(), udp.remotePort());
          udp.write((uint8_t*)&packet, sizeof(NTPPacket));
          udp.endPacket();

        }
      }
    }
  }

};

#endif

В принципе, часть задачи реализована: у нас есть счетчики точного времени, мы можем получать время по NTP с других серверов, и раздавать его сами.
Но как уже говорилось - не факт, что сервера будут доступны.

Добавим немного космоса

Есть еще один источник точного времени, о котором забывают: это GPS.
Сам принцип работы GPS основан на том, что известно точное время и время задержки поступления сигналов с разных спутников, что позволяет вычислять расстояния до них с высокой точностью и выстраивать пространственную картину, в которой место приемника точно известно.
Место сейчас не очень важно, приемник стационарный - а вот время да.

Для этого подключаем модуль GPS Neo-6M

Комментарий в Ардуино-стиле: обратите внимание, он "красненький"!
Дело в том, что есть еще другие, как правило синего цвета, но отличаются они не цветом, а наличием вывода PPS: здесь 5 контактов, один из них PPS, а там 4, и PPS нет.
Но он нам нужен.

Это довольно самодостаточный модуль, который сам пытается искать сигналы спутников, сам рассчитывает геопозицию и т.д., и выдает уже готовые результаты в виде обычного TTL serial. То есть, достоточно просто читать что он пишет - и среди строк разного типа есть такая, которая содержит текущее время с точностью до секунды, и дату.

Для этого неплохо подходит сообщение GPRMC, выглядит примерно так:

$GPRMC,050603.00,A,2236.91423,N,11403.34555,E,0.13,303.34,020126,D*7F

В данном случае -
050603 это время, 05:06:03 UTC
020126 - дата, 2026.01.02
A - данные валидны
2236.91423,N,11403.34555,E - координаты
0.13 - скорость в узлах (мили в час),
303.34 - азимут направления движения (как известно - всегда есть погрешность по скорости, а неподвижный объект “крутится” в случайных направлениях)

Для того, чтобы узнать время - нужно прочитать это сообщение, разобрать его, и вытащить дату и время.
Но время тут в секундах, а нужно ТОЧНЕЕ.
Именно для этого и нужен вывод PPS:

PPS (Pulse Per Second): это не просто "моргалка светодиодом раз в секунду", тут логика работы такая, что когда начинается очередная секунда - на этом выводе появляется импульс. Точность - единицы микросекунд. И уже после этого - в serial идут строки с новым временем, какая именно секунда началась.

Если отследить этот момент, и потом получить строку со временем - мы будем точно знать, сколько уже микросекунд прошло, и можем установить системное время максимально точно.
Для этого используем прерывание, отмечаем момент импульса - затем получаем значение секунды которая началась, а в момент запроса на время вычислим разницу и тем самым получим почти точное время.

Но есть еще один нюанс:
В наше время могут заблокировать не только NTP, но и заглушить GPS. Точнее, занимаются спуфингом, забивая эфир ложными сигналами - при этом приемник либо просто не может поймать локацию (и соответственно время), либо ловит обманку, показывая что летит где-то на высоте, с большой скоростью, и в сотне километров отсюда.
Естественно, время при этом тоже неверное.

Поэтому пришлось добавить контроль местонахождения: если высота, координаты и скорость сильно отличаются от заданных - значит, GPS врёт.

Получилось вот так:

#include <ESP8266WiFi.h>
#include <time.h>
#include <JbTime.h>

//#define DEBUG

#ifdef DEBUG
void MqttPublish(const char * str);
#endif

/* add to main.ino

void GPSSetup();
bool GPSGetTime(JbTime * time);
void GPSLoop();

*/

#define REAL_ALT        XXX             // RMC 7
#define REAL_LAT        XXXX           // RMC 5
#define REAL_LON       XXXX           // RMC 3

#define PIN_INT         13
#define BUFLEN          200

volatile bool ppsFlag = false;
volatile uint32_t ppsMicros = 0;
bool gpsOk = false;
bool altOk = false;
uint64_t sec = 0;

#define X_GGA   0x00
#define X_GLL   0x01
#define X_GSA   0x02
#define X_GSV   0x03
#define X_RMC   0x04
#define X_VTG   0x05

void calcChecksum(uint8_t *data, uint8_t len, uint8_t &ck_a, uint8_t &ck_b) {
  ck_a = 0;
  ck_b = 0;

  for (uint8_t i = 0; i < len; i++) {
    ck_a = ck_a + data[i];
    ck_b = ck_b + ck_a;
  }
}

void sendUBX(uint8_t *msg, uint8_t len) {
  for (uint8_t i = 0; i < len; i++) {
    Serial.write(msg[i]);
  }
}

void disableX(uint8_t x){
  uint8_t buffer[] = {
    0xB5, 0x62,       // Header
    0x06, 0x01,       // CFG-MSG
    0x03, 0x00,       // Length
    0xF0, 0x03, 0x00, // Disable GSV (rate = 0)
    0xFD, 0x15        // Checksum
  };
  buffer[7] = x;
  calcChecksum(&buffer[2], sizeof(buffer) - 4, buffer[9], buffer[10]);

  sendUBX(buffer, sizeof(buffer));
}

//-------------------------------------
void ICACHE_RAM_ATTR onPPS() {
    ppsMicros = micros();       // фиксируем момент импульса
    ppsFlag = true;             // отметка о событии
}

//-------------------------------------
void GPSSetup(){
  gpsOk = false;
  altOk = false;
  ppsFlag = false;
  ppsMicros = micros();

  disableX(X_GSV);
  disableX(X_GSA);
  disableX(X_VTG);
  disableX(X_GLL);

  pinMode(PIN_INT, INPUT);
  attachInterrupt(digitalPinToInterrupt(PIN_INT), onPPS, RISING);
}

//-------------------------------------
bool GPSGetTime(JbTime * time){

  if(gpsOk && altOk){

    uint32_t nowMicros = micros();
    uint32_t usec = nowMicros - ppsMicros;

    if(ppsFlag){
      // RMC не успел придти
      time->settime(sec + 1,usec);
    }else{
      time->settime(sec, usec);
    }

    return true;
  }
  return false;
}

// -----------------------------------------------
void parse_rmc(char * str){
  char *token;
  char *rest = str;

  char *fields[20];
  int count = 0;

  // разбор на токены
  while ((token = strsep(&rest, ",")) != NULL) {
    fields[count++] = token;
  }

  gpsOk = false;

  // предварительно - OK
  if(strcmp(fields[2],"A")==0){

    double lon = 0;
    sscanf(fields[3], "%lf", &lon );
    double lat = 0;
    sscanf(fields[5], "%lf", &lat );
    float speed;
    sscanf(fields[7], "%f", &speed );

    if(speed > 0.2) return;
    if(abs(lon - REAL_LON) > 0.03) return;
    if(abs(lat - REAL_LAT) > 0.03) return;

    // ----------------------
    int hh, mm, ss;
    int day, mon, year;

    // time
    sscanf(fields[1], "%2d%2d%2d", &hh, &mm, &ss);
    // date
    sscanf(fields[9], "%2d%2d%2d", &day, &mon, &year);

    if (year < 80)
      year += 2000;
    else
      year += 1900;

    struct tm t = {0};

    t.tm_year = year - 1900;
    t.tm_mon  = mon - 1;
    t.tm_mday = day;
    t.tm_hour = hh;
    t.tm_min  = mm;
    t.tm_sec  = ss;

    if(ppsFlag){
      // PPS был, отмечаем секунду
      sec = mktime(&t);
      ppsFlag = false;
    }

    // ----------------------

    gpsOk = true;

  }

}

void parse_gga(char * str){
  char *token;
  char *rest = str;

  char *fields[20];
  int count = 0;

  // разбор на токены
  while ((token = strsep(&rest, ",")) != NULL) {
    fields[count++] = token;
  }

  altOk = false;

  float alt = 0;
  sscanf(fields[9], "%f", &alt );

  if(abs(alt - REAL_ALT) < 50) altOk = true;

}

// -----------------------------------------------
void GPSLoop(){
  static char buffer[BUFLEN];
  static int p = 0;

  while (Serial.available()) {
    char c = Serial.read();

    if (c == '\n') {                                  // конец строки

      if(memcmp(buffer,"$GPRMC",6)==0){
        parse_rmc(buffer);
      }
      else{
        //GGA - 9 высота
        if(memcmp(buffer,"$GPGGA",6)==0){
          parse_gga(buffer);
        }
      }
      buffer[0] = 0;                                    // очищаем буфер
      p = 0;
      return;                                           // выходим сразу после одной строки
    } else if (c != '\r' && p < (BUFLEN - 1) ) {        // игнорируем CR
      buffer[p] = c;
      p++;
      buffer[p] = 0;
    }
  }
}


Сохраним время

Но всё это работает, пока модуль работает. Стоит отключить питание - и время пропадает, а вот сможем ли мы получить его после перезапуска - в нынешних условиях большой вопрос. Кроме того, точность хода системного времени при отсутствии синхронизации тоже под вопросом, поэтому следующий шаг - добавить RTC, “часы реального времени”. Примерно как в компьютерах - встроенный чип с батарейкой “биоса” на плате (никакому “BIOS” батарейка не нужна, это именно для RTC).

По сути это что-то вроде электронных часов, только без дисплея: в основе кварцевый резонатор на частоту 32768 Гц, из которой с помощью несложных двоичных делителей получается 1Гц, которые затем считаются, складываясь в минуты, часы и дни. Никаких микропроцессоров, простая бинарная логика - вот её-то и питает батарейка. В процессе работы можно установить системное время в RTC, и после выключения внешнего питания часы продолжают идти. При очередной загрузке из RTC можно получить текущее время и установить как системное.

По подобной схеме устроены примерно все электронные цифровые часы: если на них написано “Quartz” - это как раз про тот кварцевый резонатор на 32768 Гц. Кто с ними сталкивался - знает и ключевую проблему: время “плывёт”, часы постепенно отстают или спешат, как повезет. Причина не в качестве самой электроники часов, а в точности резонансной частоты этого резонатора, которая зависит от его физических размеров, точности изготовления на заводе, и даже от температуры - ведь при изменении температуры физические тела сжимаются или расширяются, в том числе кристаллы кварца.

Для этого придумали системы термостабилизации (изолированная коробка со стабильной температурой) и термокомпенсации (добавление или удаление “тиков” по таблицам зависимости от текущей температуры).

Поэтому, при выборе варианта RTC вместо, например, DS1301 (простые часы с обычным “часовым” резонатором) лучше выбирать DS3231, где кристалл встроен в микросхему, и имеется готовая система термокомпенсации. У такого модуля стабильность хода намного выше, а значит, что и время он будет держать точнее, даже без синхронизации.

И снова - та же проблема что с GPS (дискретность - 1 секунда), и подобное же решение: если у модуля есть выход SQ - его можно настроить на выдачу сигнала в 1 Гц, и по фронту импульса ловить микросекунды через прерывание, так как импульс жестко привязан к счетчику секунд.
Останется настроить еще установку времени в RTC, так, чтобы секунды устанавливались максимально близко к реальным, с учетом задержек на процесс записи времени в чип.

#include <RTClib.h>
#include <JbTime.h>

/* add to main.ino

void RTCSetup();
bool RTCSetTime(JbTime * time);
bool RTCGetTime(JbTime * time);
float RTCGetTemperature();
void RTCLoop(JbTime * time);

*/

// information
#define P_SDA         4
#define P_SCL         5

#define PIN_SQ        12

#define RTC_MIN_YEAR  2026

RTC_DS3231 rtc;

volatile uint32_t sqMicros = 0;
bool rtcOk = false;

#define RTC_WRITE_DELAY_MAX   30000
#define RTC_WRITE_DELAY_MIN   28000


// -----------------------------------------------
float RTCGetTemperature(){
  if(!rtcOk) return -99;
  return rtc.getTemperature();
}

// -----------------------------------------------
bool RTCGetTime(JbTime * time){
  if(!rtcOk) return false;

  if( rtc.lostPower() ) return false;

  DateTime now = rtc.now();
  if(now.year() < RTC_MIN_YEAR) return false;

  unsigned long sec = now.unixtime();

  uint32_t nowMicros = micros();
  uint32_t usec = nowMicros - sqMicros;

  time->settime(sec,usec);

  return true;
}

// -----------------------------------------------

void ICACHE_RAM_ATTR onSQ() {
    sqMicros = micros();        // фиксируем момент импульса
}

// -----------------------------------------------
void RTCSetup() {
  sqMicros = 0;
  Wire.begin();
  rtcOk = rtc.begin();

  if(rtcOk){

    rtc.writeSqwPinMode(DS3231_SquareWave1Hz);

    pinMode(PIN_SQ, INPUT);
    attachInterrupt(digitalPinToInterrupt(PIN_SQ), onSQ, FALLING);

    delay(3000);
  }

}

// -----------------------------------------------
bool RTCSetTime(JbTime * time){
  if(!rtcOk) return false;

  uint64_t sec ;
  uint32_t usec ;
  time->gettime(&sec, &usec);

  // left to next second
  uint32_t x = 1000000 - usec;
  if (x < RTC_WRITE_DELAY_MIN) return false;

  while(x > RTC_WRITE_DELAY_MAX){
    delay(1);
    x -= 1000;
  }

  if(x > RTC_WRITE_DELAY_MIN && x < RTC_WRITE_DELAY_MAX){

    sec ++;

    rtc.adjust(DateTime(sec));
    return true;
  }

  return false;
}

// -----------------------------------------------
void RTCLoop(JbTime * time){
  if(!rtcOk) return ;
  if(time->fresh){
    if(RTCSetTime(time)){
      time->fresh = false;
    }
  }
  else if(time->old()){
    if(RTCGetTime(time)){
      // nothing
    }
  }
}

Ну и подключить это все в основном коде:

#include <JbTime.h>

void WifiSetup();
void WifiLoop();
String WifiIP();
// ---------------------------


void MqttSetup();
void MqttLoop();
void MqttPublish(const char * str);
void MqttPublish(const char * topic, const char * str);
void MqttPublishBin(const char * topic, byte * data, int len);
// ---------------------------

void RTCSetup();
bool RTCSetTime(JbTime * time);
bool RTCGetTime(JbTime * time);
float RTCGetTemperature();
void RTCLoop(JbTime * time);
// ---------------------------

void GPSSetup();
bool GPSGetTime(JbTime * time);
void GPSLoop();
// ---------------------------

#define IND     2

JbTime systime;
JbTime rtctime;
JbTime gpstime;

JbTime ntptime1;
JbTime ntptime2;

#include <JbNTP.h>
JbNTPClient ntp(&systime);

#define NTP_PERIOD 3700000
unsigned long ntp_timer;

void NTPSetup(){
  ntp.begin();
  ntp_timer = 0;
}

void NTPLoop(){
  ntp.serve();
}

void sync_time() {

  // try to get GPS time
  GPSGetTime(&gpstime);
  if(gpstime.ok && !gpstime.old()){
    systime.copy(&gpstime);
    systime.fresh = true;
  }

  // try to get NTP time
  bool var1 = ntp.requestTime("xxxxxxxxx",&ntptime1);
  bool var2 = ntp.requestTime("yyyyyyyyy",&ntptime2);

  if((var1 || var2) && !systime.fresh){

    uint64_t ntp1_sec = 0;
    uint32_t ntp1_usec = 0;
    ntptime1.gettime(&ntp1_sec, &ntp1_usec);
    uint64_t ntp2_sec = 0;
    uint32_t ntp2_usec = 0;
    ntptime2.gettime(&ntp2_sec, &ntp2_usec);

    if(ntp1_sec == ntp2_sec){
      uint32_t mid = (ntp1_usec + ntp2_usec ) >> 1;
      systime.settime(ntp1_sec, mid);
      systime.fresh = true;
    }else{
      if(var1 && !var2){
        systime.settime(ntp1_sec, ntp1_usec);
        systime.fresh = true;
      }
      if(!var1 && var2){
        systime.settime(ntp2_sec, ntp2_usec);
        systime.fresh = true;
      }
    }

  }

  if(systime.fresh){
    if(RTCSetTime(&systime)){
      systime.fresh = false;
    }
  }

}

// =====================================
#include <ArduinoJson.h>

unsigned long publish_timer = 0;
#define PUBLISH_PERIOD  60000

#define PUBLISH_LENGTH 300


void Publish(){

  DynamicJsonDocument  doc(PUBLISH_LENGTH);
 char buf[30];

  float t = RTCGetTemperature();
  doc["t"] = t;

  uint64_t sec = 0;
  uint32_t usec = 0;
  // ---------------
  if(RTCGetTime(&rtctime)){
    rtctime.gettime(&sec, &usec);
    sprintf(buf,"%llu.%06u",sec,usec);
    doc["rtc"]  = buf;
  }else{
    doc["rtc"]  = "none";
  }

  // ---------------
  GPSGetTime(&gpstime);
  if(gpstime.ok && !gpstime.old()){
    gpstime.gettime(&sec, &usec);
    sprintf(buf,"%llu.%06u",sec,usec);
    doc["gps"]  = buf;
  }else{
    doc["gps"]  = "none";
  }

  // ---------------
  if(ntptime1.ok){
    ntptime1.gettime(&sec, &usec);
    sprintf(buf,"%llu.%06u",sec,usec);
    doc["ntp1"]  = buf;
  }else{
    doc["ntp1"]  = "none";
  }

  if(ntptime2.ok){
    ntptime2.gettime(&sec, &usec);
    sprintf(buf,"%llu.%06u",sec,usec);
    doc["ntp2"]  = buf;
  }else{
    doc["ntp2"]  = "none";
  }
    // ---------------
  if(systime.ok){
    systime.gettime(&sec, &usec);
    sprintf(buf,"%llu.%06u",sec,usec);
    doc["sys"]  = buf;
  }else{
    doc["sys"] = "none";
  }

  // ---------------
  doc["ip"] = WifiIP();

  // ---------------

  String message;
  serializeJson(doc, message);

  MqttPublish(message.c_str());
}

void msg_callback(char* topic, byte* payload, unsigned int length) {

  if(!strncmp((char *)payload, "reset", length)){
    ESP.reset();
  }
  if(!strncmp((char *)payload, "sync", length)){
    sync_time();
  }

}
//==========================================


void setup() {

  Serial.begin(9600);
  pinMode(IND,OUTPUT);

  WifiSetup();

  MqttSetup();

  RTCSetup();

  NTPSetup();

  GPSSetup();

}

void loop() {

  WifiLoop();

  MqttLoop();

  NTPLoop();

  GPSLoop();

  RTCLoop(&systime);

  if( (millis() - publish_timer) > PUBLISH_PERIOD ){
    publish_timer = millis();
    Publish();
    digitalWrite(IND,LOW);
    delay(50);
    digitalWrite(IND,HIGH);

  }

  if( (millis() - ntp_timer) > NTP_PERIOD || ntp_timer == 0 ){
    ntp_timer = millis();
    sync_time();
  }

  delay(100);
}

Теперь при старте девайс берет время из RTC, пытается синхронизироваться по нескольким NTP-серверам, по GPS, при успехе обновляет RTC, и сам по нему сверяется время от времени. Ну и сам работает NTP-сервером.

Можно было бы еще приделать к нему экранчик и выводить текущее время - но по идее он должен висеть где-то на стене в маленькой коробке и не мозолить глаза, так что именно как часы его использовать не будем. В конце концов ничего теперь не мешает сделать отдельные часы, синхронизирующиеся уже с ним.