DIY: Ардуино и сервер точного времени

от автора

Мы уже привыкли жить в глобальном информационном мире, где, с одной стороны, довольно важно знать точное время, а с другой стороны — легко его получить, достаточно настроить на компьютере 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  600class JbTime {private:  uint64_t _last ;    // last change time  uint64_t _sec ;     // seconds  uint32_t _usec ;    // microseconds  uint32_t _mark ;    // mark uS for time updatepublic:  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   6class 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 DEBUGvoid MqttPublish(const char * str);#endif/* add to main.inovoid 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          200volatile 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   0x05void 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.inovoid 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  2026RTC_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     2JbTime systime;JbTime rtctime;JbTime gpstime;JbTime ntptime1;JbTime ntptime2;#include <JbNTP.h>JbNTPClient ntp(&systime);#define NTP_PERIOD 3700000unsigned 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 300void 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-сервером.

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

ссылка на оригинал статьи https://habr.com/ru/articles/1023414/