Полив на даче включался по «часам», которых у платы не было: скетч считал время сам, от millis(). Две недели всё работало, потом ночью моргнуло электричество, плата перезагрузилась — и в шесть утра, «по её внутреннему счёту полдень», включился насос. Клумба получила душ в шесть утра вместо шести вечера, а я получил урок: millis() — это тики от старта, а не время суток. Он не знает ни часа, ни даты, ни того, что было до перезагрузки.
Точное время ESP32 берёт из интернета по протоколу NTP — без единого модуля и лишнего провода. В этом уроке подключаем плату к серверам точного времени вызовом configTime(), разбираемся, что такое epoch и почему в одном её числе сидит вся дата сразу, переводим unix-время в часы и минуты обычным делением с остатком — и проделываем ту же математику в симуляторе на этой странице. Сам GET-запрос к серверу мы собирали в уроке про HTTP-запросы — здесь он понадобится как транспорт для ответа с меткой времени.
NTP: секунда из интернета
NTP (Network Time Protocol) — протокол синхронизации часов по сети: клиент спрашивает сервер «который час», тот отвечает меткой времени, а клиент правит свои часы с учётом задержек в пути. Транспорт — UDP, порт 123, пакет всего 48 байт, точность по обычному домашнему интернету — десятки миллисекунд. Для включения полива, будильников и меток в логах этого хватает с огромным запасом.
Метка времени, которой обмениваются клиент и сервер, — это epoch, она же unix-время: целое число секунд, прошедших с полуночи 1 января 1970 года по UTC. Из одного числа достаётся всё: и дата, и день недели, и время — делением. Один день — ровно 86400 секунд, час — 3600, минута — 60; никакой календарной магии в epoch нет, она появляется на этапе перевода, и это отличная новость: переводить её можно на бумажке, пальцем и в симуляторе.
Серверы выбирают из публичных пулов: pool.ntp.org раздаёт адреса сотен машин по всему миру, есть и локальные зеркала вроде ru.pool.ntp.org. У пулов есть этикет: дёргать их чаще пары раз в час не стоит, поэтому стек ESP32 после первой синхронизации повторяет её сам раз в час — configTime настраивает этот фоновый процесс один раз, и дальше он живёт своей жизнью, без единой строки в loop.
Любопытно, как NTP выжимает точность из нестабильного канала: запрос и ответ таскают четыре метки времени — когда запрос ушёл, когда пришёл, когда ушёл ответ и когда его встретил клиент. Из четырёх чисел клиент вычисляет и сетевую задержку, и поправку часов, поэтому десятки миллисекунд точности реальны даже на домашнем канале, где пинг гуляет на десятки миллисекунд. Нам в это лезть не нужно — стек платы делает всё сам, но уважение к протоколу появляется.
Бонусная математика для журналов: день недели из epoch достаётся тем же делением. Полных суток с 1970 года — epoch / 86400; 1 января 1970 был четверг, поэтому остаток от (epoch / 86400 + 4) % 7 даёт день недели, где ноль — воскресенье. Проверка на нашем числе: 1737567600 / 86400 = 20110 суток, (20110 + 4) % 7 = 3 — среда; 22 января 2025 года и правда была среда.
configTime и математика epoch
#include <WiFi.h>#include <time.h> // configTime, time(), struct tm const int MSK = 3 * 3600; // смещение Москвы от UTC, в секундах void setup() { Serial.begin(115200); WiFi.begin("DachaNet", "superParol123"); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println(); configTime(MSK, 0, "pool.ntp.org", "time.google.com"); // фон: синхронизируется сам // ждём первую синхронизацию: пока её нет, time() вернёт 1970 год while (time(nullptr) < 1000000000) { // порог ~2001 год: время пришло delay(200); Serial.print("."); } Serial.println();} void loop() { time_t utc = time(nullptr); // epoch: секунды с 01.01.1970 UTC time_t local = utc + MSK; // локальное время: смещение прибавили сами int hh = (local / 3600) % 24; // часы: деление отбрасывает минуты int mm = (local / 60) % 60; // минуты: то же деление int ss = local % 60; // секунды: просто остаток Serial.print("hh="); Serial.print(hh); Serial.print(" mm="); Serial.print(mm); Serial.print(" ss="); Serial.println(ss); delay(1000);}configTime(смещение, летнее, сервер1, сервер2) настраивает фоновую синхронизацию: соединение с NTP-сервером плата установит сама и будет повторять раз в час. Смещение задаётся в секундах — для Москвы это 3 на 3600, то есть 10800; второй аргумент, летнее время, после отмены переводов часов остаётся нулём. Два сервера через запятую — страховка: если первый молчит, стек спросит второй.
Дальше — вся математика, которую я обещал. time(nullptr) возвращает epoch — число секунд с 1970 года по UTC. Часы суток: (epoch / 3600) % 24 — целочисленное деление отбрасывает минуты, остаток от деления на 24 оставляет час. Проверим на числе 1737567600: деление на 3600 даёт 482657, остаток от деления на 24 — 17. Минуты: 1737567600 / 60 = 28959460, остаток от деления на 60 — 40. Секунды — 0. Итого 17:40:00 по UTC; прибавили 10800 — получили 20:40 по Москве 22 января 2025 года. В симуляторе ниже движок честно проделает это же деление — сверьте свои расчёты.
Второй путь — готовый: getLocalTime(&timeinfo, 5000) заполняет структуру tm (tm_hour, tm_min, tm_mday, tm_mon — месяцы с нуля, tm_year — года от 1900) и ждёт синхронизации до пяти секунд. Когда нужна и дата, он короче ручного деления. Но деление из этого урока стоит понимать в любом случае: epoch встретится в каждом API — от логов серверов до MQTT-брокеров. И заметка на смежную дорогу: там, где интернета нет, время держит RTC-модуль — у нас есть раздел про часы DS3231; самые честные проекты совмещают обоих — NTP калибрует RTC раз в сутки.
| Источник времени | Точность | Что нужно | Грабли |
|---|---|---|---|
| millis() | Тики, но не время суток | Ничего | Обнуляется при перезагрузке — мои поливные грабли |
| NTP через configTime() | Десятки миллисекунд | WiFi и интернет | До первой синхронизации epoch = 0: год 1970-й |
| RTC DS3231 | Дрейф около 1-2 минут в год | Модуль с батарейкой CR2032 | Слетает при севшей батарейке |
| epoch из ответа сервера | Как у источника | Свой протокол запроса | Смещение зоны приходится держать в уме |
Симулятор: переводим epoch в часы
Что здесь настоящее, а что зафиксировано. Сеть — настоящая эмуляция: подключение, адрес, GET-запрос с кодом 200 идут как на плате. Число epoch в переменной — это место, где на реальной плате работает configTime(): фоновая синхронизация сама кладёт время в часы платы, и time(nullptr) его отдаёт. А вот деление с остатком, смещение 10800 и переносы 60 секунд в минуту движок считает по-настоящему — теми же операциями, что и скетч на плате. Хотите проверить мою арифметику на бумажке: 1737567600 секунд — это 20110 полных суток и хвост 63600 секунд, а 63600 — это ровно 17 часов 40 минут.
Перенос единиц в цикле тикания — та же математика, на которой держались бы часы платы без сети: секунда прибавилась, стало 60 — секунды в ноль, минута плюс один. Именно эту цепочку if-ов я однажды испортил, написав сброс минуты раньше сброса секунд, и получил часы, которые раз в сутки застывали на минуте вперед; теперь переношу строго от младшего к старшему, как в столбик. В уроке про millis-планировщик тот же принцип переносов работает на строках расписания.
- Смена зоны. Поставьте msk = 0 и запустите: «московское» время сравняется с UTC — зона живёт в смещении, а не в плате.
- Сутки вперёд. Прибавьте к epoch 86400: дата сдвинется на сутки, а часы и минуты не дрогнут — деление на 3600 дней не чувствует.
- Второй сервер. В скетче для платы добавьте третий адрес в configTime и по логам в мониторе поймайте момент, когда стек меняет источник.
- Связка с RTC. На плате с DS3231 совмещайте: NTP калибрует модуль раз в сутки — точность из урока про DS3231 плюс живой интернет.
- 1. Что возвращает time(nullptr) сразу после включения платы, до первой NTP-синхронизации?
- 2. epoch = 1737567600. Как из неё достать часы суток?
- 3. Сколько секунд в смещении Москвы UTC+3 для configTime()?
- 4. Печать времени показывает 00:00 1 января 1970 года. Первое действие?
- 5. Почему полив по millis() включился ночью вместо вечера?
- millis() — тики от старта, а не время: после перезагрузки расписание по нему обнуляется
- configTime(смещение_сек, 0, "pool.ntp.org") запускает фоновую NTP-синхронизацию; повтор раз в час, этикет пула — не дёргать чаще
- epoch — секунды с 01.01.1970 UTC: часы = (epoch/3600)%24, минуты = (epoch/60)%60, секунды = epoch%60
- Смещение зоны задаётся в секундах (Москва — 10800) и прибавляется к epoch до деления
- До первой синхронизации time() возвращает 0 (1970 год): ставьте порог time(nullptr) > 1000000000 перед использованием времени
Теперь у платы есть и имя из урока про mDNS, и честное время — связка «esp32-garage.local показывает 20:40» уже звучит как устройство, а не как эксперимент. На этой паре собираются будильники, поливы, журналы с метками и термостаты с ночным тарифом.
Третью дыру сетевого скетча закрывает урок про HTTPS: как ходить на настоящие API, которые давно живут за TLS, что лежит в CA-сертификате и сколько стоит криптография на микроконтроллере — в секундах и килобайтах.
Частые вопросы по теме урока
- Что возвращает time(nullptr) сразу после включения платы, до первой NTP-синхронизации?
- Внутренних батарейных часов у ESP32 нет: до первой синхронизации время держится на нуле, то есть на 1970 годе. millis() при этом растёт с первой секунды — это разные величины. Ноль: отсчёт epoch начинается с 1 января 1970 года
- epoch = 1737567600. Как из неё достать часы суток?
- Деление на 3600 даёт полные часы с начала времён, остаток от деления на 24 — час суток. Минуты ищутся тем же приёмом через 60, секунды — просто остаток от 60. (epoch / 3600) % 24
- Сколько секунд в смещении Москвы UTC+3 для configTime()?
- Смещение задаётся в секундах: 3 часа умножить на 3600 — 10800. Числа 3 или 180 стек тихо истолкует почти как ноль, и часы уплывут на три часа — ловил такое дважды. 10800
- Печать времени показывает 00:00 1 января 1970 года. Первое действие?
- 1970-й значит «синхронизации ещё не было». Порог на epoch перед использованием времени — стандартная страховка. Проверить, прошла ли синхронизация: WiFi, сеть, порог time(nullptr) > 1000000000
- Почему полив по millis() включился ночью вместо вечера?
- millis() — секундомер, а не часы. Перестартовал он — «время» пошло с нуля, и расписание поехало. millis() считает тики от старта и ничего не знает о часах: после перезагрузки счёт обнулился