Окт 162026
 

Home AssistantТак как в умном доме стало существовать довольно таки много устройств, которые требуют источника времени для записи логов, или выполнения задач по точному расписанию, возникла необходимость поднятия локального сервера времени. Вы спросите — а в чем проблема? Поднял NTP сервер на роутере или где нибудь в сети на виртуалке установил сервер времени и будет тебе счастье. Ну да, частично это решает задачу, до той поры пока интернет есть, и доступ к синхронизации с внешним сервером стабилен. Но вот незадача, а если интернет отпадает на долгий промежуток времени, стратум локального сервера упал до 16, и ваше устройство сбрендило и убежало по времени куда то в будущее и скрипт запуска вашего газового котла перестал запускаться вовремя и в зимнее время дом превратился в заледеневшую тыкву, а вы бегаете по стенам где то за тысячи километров, ничего сделать при этом не можете? Ы? И конечно у меня в голове появилась мысль, сделать автономный источник времени на аппаратных часах, которые гарантированно будут работать очень долгий промежуток времени, даже если доступа в интернет не будет месяцами.

Перейдем теперь от идеи к реализации.
Для этого нам понадобится стандартные компоненты для самоделок, немного паяльника и терпения.
Аппаратная составляющая:
1. Wemos-D1 (на ESP8266)
2. DS3221 (не самые точные аппаратные часы в мире, но для наших задач за глаза)

На Wemos D1 будем использовать стандартные пины для подключения подобных устройств D1 и D2.
По итогу нам нужно соединить:
1. PIN D1 устройства Wemos-D1 с PIN С устройства DS3221
2. PIN D2 устройства Wemos-D1 с PIN D устройства DS3221
3. PIN G (Ground) устройства Wemos-D1 с PIN — (Ground) устройства DS3221
4. PIN 3v3 (3V выход, можно запитаться и от 5V, но все рекомендуют именно первый вариант) устройства Wemos-D1 с PIN + устройства DS3221

Это была самая сложная часть процесса, остальное уже мной сделано для Вас, так что следуем инструкции и будет все хорошо 🙂
Далее нам понадобится установленный стабильный пакет Arduino 1.8.19 , провод подключения Type-C, и немного библиотек.
1. Запускаем Arduino
2. Заходим по пути Файл -> Настройки, добавляем в «Дополнительные ссылки для Менеджера плат»

http://arduino.esp8266.com/stable/package_esp8266com_index.json

3. Заходим по пути Скетч -> Подключить библиотеку -> Управлять библиотеками (или просто нажимаем сочетание Ctrl + Shift + I)
Устанавливаем библиотеки:

RTClib (Adafruit)
ESPAsyncTCP (dvarrel)
AsyncTCP (dvarrel)
ESPAsyncWebServer (lacamera)
ArduinoJson (Benoit Blanchon)

По идее, все остальные библиотеки должны стоять по умолчанию, но если будет при компиляции ругаться, смотрим в скетч, и добавляем по аналогии руками.
4. Заходим в Инструменты -> Плата -> Менеджер плат
Ищем ESP8266 Community (последняя версия), устанавливаем.
После установки идем по пути Инструменты -> Плата -> ESP8266 Boards -> выбираем из списка LOLIN(WEMOS) D1 R2 & mini
5. Не забываем выбрать COM порт на котором определилось наше устройство Инструменты -> Порт
На этом все подготовительные процессы можно считать завершенными, переходим к прошивке.
Добавляем наш скетч:

#include <ESP8266WiFi.h>
#include <ESPAsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <WiFiUdp.h>
#include <Wire.h>
#include <RTClib.h>
#include <LittleFS.h>
#include <ArduinoJson.h>

// Network Ports
const int NTP_PORT = 123;
AsyncWebServer webServer(80);
WiFiUDP ntpUDP;

// Hardware RTC
RTC_DS3231 rtc;
bool rtcPresent = false;
bool rtcBatteryOk = true;

// Configuration Structure
struct Config {
  char wifi_ssid[32] = "";
  char wifi_pass[64] = "";
  bool dhcp = true;
  char ip[16] = "192.168.1.100";
  char gateway[16] = "192.168.1.1";
  char subnet[16] = "255.255.255.0";
  char dns[16] = "192.168.1.1";
 
  char ntp1[64] = "pool.ntp.org";
  char ntp2[64] = "time.nist.gov";
  char ntp3[64] = "time.google.com";
  uint32_t sync_interval = 3600; // In seconds
  int timezone_offset = 3;        // UTC+3 (supports negative offsets)
} config;

// Global State Tracking
struct Status {
  bool last_sync_success = false;
  String current_active_ntp = "N/A";
  uint8_t upstream_stratum = 16;
  uint8_t assigned_stratum = 16;
  uint8_t local_stratum = 16;    
  uint32_t last_sync_time = 0;  
  uint16_t sync_ntp_ms = 0;      
  uint16_t sync_rtc_ms = 0;      
  uint32_t sync_rtc_epoch = 0;  
  uint32_t last_sync_millis = 0;
  String last_error = "Not synced yet";
} statusState;

byte packetBuffer[48];

void saveConfig() {
  StaticJsonDocument&lt;1024> doc;
  doc["wifi_ssid"] = config.wifi_ssid;
  doc["wifi_pass"] = config.wifi_pass;
  doc["dhcp"] = config.dhcp;
  doc["ip"] = config.ip;
  doc["gateway"] = config.gateway;
  doc["subnet"] = config.subnet;
  doc["dns"] = config.dns;
  doc["ntp1"] = config.ntp1;
  doc["ntp2"] = config.ntp2;
  doc["ntp3"] = config.ntp3;
  doc["sync_interval"] = config.sync_interval;
  doc["timezone_offset"] = config.timezone_offset;

  File file = LittleFS.open("/config.json", "w");
  if (file) {
    serializeJson(doc, file);
    file.close();
  }
}

void loadConfig() {
  if (!LittleFS.exists("/config.json")) return;
  File file = LittleFS.open("/config.json", "r");
  if (!file) return;

  StaticJsonDocument&lt;1024> doc;
  DeserializationError error = deserializeJson(doc, file);
  file.close();
  if (error) return;

  strlcpy(config.wifi_ssid, doc["wifi_ssid"] | "", sizeof(config.wifi_ssid));
  strlcpy(config.wifi_pass, doc["wifi_pass"] | "", sizeof(config.wifi_pass));
  config.dhcp = doc["dhcp"] | true;
  strlcpy(config.ip, doc["ip"] | "192.168.1.100", sizeof(config.ip));
  strlcpy(config.gateway, doc["gateway"] | "192.168.1.1", sizeof(config.gateway));
  strlcpy(config.subnet, doc["subnet"] | "255.255.255.0", sizeof(config.subnet));
  strlcpy(config.dns, doc["dns"] | "192.168.1.1", sizeof(config.dns));
  strlcpy(config.ntp1, doc["ntp1"] | "pool.ntp.org", sizeof(config.ntp1));
  strlcpy(config.ntp2, doc["ntp2"] | "time.nist.gov", sizeof(config.ntp2));
  strlcpy(config.ntp3, doc["ntp3"] | "time.google.com", sizeof(config.ntp3));
  config.sync_interval = doc["sync_interval"] | 3600;
  if (config.sync_interval < 10) config.sync_interval = 10;
 
  if (doc.containsKey("timezone_offset")) {
    config.timezone_offset = doc["timezone_offset"].as<int>();
  } else {
    config.timezone_offset = 3;
  }
}

void checkRTCHealth() {
  Wire.beginTransmission(0x68);
  if (Wire.endTransmission() == 0) {
    if (!rtcPresent) {
      rtcPresent = rtc.begin();
    }
    if (rtcPresent) {
      rtcBatteryOk = !rtc.lostPower();
    } else {
      rtcBatteryOk = false;
    }
  } else {
    rtcPresent = false;
    rtcBatteryOk = false;
  }
}

void updateLocalStratum() {
  if (statusState.last_sync_success) {
    statusState.local_stratum = statusState.assigned_stratum;
  } else {
    statusState.upstream_stratum = 16;
    statusState.current_active_ntp = "N/A";
   
    if (rtcPresent && rtcBatteryOk) {
      statusState.local_stratum = 2;
    } else {
      statusState.local_stratum = 16;
    }
  }
}

bool queryNTPHost(const char* host, uint32_t& outEpoch, uint16_t& outMs, uint8_t& outStratum) {
  IPAddress timeServerIP;
  if (!WiFi.hostByName(host, timeServerIP)) return false;

  memset(packetBuffer, 0, 48);
  packetBuffer[0] = 0b11100011;
 
  if (!ntpUDP.beginPacket(timeServerIP, 123)) return false;
  ntpUDP.write(packetBuffer, 48);
  if (!ntpUDP.endPacket()) return false;

  uint32_t startMs = millis();
  while (millis() - startMs < 2000) {
    int size = ntpUDP.parsePacket();
    if (size >= 48) {
      uint32_t respMs = millis();
      ntpUDP.read(packetBuffer, 48);
     
      outStratum = packetBuffer[1];
     
      uint32_t highWord = word(packetBuffer[40], packetBuffer[41]);
      uint32_t lowWord = word(packetBuffer[42], packetBuffer[43]);
      uint32_t secsSince1900 = (highWord << 16) | lowWord;

      uint32_t fraction = ((uint32_t)packetBuffer[44] << 24) |
                          ((uint32_t)packetBuffer[45] << 16) |
                          ((uint32_t)packetBuffer[46] << 8)  |
                          ((uint32_t)packetBuffer[47]);
     
      // Strict RFC validation: Stratum MUST be between 1 and 14
      if (secsSince1900 > 2208988800UL && outStratum >= 1 && outStratum <= 14) {
        outEpoch = secsSince1900 - 2208988800UL;
        outMs = (uint16_t)(((uint64_t)fraction * 1000) >> 32);
       
        outMs += (millis() - respMs);
        if (outMs >= 1000) {
          outEpoch += outMs / 1000;
          outMs %= 1000;
        }
        return true;
      }
    }
    delay(10);
  }
  return false;
}

void syncWithExternalNTP() {
  statusState.last_sync_success = false;

  if (WiFi.status() != WL_CONNECTED) {
    statusState.last_error = "Network unavailable";
    statusState.last_sync_time = 0;
    statusState.sync_rtc_epoch = 0;
    updateLocalStratum();
    return;
  }

  const char* servers[3] = { config.ntp1, config.ntp2, config.ntp3 };
  uint32_t epoch = 0;
  uint16_t ntpMs = 0;
  uint8_t stratum = 16;
  bool success = false;

  for (int i = 0; i < 3; i++) {
    if (strlen(servers[i]) == 0) continue;
    if (queryNTPHost(servers[i], epoch, ntpMs, stratum)) {
      success = true;
      statusState.current_active_ntp = String(servers[i]);
      statusState.upstream_stratum = stratum;
      statusState.assigned_stratum = (stratum < 15) ? (stratum + 1) : 16;
      break;
    }
  }

  if (success) {
    statusState.last_sync_success = true;
    statusState.last_sync_time = epoch;
    statusState.sync_ntp_ms = ntpMs;
    statusState.last_sync_millis = millis();
    statusState.last_error = "None";

    checkRTCHealth();
    if (rtcPresent) {
      DateTime rtcNow = rtc.now();
      statusState.sync_rtc_epoch = rtcNow.unixtime();
      statusState.sync_rtc_ms = statusState.sync_ntp_ms;

      int32_t diff = abs((int32_t)(epoch - statusState.sync_rtc_epoch));
      if (diff > 1 || !rtcBatteryOk) {
        rtc.adjust(DateTime(epoch));
        rtcBatteryOk = true;
      }
    }
  } else {
    statusState.last_sync_success = false;
    statusState.last_sync_time = 0;
    statusState.sync_rtc_epoch = 0;
    statusState.last_error = "NTP servers unreachable";
  }

  updateLocalStratum();
}

uint32_t getCurrentEpoch() {
  checkRTCHealth();
  if (rtcPresent && rtcBatteryOk) {
    return rtc.now().unixtime();
  }
  if (statusState.last_sync_time > 0) {
    return statusState.last_sync_time + ((millis() - statusState.last_sync_millis) / 1000);
  }
  return 0;
}

void processNTPRequests() {
  int cb = ntpUDP.parsePacket();
  if (!cb) return;

  IPAddress remoteIP = ntpUDP.remoteIP();
  uint16_t remotePort = ntpUDP.remotePort();
 
  ntpUDP.read(packetBuffer, 48);

  uint32_t epoch = getCurrentEpoch();
  if (epoch == 0) return;

  uint32_t ntpTime = epoch + 2208988800UL;

  byte origNTP[8];
  memcpy(origNTP, &packetBuffer[40], 8);

  memset(packetBuffer, 0, 48);

  packetBuffer[0] = 0b00100100; // LI=0, Ver=4, Mode=4 (Server)
  packetBuffer[1] = statusState.local_stratum;
  packetBuffer[2] = 6;
  packetBuffer[3] = 0xEE;

  packetBuffer[7] = 0x10;
  packetBuffer[10] = 0x0A;

  packetBuffer[12] = 'L'; packetBuffer[13] = 'O';
  packetBuffer[14] = 'C'; packetBuffer[15] = 'L';

  packetBuffer[16] = (ntpTime >> 24) & 0xFF;
  packetBuffer[17] = (ntpTime >> 16) & 0xFF;
  packetBuffer[18] = (ntpTime >> 8) & 0xFF;
  packetBuffer[19] = ntpTime & 0xFF;

  memcpy(&packetBuffer[24], origNTP, 8);

  packetBuffer[32] = (ntpTime >> 24) & 0xFF;
  packetBuffer[33] = (ntpTime >> 16) & 0xFF;
  packetBuffer[34] = (ntpTime >> 8) & 0xFF;
  packetBuffer[35] = ntpTime & 0xFF;

  packetBuffer[40] = (ntpTime >> 24) & 0xFF;
  packetBuffer[41] = (ntpTime >> 16) & 0xFF;
  packetBuffer[42] = (ntpTime >> 8) & 0xFF;
  packetBuffer[43] = ntpTime & 0xFF;

  ntpUDP.beginPacket(remoteIP, remotePort);
  ntpUDP.write(packetBuffer, 48);
  ntpUDP.endPacket();
}

const char INDEX_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html><head><meta charset="
UTF-8"/>
<meta name="
viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="
icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'/><circle cx='50' cy='50' r='45' fill='%23007bff'></circle><circle cx='50' cy='50' r='38' fill='white'></circle><path d='M50 20 V50 L70 50' stroke='%23007bff' stroke-width='8' stroke-linecap='round' fill='none'></path>">
<title>ESP TimeServer</title>
<style>
  body { font-family: Arial, sans-serif; margin: 0; padding: 10px; background: #f0f2f5; font-size: 13px; color: #333; }
  .container { max-width: 650px; margin: 0 auto; position: relative; }
  .version-tag { position: absolute; top: 0; right: 0; font-size: 11px; color: #888; font-weight: bold; }
  h1 { font-size: 18px; text-align: center; margin: 5px 0 10px 0; color: #222; }
  .card { background: white; padding: 12px 15px; border-radius: 6px; margin-bottom: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
  h2 { font-size: 14px; margin: 0 0 8px 0; color: #007bff; border-bottom: 1px solid #eee; padding-bottom: 4px; }
  .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
  .p-row { margin: 3px 0; display: flex; justify-content: space-between; }
  .p-label { font-weight: bold; color: #555; }
  label { display: block; margin-top: 6px; font-weight: bold; font-size: 12px; }
  input[type="
text"], input[type="number"], input[type="password"] { width: 100%; padding: 5px 8px; box-sizing: border-box; margin-top: 2px; font-size: 12px; border: 1px solid #ccc; border-radius: 3px; }
  input[readonly] { background-color: #e9ecef; cursor: not-allowed; color: #495057; }
  .checkbox-container { margin-top: 8px; font-weight: bold; font-size: 12px; }
  .btn-container { text-align: right; margin-top: 10px; }
  button { background: #007bff; color: white; border: none; padding: 7px 18px; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: bold; }
  button:hover { background: #0056b3; }
  .status-ok { color: green; font-weight: bold; }
  .status-err { color: red; font-weight: bold; }
</style>
</head><body>
  <div class="
container">
    <div class="
version-tag">v. 1.15</div>
    <h1>NTP Server Management</h1>
   
    <div class="
card">
      <h2>System Status</h2>
      <div class="
p-row"><span class="p-label">DS3231 RTC:</span><span id="rtc_status">...</span></div>
      <div class="
p-row"><span class="p-label">DS3231 Battery:</span><span id="rtc_battery">...</span></div>
      <div class="
p-row"><span class="p-label">DS3231 Time (Current):</span><span id="rtc_time">...</span></div>
      <div class="
p-row"><span class="p-label">Active Upstream NTP:</span><span id="active_ntp">...</span></div>
      <div class="
p-row"><span class="p-label">Stratum (Upstream / Local):</span><span id="stratum">...</span></div>
      <div class="
p-row"><span class="p-label">Upstream NTP Time at Sync (UTC):</span><span id="sync_ntp_time">...</span></div>
      <div class="
p-row"><span class="p-label">DS3231 Time at Sync (UTC):</span><span id="sync_rtc_time">...</span></div>
      <div class="
p-row"><span class="p-label">Status Sync:</span><span id="sync_status">...</span></div>
    </div>
   
    <form id="
configForm">
      <div class="
card">
        <h2>Wi-Fi & Network Settings</h2>
        <label>Device MAC Address:</label><input type="
text" id="mac" readonly/>
        <div class="
grid">
          <div><label>Wi-Fi SSID:</label><input type="
text" name="ssid" id="ssid"/></div>
          <div><label>Wi-Fi Password:</label><input type="
password" name="pass" id="pass"/></div>
        </div>
        <div class="
checkbox-container">
          <label><input type="
checkbox" name="dhcp" id="dhcp" onchange="toggleIPFields()"/> Use DHCP</label>
        </div>
        <div id="
ip_fields">
          <div class="
grid">
            <div><label>Static IP:</label><input type="
text" name="ip" id="ip"/></div>
            <div><label>Gateway:</label><input type="
text" name="gateway" id="gateway"/></div>
            <div><label>Subnet:</label><input type="
text" name="subnet" id="subnet"/></div>
            <div><label>DNS:</label><input type="
text" name="dns" id="dns"/></div>
          </div>
        </div>
      </div>

      <div class="
card">
        <h2>NTP & Timezone Settings</h2>
        <div class="
grid">
          <div><label>NTP Server 1:</label><input type="
text" name="ntp1" id="ntp1"/></div>
          <div><label>NTP Server 2:</label><input type="
text" name="ntp2" id="ntp2"/></div>
        </div>
        <div class="
grid">
          <div><label>NTP Server 3:</label><input type="
text" name="ntp3" id="ntp3"/></div>
          <div><label>Sync Interval (sec):</label><input type="
number" name="sync_interval" id="sync_interval" min="10"/></div>
        </div>
        <div><label>Timezone Offset (UTC hours):</label><input type="
number" name="tz" id="tz" min="-12" max="14"/></div>
       
        <div class="
btn-container">
          <button type="
button" onclick="saveAndReboot()">Save & Reboot</button>
        </div>
      </div>
    </form>
  </div>

<script>
function updateStatus() {
  fetch('/api/status').then(r => r.json()).then(data => {
    document.getElementById('rtc_status').innerText = data.rtc_ok ? "
CONNECTED" : "DISCONNECTED";
    document.getElementById('rtc_status').className = data.rtc_ok ? "
status-ok" : "status-err";
   
    document.getElementById('rtc_battery').innerText = data.rtc_bat_ok ? "
OK" : "ERROR";
    document.getElementById('rtc_battery').className = data.rtc_bat_ok ? "
status-ok" : "status-err";

    document.getElementById('rtc_time').innerText = data.rtc_time;
    document.getElementById('active_ntp').innerText = data.active_ntp;
    document.getElementById('stratum').innerText = data.upstream_stratum + "
/ " + data.local_stratum;
    document.getElementById('sync_ntp_time').innerText = data.sync_ntp_time;
    document.getElementById('sync_rtc_time').innerText = data.sync_rtc_time;
    document.getElementById('sync_status').innerText = data.sync_ok ? "
OK" : data.last_error;
    document.getElementById('sync_status').className = data.sync_ok ? "
status-ok" : "status-err";
  }).catch(e => console.log(e));
}

function loadConfig() {
  fetch('/api/config').then(r => r.json()).then(data => {
    document.getElementById('mac').value = data.mac;
    document.getElementById('ssid').value = data.ssid;
    document.getElementById('pass').value = data.pass;
    document.getElementById('dhcp').checked = data.dhcp;
    document.getElementById('ip').value = data.ip;
    document.getElementById('gateway').value = data.gateway;
    document.getElementById('subnet').value = data.subnet;
    document.getElementById('dns').value = data.dns;
    document.getElementById('ntp1').value = data.ntp1;
    document.getElementById('ntp2').value = data.ntp2;
    document.getElementById('ntp3').value = data.ntp3;
    document.getElementById('sync_interval').value = data.sync_interval;
    document.getElementById('tz').value = data.tz;
    toggleIPFields();
  }).catch(e => console.log(e));
}

function toggleIPFields() {
  document.getElementById('ip_fields').style.display = document.getElementById('dhcp').checked ? 'none' : 'block';
}

function saveAndReboot() {
  const form = document.getElementById('configForm');
  const formData = new FormData(form);
 
  document.body.innerHTML = '<div style="
font-family:Arial,sans-serif;background:#f0f2f5;height:100vh;display:flex;justify-content:center;align-items:center;"><div style="background:white;padding:30px;border-radius:8px;box-shadow:0 2px 4px rgba(0,0,0,0.1);text-align:center;"><h2>Settings Saved!</h2><p>Device is rebooting to apply changes.</p><p>Redirecting in <span id="sec">5</span> seconds...</p></div></div>';
 
  let s = 5;
  setInterval(() => {
    s--;
    if (s >= 0) {
      const el = document.getElementById('sec');
      if (el) el.innerText = s;
    }
    if (s === 0) window.location.href = "/";
  }, 1000);

  fetch('/save', {
    method: 'POST',
    body: formData
  }).catch(e => console.log(e));
}

setInterval(updateStatus, 60000);
loadConfig();
updateStatus();
</script>
</body></html>
)rawliteral";

void setupWebServer() {
  webServer.on("
/", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send_P(200, "
text/html", INDEX_HTML);
  });

  webServer.on("
/api/status", HTTP_GET, [](AsyncWebServerRequest *request){
    StaticJsonDocument&lt;768> doc;
    checkRTCHealth();
   
    doc["
rtc_ok"] = rtcPresent;
    doc["
rtc_bat_ok"] = rtcBatteryOk;
    doc["
active_ntp"] = statusState.current_active_ntp;
    doc["
upstream_stratum"] = statusState.upstream_stratum;
    doc["
local_stratum"] = statusState.local_stratum;
    doc["
sync_ok"] = statusState.last_sync_success;
    doc["
last_error"] = statusState.last_error;
   
    if (rtcPresent && rtcBatteryOk) {
      DateTime rtcDt(rtc.now().unixtime() + config.timezone_offset * 3600);
      char rtcBuf[32];
      snprintf(rtcBuf, sizeof(rtcBuf), "
%02d.%02d.%04d %02d:%02d",
               rtcDt.day(), rtcDt.month(), rtcDt.year(),
               rtcDt.hour(), rtcDt.minute());
      doc["
rtc_time"] = rtcBuf;
    } else {
      doc["
rtc_time"] = "N/A";
    }

    if (statusState.last_sync_success && statusState.last_sync_time > 0) {
      DateTime dt(statusState.last_sync_time);
      char buf[32];
      snprintf(buf, sizeof(buf), "
%02d.%02d.%04d %02d:%02d:%02d",
               dt.day(), dt.month(), dt.year(), dt.hour(), dt.minute(), dt.second());
      doc["
sync_ntp_time"] = buf;
    } else {
      doc["
sync_ntp_time"] = "N/A";
    }

    if (statusState.last_sync_success && statusState.sync_rtc_epoch > 0 && rtcPresent) {
      DateTime dtRtc(statusState.sync_rtc_epoch);
      char bufRtc[32];
      snprintf(bufRtc, sizeof(bufRtc), "
%02d.%02d.%04d %02d:%02d:%02d",
               dtRtc.day(), dtRtc.month(), dtRtc.year(), dtRtc.hour(), dtRtc.minute(), dtRtc.second());
      doc["
sync_rtc_time"] = bufRtc;
    } else {
      doc["
sync_rtc_time"] = "N/A";
    }

    String json;
    serializeJson(doc, json);
    request->send(200, "
application/json", json);
  });

  webServer.on("
/api/config", HTTP_GET, [](AsyncWebServerRequest *request){
    StaticJsonDocument&lt;512> doc;
    doc["
mac"] = WiFi.macAddress();
    doc["
ssid"] = config.wifi_ssid;
    doc["
pass"] = config.wifi_pass;
    doc["
dhcp"] = config.dhcp;
    doc["
ip"] = config.ip;
    doc["
gateway"] = config.gateway;
    doc["
subnet"] = config.subnet;
    doc["
dns"] = config.dns;
    doc["
ntp1"] = config.ntp1;
    doc["
ntp2"] = config.ntp2;
    doc["
ntp3"] = config.ntp3;
    doc["
sync_interval"] = config.sync_interval;
    doc["
tz"] = config.timezone_offset;

    String json;
    serializeJson(doc, json);
    request->send(200, "
application/json", json);
  });

  webServer.on("
/save", HTTP_POST, [](AsyncWebServerRequest *request){
    if (request->hasParam("
ssid", true)) strlcpy(config.wifi_ssid, request->getParam("ssid", true)->value().c_str(), sizeof(config.wifi_ssid));
    if (request->hasParam("
pass", true)) strlcpy(config.wifi_pass, request->getParam("pass", true)->value().c_str(), sizeof(config.wifi_pass));
    config.dhcp = request->hasParam("
dhcp", true);
    if (request->hasParam("
ip", true)) strlcpy(config.ip, request->getParam("ip", true)->value().c_str(), sizeof(config.ip));
    if (request->hasParam("
gateway", true)) strlcpy(config.gateway, request->getParam("gateway", true)->value().c_str(), sizeof(config.gateway));
    if (request->hasParam("
subnet", true)) strlcpy(config.subnet, request->getParam("subnet", true)->value().c_str(), sizeof(config.subnet));
    if (request->hasParam("
dns", true)) strlcpy(config.dns, request->getParam("dns", true)->value().c_str(), sizeof(config.dns));
    if (request->hasParam("
ntp1", true)) strlcpy(config.ntp1, request->getParam("ntp1", true)->value().c_str(), sizeof(config.ntp1));
    if (request->hasParam("
ntp2", true)) strlcpy(config.ntp2, request->getParam("ntp2", true)->value().c_str(), sizeof(config.ntp2));
    if (request->hasParam("
ntp3", true)) strlcpy(config.ntp3, request->getParam("ntp3", true)->value().c_str(), sizeof(config.ntp3));
    if (request->hasParam("
sync_interval", true)) {
      uint32_t val = request->getParam("
sync_interval", true)->value().toInt();
      config.sync_interval = (val < 10) ? 10 : val;
    }
    if (request->hasParam("
tz", true)) config.timezone_offset = request->getParam("tz", true)->value().toInt();

    saveConfig();
    request->send(200, "
text/plain", "OK");

    delay(1000);
    ESP.restart();
  });

  webServer.begin();
}

void setup() {
  Serial.begin(115200);
  Wire.begin(4, 5); // SDA = GPIO4, SCL = GPIO5

  WiFi.persistent(false);
  WiFi.setSleepMode(WIFI_NONE_SLEEP);

  if (LittleFS.begin()) {
    loadConfig();
  }

  checkRTCHealth();

  if (strlen(config.wifi_ssid) == 0) {
    WiFi.mode(WIFI_AP);
    IPAddress apIP(192, 168, 4, 1);
    WiFi.softAPConfig(apIP, apIP, IPAddress(255, 255, 255, 0));
    WiFi.softAP("
ESP-TimeServer");
  } else {
    WiFi.mode(WIFI_STA);
    if (!config.dhcp) {
      IPAddress ip, gw, sn, dns;
      ip.fromString(config.ip);
      gw.fromString(config.gateway);
      sn.fromString(config.subnet);
      dns.fromString(config.dns);
      WiFi.config(ip, gw, sn, dns);
    }
    WiFi.begin(config.wifi_ssid, config.wifi_pass);

    int timeout = 0;
    while (WiFi.status() != WL_CONNECTED && timeout < 30) {
      delay(500);
      timeout++;
    }

    if (WiFi.status() != WL_CONNECTED) {
      WiFi.mode(WIFI_AP);
      IPAddress apIP(192, 168, 4, 1);
      WiFi.softAPConfig(apIP, apIP, IPAddress(255, 255, 255, 0));
      WiFi.softAP("
ESP-TimeServer-Fallback");
    }
  }

  setupWebServer();
  ntpUDP.begin(NTP_PORT);
 
  if (WiFi.status() == WL_CONNECTED) {
    syncWithExternalNTP();
  } else {
    updateLocalStratum();
  }
}

unsigned long lastSyncAttempt = 0;
unsigned long lastRTCCheck = 0;

void loop() {
  processNTPRequests();

  if (millis() - lastRTCCheck >= 30000UL) {
    lastRTCCheck = millis();
    checkRTCHealth();
    updateLocalStratum();
  }

  uint32_t intervalMs = statusState.last_sync_success ? (config.sync_interval * 1000) : 15000UL;
  if (millis() - lastSyncAttempt >= intervalMs) {
    lastSyncAttempt = millis();
    syncWithExternalNTP();
  }
}

После копирования скетча, запускаем заливку кода на Wemos-D1, тут я думаю никаких сложностей возникнуть не должно.
Как только произойдет автоматическая перезагрузка девайса, берем свой телефон или компьютер c модулем Wi-Fi, и ищем точку доступа ESP-TimeServer
После успешного подключения, в адресной строке браузера вводим IP адрес по умолчанию 192.168.4.1
Появится Web интерфейс управления, где указаны основные параметры:
Блок «System Status»
Поля в этом блоке обновляются автоматически каждые 60 секунд (или по запросу страницы).
DS3231 RTC:
Может принимать значение:
1. CONNECTED (зеленый) — аппаратный модуль часов реального времени DS3231 успешно найден и отвечает по шине I2C.
2. DISCONNECTED (красный) — модуль RTC не обнаружен или повреждена линия связи (SDA/SCL).
DS3231 Battery:
Может принимать значение:
1. OK (зеленый) — Батарейка CR2032 исправна, встроенный генератор DS3231 работает без сбоев.
2. ERROR (красный) — Батарейка разряжена или отсутствует, время RTC сбросилось и требует синхронизации.
DS3231 Time (Current):
Отображает текущую дату и время с модуля RTC с учетом настроенного смещения часового пояса (Timezone Offset).
Active Upstream NTP:
Имя хоста или IP-адрес того внешнего NTP-сервера, с которым прошла последняя успешная синхронизация. Если синхронизации не было, отображает N/A.
Stratum (Upstream / Local):
Показывает уровень страты: [Страта внешнего сервера] / [Локальная страта Wemos D1].Пример 3 / 4: Внешний сервер имеет Stratum 3, а наш Wemos D1 анонсирует локальной сети Stratum 4.Пример 16 / 2: Внешний сервер недоступен (16), но Wemos D1 работает в автономном режиме от RTC (зафиксирован Stratum 2).
Upstream NTP Time at Sync (UTC):
Точное время (в формате UTC), полученное от внешнего NTP-сервера в момент проведения последней успешной синхронизации.
DS3231 Time at Sync (UTC):
Время (в формате UTC), которое было записано в модуле DS3231 в момент синхронизации. Позволяет визуально оценить уход часов RTC до их подстройки.
Status Sync:
Может принимать значение:
1. OK (зеленый) — последняя попытка обращения к внешним NTP-серверам завершилась успешно.
2. NTP servers unreachable / Network unavailable (красный) — внешние сервера не ответили или отсутствует подключение к Wi-Fi.

Блок «Wi-Fi & Network Settings»
Device MAC Address:
Физический MAC-адрес Wi-Fi модуля ESP8266 (эта информация может вам пригодиться, если например вы хотите оставить настройку DHCP, но при этом выдавать один и тот же IP).
Wi-Fi SSID:
Имя вашей беспроводной Wi-Fi сети (2.4 ГГц), к которой подключается Wemos D1.
Wi-Fi Password:
Пароль от Wi-Fi сети.
Use DHCP:
Если включено, устройство получает IP-адрес, шлюз, маску и DNS автоматически от роутера. Поля статических IP-адресов при этом скрываются.
Если выключено, активируются поля для ручного ввода статических сетевых настроек.

Блок «NTP & Timezone Settings»
NTP Server 1 / 2 / 3:Доменные имена или IP-адреса вышестоящих внешних NTP-серверов (например, pool.ntp.org, time.nist.gov, ntp.ix.ru). Устройство опрашивает их поочередно до первого успешного ответа.
Sync Interval (sec):
Периодичность обращения к внешним NTP-серверам в секундах (например, 3600 — каждый час, 122 — каждые 2 минуты). Минимально допустимое значение — 10 секунд.
Timezone Offset (UTC hours):
Смещение часового пояса относительно UTC в часах (например, 3 для UTC+3, 5 для UTC+5 или -5 для EST). Влияет только на отображение времени в поле DS3231 Time (Current), для того, чтобы удобнее было смотреть текущее местное время и дату.

Так же решил зафиксировать основной функционал, для понимания масштаба происходящего.
RFC 5905-совместимый NTP-сервер:
Обрабатывает клиентские запросы по протоколу UDP (порт 123).
Использует идентификатор источника LOCL и формат 0-delay для совместимости с клиентами Windows (w32tm) и Linux (chrony, ntpd, systemd-timesyncd).

Двухуровневое резервирование времени:
Основной режим: Синхронизация с внешними upstream NTP-серверами (по умолчанию назначается Stratum N+1).
Автономный режим: При отсутствии интернета сервер мгновенно переключается на аппаратный модуль DS3231 и анонсирует локальным клиентам Stratum 2.

Веб-интерфейс управления и мониторинга:
Одностраничное веб-приложение (SPA) с AJAX-обновлением статуса в реальном времени.
Полная настройка сети: поддержка DHCP и статичного IP (IP, Gateway, Subnet, DNS).
Конфигурация трех внешних NTP-серверов, интервала синхронизации и часового пояса.

Энергонезависимое хранение:
Все сетевые и системные настройки сохраняются в файловой системе LittleFS (config.json).

Проверка валидности страты (RFC Filtering):
В функции queryNTPHost() принимаются только пакеты со Stratum от 1 до 14. Это исключает ложные синхронизации от сервисных пакетов роутеров со Stratum 0 (Kiss-o’-Death).

Мониторинг аппаратного RTC и батареи (OSF Flag):
Функция checkRTCHealth() каждые 30 секунд проверяет физическое присутствие DS3231 на шине I2C и состояние регистра сброса питания (rtc.lostPower()).
При разряде или отсутствии батарейки CR2032 выводится статус DS3231 Battery: ERROR, а локальная страта принудительно сбрасывается в 16 (Unsynchronized) для защиты локальных ПК от выдачи недостоверного времени.
При первой успешной внешней синхронизации вызывается rtc.adjust(), что обновляет время RTC и автоматически сбрасывает статус батареи в OK.
Защита от скомпрометированного сетевого окружения:

Динамический пересчет Stratum:
Автоматический fallback: Stratum N+1 (при внешней синхронизации)
→ Stratum 2 (автономно от RTC)
→ Stratum 16 (при сбое RTC или разряде батареи).

Возможно Вам пригодится шпаргалка с командами:

Опрос NTP-сервера для проверки доступности и измерения задержки/рассогласования времени.
w32tm /stripchart /computer:192.168.50.30 /samples:3 /dataonly
Настройка Windows на постоянную синхронизацию времени с вашим NTP-сервером.
w32tm /config /manualpeerlist:"192.168.50.30" /syncfromflags:manual /reliable:YES /update
Вывод текущего состояния службы времени Windows на данном ПК.
w32tm /query /status
Вывод списка всех настроенных узлов (NTP-серверов) и их статусов.
w32tm /query /peers

На этом все, надеюсь если у вас в подобном инструменте возникнет нужда и вы им воспользуетесь, я буду рад, что помог сэкономить вам время и силы.

 Leave a Reply

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

(required)

(required)