Так как в умном доме стало существовать довольно таки много устройств, которые требуют источника времени для записи логов, или выполнения задач по точному расписанию, возникла необходимость поднятия локального сервера времени. Вы спросите — а в чем проблема? Поднял 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. Заходим по пути Файл -> Настройки, добавляем в «Дополнительные ссылки для Менеджера плат»
3. Заходим по пути Скетч -> Подключить библиотеку -> Управлять библиотеками (или просто нажимаем сочетание Ctrl + Shift + I)
Устанавливаем библиотеки:
ESPAsyncTCP (dvarrel)
AsyncTCP (dvarrel)
ESPAsyncWebServer (lacamera)
ArduinoJson (Benoit Blanchon)
По идее, все остальные библиотеки должны стоять по умолчанию, но если будет при компиляции ругаться, смотрим в скетч, и добавляем по аналогии руками.
4. Заходим в Инструменты -> Плата -> Менеджер плат
Ищем ESP8266 Community (последняя версия), устанавливаем.
После установки идем по пути Инструменты -> Плата -> ESP8266 Boards -> выбираем из списка LOLIN(WEMOS) D1 R2 & mini
5. Не забываем выбрать COM порт на котором определилось наше устройство Инструменты -> Порт
На этом все подготовительные процессы можно считать завершенными, переходим к прошивке.
Добавляем наш скетч:
#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<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<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<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<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 или разряде батареи).
Возможно Вам пригодится шпаргалка с командами:
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
На этом все, надеюсь если у вас в подобном инструменте возникнет нужда и вы им воспользуетесь, я буду рад, что помог сэкономить вам время и силы.