Перейти к содержимому

Работа с датой и временем и SNTP-синхронизация на ESP32 и ESP8266

Добрый день, уважаемый читатель! В прошлой статье я рассказывал про подключение к сети WiFi, что нужно сделать сразу после этого? Ну, не строго обязательно, конечно, но строго желательно?

Подключаться к MQTT-брокеру? Нет, рановато – защищенное подключение потребует проверки TSL-сертификата, а для этого потребуется проверить срок его действия.

Да вы, наверное, уже догадались из названия статьи: первое, что потребуется сделать сразу после подключения – это получить актуальные дату и время с любого публичного NTP-сервера. Это позволяет не тратится на автономные I2C-часики, а всегда иметь “под рукой” достаточно точные отметки времени. Впрочем, даже когда в вашей системе установлена микросхема часов реального времени, никогда не помешает синхронизировать её с более точными данными из сети Интернет.

 

Дата и время используются в микроконтроллерах повсеместно – для журналирования, для установки защищенных интернет соединений, для работы по расписаниям, да много для чего ещё.

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

Почти всё, что описано в данной статье, применимо не только к ESP32, но и к ESP8266 + Arduino, там также можно использовать стандартную библиотеку <time.h> (с небольшими изменениями, но не суть). Поэтому в этот раз я решил написать “универсальную” статью.

Для начала обсудим методы работы с датой и временем с использованием стандартной библиотеки <time.h>, а уже потом я расскажу, как получить актуальное время из сети интернет. В том числе и для Arduino в лице esp8266. Ну в заключение приведу нескольких несложных “самодельных” функций для контроля интервалов суток, которыми я частенько пользуюсь.

ESP32 использует два аппаратных таймера для сохранения системного времени. Системное время можно сохранить с помощью одного или обоих аппаратных таймеров в зависимости от назначения устройства и требований к точности системного времени:

  • Таймер RTC (по умолчанию): этот таймер позволяет сохранять время в различных спящих режимах, а также может сохранять отсчет времени при любых сбросах (за исключением сбросов при включении питания, которые сбрасывают таймер RTC). Отклонение частоты зависит от источников часов таймера RTC и влияет на точность только в спящих режимах, в этом случае время будет измеряться с разрешением 6,6667 мкс.
  • Таймер высокого разрешения: этот таймер недоступен в спящих режимах и не будет сохраняться после сброса, но имеет большую точность. Таймер использует источник тактового сигнала APB_CLK (обычно 80 МГц), который имеет девиацию частоты менее ±10 ppm. Время будет измеряться с разрешением 1 мкс.

Стандартная библиотека time.h

Как я уже написал, для работы с датой и временем используется стандартная библиотека time.h, которая была унаследована миром микроконтроллеров из UNIX и других POSIX-совместимых операционных систем, поэтому используемый на большинстве микроконтроллеров способ кодирования даты и времени называется UNIX-время или POSIX-время (англ. Unix time).

Дата и время в этом стандарте представлены в виде обычного 64-битного числа, в котором хранится количество секунд, прошедших с начала условного отсчёта. Моментом начала отсчёта считается полночь (по UTC) с 31 декабря 1969 года на 1 января 1970, время с этого момента называют “эрой UNIX” (англ. Unix Epoch). Способ хранения времени в виде количества секунд очень удобно использовать при сравнении даты и времени с точностью до секунды, а также для хранения: дата и время занимают в памяти относительно мало места и при необходимости их можно легко преобразовать в любой удобочитаемый формат. Недостатками такого способа хранения времени являются невозможность хранения времени с точностью меньше секунды, а также потери в производительности при очень частом обращении к “вычисляемым” элементам даты, вроде номера месяца и т. п. Но в большинстве случаев всё-таки эффективнее хранить время в виде одной величины, а не набора полей.

Представление стандартных временных интервалов в секундах

Пересчитать “человеческие” дату и время в unix time и наоборот можно с помощью online конверторов.


Получение системного времени

Итак, с форматом определились, как же его получить? А очень просто – для этого используйте функцию, которая таки так и называется – time():

Работа с датой и временем и SNTP-синхронизация на ESP32 и ESP8266

Позволю себе процитировать справочную систему:

Функция time() возвращает число секунд, истекших после полуночи (00:00:00) 1 января 1970 г. (UTC) по системным часам. Возвращаемое значение сохраняется в расположении, предоставленном destTime. Этот параметр может иметь значение NULL, в этом случае возвращаемое значение не сохраняется.

time() является оболочкой для _time64(), а time_t по умолчанию равнозначно uint64_t. Если необходимо, чтобы компилятор принудительно интерпретировал time_t как старое 32-разрядное значение time_t, можно определить макрос _USE_32BIT_TIME_T, но это не приветствуется, так как приложение может завершиться сбоем после 18 января 2038 г.

Теперь давайте посмотрим на практике, как это сделать на ESP32 или ESP8266:

Работа с датой и временем и SNTP-синхронизация на ESP32 и ESP8266

Как использовать – решайте сами в зависимости от ситуации. Чаще удобнее использовать первый способ, передав NULL в функцию. Но когда требуется периодически “обновлять” одну и ту же переменную, разумнее использовать второй способ.

 


Получение более точного системного времени

Если вам нужно получить время с разрешением в одну микросекунду, используйте функцию gettimeofday(). Данная функция принимает в качестве аргумента структуру, с двумя полями:

  • в поле tv_sec хранится целое число секунд, это и есть time_t
  • в поле tv_usec хранится дополнительное число микросекунд.

У функции есть также второй аргумент, который должен быть равен NULL.

Работа с датой и временем и SNTP-синхронизация на ESP32 и ESP8266


Функции – счетчики циклов работы процессора

На Arduino-платформе есть популярная функция millis(), которая возвращает количество миллисекунд, прошедших с момента запуска контроллера. По сути это просто счетчик, который каждую миллисекунду добавляет к своему значению единичку.

На ESP32 есть несколько функций с аналогичным назначением:

  • int64_t esp_timer_get_time() – возвращает количество микросекунд с момента запуска базового таймера (чуть-чуть позже момента запуска контроллера). Можно использовать её вместо millis(), но нужно поделить полученное значение на 1000UL. В отличие от рассмотренной выше функции gettimeofday(), значение, возвращаемое функцией esp_timer_get_time() будет сброшено в 0 при выходе из режима глубокого сна и к нему не применяется корректировка часового пояса.
  • uint32_t esp_cpu_get_cycle_count() (или cpu_hal_get_cycle_count()) – вернет количество выполненных циклов для текущего ядра процессора. Эта функция имеет наименьшие накладные расходы, чем все остальные. Она отлично подходит для измерения очень короткого времени выполнения с высокой точностью. Циклы ЦП подсчитываются для каждого ядра отдельно, поэтому используйте этот метод только из обработчика прерываний или задачи, закрепленной за одним ядром.
  • TickType_t xTaskGetTickCount() – количество тиков операционной системы с момента вызова vTaskStartScheduler, то есть с момента запуска FreeRTOS. Её удобно использовать внутри задач для подсчета относительно коротких интервалов времени (длиннее одного тика ОС). Для работы из прерывания существует специальная версия xTaskGetTickCountFromISR().

Работа с датой и временем и SNTP-синхронизация на ESP32 и ESP8266

Примечание: cpu_hal_get_cycle_count() используется в том числе в библиотеке “esp_log.h” для вывода меток времени.


Формирование временных интервалов

Целочисленный формат времени time_t очень удобен для вычисления и отчёта интервалов времени с точностью до секунды. Просто вычтите одно значения time_t из другого time_t, и вы получите искомый интервал в секундах. Для интервалов до месяца это очень удобно – целочисленное деление поможет вам легко и очень быстро (в процессорных тактах) определить количество дней, часов, минут и секунд между двумя отметками времени:

Работа с датой и временем и SNTP-синхронизация на ESP32 и ESP8266

В данном случае аргумент value это не отметка времени, а разница между двумя отметками, то есть (time_t nowtime_t prev). Функция malloc_timespan_hms(time_t value) возвращает интервал времени в виде строки в часах, минутах и секундах, а похожая функция malloc_timespan_dhms(time_t value) возвращает строку с интервалов в днях, часах, минутах и секундах. Эти функции я описывал в одной из предыдущих статей.

Точно также можно посчитать и недели, а вот с месяцами уже начинаются сложности, так как месяц имеет переменную длину. И тут без перекодировки данных в день месяц и год уже не обойтись.


Интерпретация time_t в набор полей – часы, минуты, день, месяц и т.д.

Целочисленный формат времени очень удобен для контупера, но не подходит для использования человеком. Да и для расписаний зачастую нужно знать конкретные месяц и год, а не количество секунд в них. Для конвертации в “раздельный” формат даты и времени предназначена функции localtime(const time_t *sourceTime) и localtime_r(const time_t *sourceTime, struct tm *targetTime). Эти функции принимают в качестве аргумента отметку времени в UNIX-формате time_t, а возвращают в виде такой структуры:

Работа с датой и временем и SNTP-синхронизация на ESP32 и ESP8266

где:

  • tm_sec – секунды после минуты (0 – 59);
  • tm_min – минуты после часа (0 – 59);
  • tm_hour – часы с полуночи (от 0 до 23);
  • tm_mday – день месяца (от 1 до 31);
  • tm_mon – месяц (0 – 11; Январь = 0);
  • tm_year – год (текущий год минус 1900);
  • tm_wday – день недели (0 – 6; Воскресенье = 0);
  • tm_yday – день года (0 – 365; 1 января = 0);
  • tm_isdst – положительное значение, если летнее время действует; 0, если летнее время не действует; отрицательное значение, если состояние летнего времени неизвестно;

Как видите, полученные данные требуют “доработки напильником”: как минимум прибавить 1 к значению месяца и дня года, 1900 к значению года. Да и обозначение дней недели не соответствует российским, но это везде так.

Важное замечание! Данные функции корректируют входное значение с учетом часового пояса, если он был до этого установлен (как установить часовой пояс я расскажу чуть ниже). Поэтому и называются localtime() – локальное (то есть местное) время.

Отличие этих функций только в том, что первая принимает только один аргумент – указатель на time_t, а возвращает указатель но описанную выше структуру struct tm в куче; а вторая имеет уже два аргумента, где можно передать указатель на заранее выделенный буфер под выходные данные struct tm. Лично я предпочитаю второй способ (дабы не возиться потом с освобождением выделенной памяти):

На этом скриншоте закралась ошибка... Вначале хотел поправить, а потом решил оставить в качестве небольшого регбуса-кроксворда ;-)

Пример результата декодирования представлен на скриншоте ниже:

Да, да, у нас пока что первые секунды нового 1970 года....

Ну а дальше можно делать с полученными данными что необходимо.

 


Установка часового пояса

Если вы планируете подключить к своему микроконтроллеру аппаратные ЧРВ (часы реального времени), то вам, вероятно, не потребуется синхронизация с SNTP, и следовательно, нет особого смысла возиться с установкой часового пояса.

Но если вы планируете получать точное время из этих ваших интырнетов, то придется заранее определиться с часовым поясом в системе, так как SNTP (сервера точного времени) всегда возвращают время “по Гринвичу”.

Для ESP32 установить локальный часовой пояс необходимо за два шага:

  1. Вызовите setenv(const char *name, const char *value, int overwrite), дабы установить для переменной среды TZ правильное значение часового пояса. Формат описания часового пояса такой же, как описано в документации GNU libc. Для Москвы это будет “MSK-3“. Список часовых поясов вы можете найти здесь.
  2. Вызовите tzset(), чтобы обновить данные времени выполнения библиотеки C для нового часового пояса.

Работа с датой и временем и SNTP-синхронизация на ESP32 и ESP8266

Для ESP8266 это можно сделать точно так же, а также дополнительно имеется функция setTZ(), которая выполнят то же самое, но копирует часовой пояс в отдельную область памяти (кучи). Кроме того, на ESP8266 установить часовой пояс можно непосредственно перед запуском синхронизации времени, о чем будет рассказано чуть ниже.

Примечание: для ESP8266 и Arduino разработчиками заботливо предусмотрен файлик TZ.h, в котором определены всевозможные часовые пояса. Почему такой файлик отсутствует в ISP-IDF – я не знаю… Но вы вполне можете скопипастить нужную зону оттуда.


Преобразование даты и времени в строку

Однако чаще всего нужно просто вывести дату или время (или и дату и время вместе) в виде строки – например в логах, в каких-либо уведомлениях и т.д. Про формирование строковых представлений для относительно небольших интервалов я уже рассказал чуть выше, теперь давайте разберемся, как формировать строковые представления для даты и времени в произвольном формате. Для этого воспользуйтесь функцией

strftime(char *strDest, size_t bufSize, const char *format, const struct tm *timeptr);

где:

Пример использования буфера со статическим буфером фиксированного размера:

В данном случае время будет выведено в формате 1900-01-01 03:00:10

 


Синхронизация внутренних часов с серверами точного времени SNTP

Для ESP8266 и ESP32, поскольку они имеют встроенный модуль WiFi, имеется возможность получить точное время с серверов SNTP через интернет. Конечно, подключить к интернету можно и обычный Arduino, но тогда придется найти библиотеку SNTP самостоятельно, а для ESPxx они уже имеются в встроенных framework-ах. Для начала давайте разберемся, как это сделать на ESP32.

Запуск синхронизации времени на ESP32

За SNTP-синхронизацию времени на ESP32 отвечает библиотека “esp_sntp.h“. Библиотека поддерживает два режима синхронизации:

  • SNTP_SYNC_MODE_IMMED (по умолчанию) – обновляет системное время сразу после получения ответа от сервера SNTP. То есть было 01/01/1900, после обновления сразу стало 23/12/2022. В большинстве случаев это самый удобный способ.
  • SNTP_SYNC_MODE_SMOOTH – плавное обновление времени за счет постепенного уменьшения ошибки времени с помощью функции adjtime(). Если разница между временем ответа SNTP и системным временем превышает 35 минут, немедленно обновите системное время с помощью settimeofday(). Это бывает необходимо для приложений, чувствительных к системному времени, для которых резкий переход через года может привести к краху.

Давайте рассмотрим самый простой способ – SNTP_SYNC_MODE_IMMED. Процедура запуска синхронизации времени в этом случае выглядит следующим образом:

  1. Установить корректный часовой пояс (time zone), так как сервера NTP всегда возвращают время в UTC. Это мы обсудили чуть выше.
  2. Установить режим работы с помощью sntp_setoperatingmode(). Функция это не описана в справочной системе, но насколько я понимаю, она позволяет выбрать режим работы – получение времени по нашему запросу SNTP_OPMODE_POLL или когда сервер сам соизволит разослать широковещательное сообщение SNTP_OPMODE_LISTENONLY. Очевидно, для первого раза нужно использовать SNTP_OPMODE_POLL, а затем можно перейти на SNTP_OPMODE_LISTENONLY, если есть особое желание, но я так никогда не делал.
  3. Установить адрес SNTP-сервера с помощью sntp_setservername(). Библиотека поддерживает установку до 16 серверов, но по умолчанию настроен только один. Я обычно использую пять. Это не значит, что синхронизация выполняется сразу по всем пяти серверам, это значит, если первый не доступен – будет выполнен запрос к следующему, и так до конца списка. Настроить количество используемых серверов времени можно через утилиту SDK config (командой pio run -t menuconfig) в разделе Component config → LWIP → SNTP.

После первой успешной синхронизации времени запускается специально выделенный программный таймер, по истечении времени которого происходит повторная синхронизация времени. Таким образом всегда поддерживается актуальное время на устройстве. Период автоматической синхронизации также настраивается через menuconfig:

Настройки SNTP в SDK config

Впрочем, период повторной синхронизации времени можно и изменить “вручную”, с помощью функции sntp_set_sync_interval(uint32_t interval_ms). Это может быть полезно, чтобы задать разные интервалы в случае успешной и не успешной синхронизации.

Пример запуска синхронизации с двумя серверами:

Работа с датой и временем и SNTP-синхронизация на ESP32 и ESP8266

Однако тут есть небольшая проблемка – как понять, случилась таки синхронизация времени или ещё нет? Процесс получения времени асинхронный и может занимать довольно длительный промежуток времени. Можно поступить двумя способами:

  • создать цикл и тупо ждать, пока системное время не превысит 1000000000 (это 09/08/2001 г.)

Пример цикла ожидания синхронизации времени для Arduino-платформы

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

Пример функции обратного вызова при синхронизации времени

Во втором случае процедура запуска SNTP-службы будет выглядеть чуть-чуть по другому:

Работа с датой и временем и SNTP-синхронизация на ESP32 и ESP8266

 


Запуск синхронизации времени на ESP8266

Теперь давайте рассмотрим, как можно сделать то же самое на ESP8266 или ESP32 на Arduino-платформе. По большому счету, процедура запуска синхронизации времени для ESP8266 почти “один в один” повторяет реализацию на ESP32 – те же setenv(), tzset(), setServer(), sntp_init() и т.д.

Но, как и во многих других случаях, Arduino предоставляет более простые функции, дабы не заморачиваться со всей этой кутерьмой. В итоге, для запуска синхронизации времени вполне достаточно вызывать всего одну функцию – configTime(), в которой необходимо указать минимально необходимые аргументы: часовой пояс и сервер (или несколько серверов) синхронизации. Существуют две перезагружаемых версии configTime(), которые отличаются только способом определения часовой зоны:

Далеко не полный список строчных идентификаторов для часовых поясов

Вы можете указать один, два или три сервера времени, просто опустив отсутствующие аргументы:

Пример запроса времени с ожиданием результата

Указанный выше пример синхронизации времени вы можете посмотреть здесь: arduino/arduino_eps8266_pub_gpio_state_ssl

 


Формирование суточных расписаний с точностью до минуты

Для большинства сценариев бытовой (да и не только) автоматизации нет необходимости учитывать секунды. Вполне достаточно, если какой-либо сценарий начнет свою работу, скажем в 22:00 и закончит в 08:30 следующего дня. Секунды можно легко отбросить, и тем самым сильно упростить себе жизнь при хранении интервалов в памяти. Но при этом хотелось бы, чтобы сценарий начал свое выполнение в 22:00:00 и закончил в 08:29:59, а не с 22:00:18 по 08:30:55.

Cамое простое, что можно сделать в данном случае – запустить программный таймер (про них я уже рассказывал на данном канале). Единственная проблемка, которую нужно будет решить – как привязаться к началу каждой минуты, про это подробнее я расскажу чуть ниже.

Хранение суточных интервалов

Для хранения суточных интервалов я использую обычное 32-х разрядное число uint32_t. Часы, не мудрствуя лукаво, умножаем на 10, минуты записываем как есть. Получаем начало интервала, для нашего примера это будет 2200 = 22*10 + 00. Далее точно таким же способом формируем конец интервала, получаем 0830.

Затем опять же “сдвигаем” начало интервала на 4 разряда вправо, просто умножив его на 10000 и складываем с вычисленным концом интервала, получаем 22000830. Это и есть “зашифрованный” суточный интервал. Его вполне можно хранить в uint32_t, и легко сохранить в flash-памяти в качестве параметров.

Расшифровать интервал (timespan) тоже очень просто с помощью целочисленного деления:

  • uint16_t time_begin = timespan / 10000;
  • uint16_t time_end = timespan % 10000;

Сравнение интервала с текущим временем

Осталось сравнить эти данные с пересчитанным в такой же формат текущим временем:

Работа с датой и временем и SNTP-синхронизация на ESP32 и ESP8266

Функция checkTimespan() учитывает интервалы не только в пределах одних суток (когда t1 меньше t2), но и интервалы с переходом через полночь (когда t1 больше t2). Но вот сформировать такой интервал более одних суток с помощью неё уже не получится, это как в истории с классическим будильником со стрелками.

Привязка программного таймера к началу минуты

Как я уже упомянул, на данный момент времени генерацией отметок времени и обработкой расписаний занимается специально выделенный программный таймер.

Таймер генерирует следующие события в системную очередь событий:

  • RE_TIME_EVERY_MINUTE – данное событие генерируется в начале каждой минуты (00 секунд)
  • RE_TIME_START_OF_HOUR – событие, обозначающее начало каждого часа
  • RE_TIME_START_OF_DAY – событие, обозначающее начало каждого дня
  • RE_TIME_START_OF_WEEK – событие, обозначающее начало очередной недели
  • RE_TIME_START_OF_MONTH – событие, обозначающее начало месяца
  • RE_TIME_START_OF_YEAR – событие, обозначающее начало года
  • RE_TIME_TIMESPAN_ON – событие, генерируемое при начале суточного расписания, добавленного пользователем
  • RE_TIME_TIMESPAN_OFF – событие, генерируемое при окончании суточного расписания, добавленного пользователем
  • RE_TIME_SILENT_MODE_ON – событие, обозначающее начало тихого режима (погасить все светодиоды, подсветку экранов, отключить звуки)
  • RE_TIME_SILENT_MODE_OFF – событие, обозначающее окончание тихого режима (разрешить светодиоды, подсветку экранов, звуки)

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

Осталось понять, каким образом сделать так, чтобы событие RE_TIME_EVERY_MINUTE генерировалось не когда-нибудь в течение минуты, а именно в 00 секунд. Для этого я использовал не периодический таймер, а одноразовый. Затем в конце функции-обработчика таймера вычисляю количество микросекунд до начала следующей минуты, и запускаю его заново:

Работа с датой и временем и SNTP-синхронизация на ESP32 и ESP8266

Как видите, всё достаточно просто.

 


Список ссылок

  1. ESP-IDF :: System Time
  2. ESP8266 / Arduino :: time.h
  3. Пример на GitHub

___________________________________

На этом пока всё, до встречи на сайте и на dzen-канале!


Пожалуйста, оцените статью:
[ 5 из 5, всего 1 оценок ]

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *