ESP32: System time and SNTP

Introduction

In this tutorial we are going to learn how to configure the system time on the ESP32 and how to obtain the current time, using the Arduino core.

We are going to use the Simple Network Time Protocol (SNTP) to do the synchronization of the ESP32 time with a time server. If you are interested in the low level details of the protocol, you can consult the RFC.

In our simple code example below, we are going to use two Arduino core functions that simplify much the configuration of the system time zone and initializing the SNTP synchronization.

Nonetheless, those are implemented on top of ESP32 IDF’s system time API, which I recommend you to read to have a better understanding on how the system time behaves. In the section “Analyzing the Arduino functions” below, we will also do a quick analysis on some of the IDF APIs called under the hood by the Arduino core functions.

It’s important to take in consideration that the application will only sync time with the time server periodically, meaning that the ESP32 will need to keep the system time between those syncs. That is achieved with either one or two internal time sources, as described here. We are not going to focus in that part of the system, but it is important to be aware about these time sources and their accuracy on timekeeping, which may be relevant for your application requirements.

The code below is based on the time example from the Arduino core, which I encourage you to check.

The tests shown below were performed on a ESP32-E FireBeetle board from DFRobot.

A simple example

We will start our code with the library includes. We will need the WiFi.h, to be able to connect the ESP32 to a WiFi network.

#include <WiFi.h>

Then we will define two global variables to hold the WiFi network credentials, namely the network name (SSID) and the password. Make sure to replace the placeholders I will be using below by the actual credentials of your network.

const char* ssid = "yourNetworkName";
const char* password =  "yourNetworkPass";

We will also define a string with the time server URL that will be used to retrieve the time. In my case, I’ll be using Google’s server, but you can check here a list of public time servers that you can choose.

const char* ntpServer = "time.google.com";

Moving on to the Arduino setup function, we will start by opening a serial connection. Then, we will connect the ESP32 to the WiFi network, using the previously defined variables.

Serial.begin(115200);
  
WiFi.begin(ssid, password);
  
while (WiFi.status() != WL_CONNECTED) {
      delay(500);
      Serial.println("Connecting...");
}

To finish the Arduino setup, we will now take care of defining the time zone of our device and the set the SNTP server. We do this with a call to the configTime function, which receives the following arguments:

  • GMT offset, in seconds. The Greenwich Mean Time (GMT) offset corresponds to the time difference between a given zone and the Greenwich Mean Time [1]. You can read more about GMT here.
  • Daylight Saving Time offset, in seconds. Daylight Saving Time (DST) is the practice of advancing clocks, typically one hour, during warmer months, so that darkness falls at a later clock time [2].
  • NTP server.

Since I’m located in Portugal, I’m in GMT (also called GMT+0) [3]. Thus, I will pass the value 0 for the first argument. You can check here a list of countries and find your time zone. Don’t forget to convert the value to seconds.

Regarding the second argument, at the time of writing, Portugal is on DST period, meaning the clocks are advanced by 1 hour. As such, I should pass a value of 3600 seconds. You can check also here the countries that have DST periods.

For the third argument we will simply pass the global string we have defined early, which contains the URL for the server.

configTime(0, 3600, ntpServer);

The complete Arduino setup can be seen below.

void setup()
{
  Serial.begin(115200);
  
  WiFi.begin(ssid, password);
  
  while (WiFi.status() != WL_CONNECTED) {
      delay(500);
      Serial.println("Connecting...");
  }
  
  Serial.println("Connected with success");
  
  configTime(0, 3600, ntpServer);
}

We will now move on to the Arduino main loop. There we will call, every 5 seconds, a function that will print the current time. We will call this function printTime and check its implementation below.

void loop()
{
  printTime();
  delay(5000);
}

To finish our code, we will analyze the implementation of our printTime function. It will return void and receive no arguments.

void printTime(){
   // implementation of the function
}

We will start by declaring a struct of type tm.

struct tm time;

Then we will call the getLocalTime function, passing as input the address of our previously declared struct. This function will populate the struct with the time information, which we will access below.

As output, this function returns a Boolean indicating if it was possible to retrieve the time information (true) or not (false). We will use the returning value for error checking.

if(!getLocalTime(&time)){
    Serial.println("Could not obtain time info");
    return;
}

Assuming we can successfully obtain the time information, we will print the values of some of the struct fields:

  • tm_year: Number of years since 1900. If you want the current year, you need to sum 1900 to the obtained value. You can read about Epochs for an explanation on why this value is counted from 1900.
  • tm_mon: Month, from 0 to 11.
  • tm_mday: Day, from 1 to 31.
  • tm_hour: Hour, from 0 to 23.
  • tm_min: Minute, from 0 to 59.
  • tm_sec: Second, from 0 to 59.
  Serial.print("Number of years since 1900: ");
  Serial.println(time.tm_year);

  Serial.print("month, from 0 to 11: ");
  Serial.println(time.tm_mon);

  Serial.print("day, from 1 to 31: "); 
  Serial.println(time.tm_mday);

  Serial.print("hour, from 0 to 23: ");
  Serial.println(time.tm_hour);

  Serial.print("minute, from 0 to 59: ");
  Serial.println(time.tm_min);
  
  Serial.print("second, from 0 to 59: ");
  Serial.println(time.tm_sec);

The whole function is shown below.

void printTime(){
 
  struct tm time;
  
  if(!getLocalTime(&time)){
    Serial.println("Could not obtain time info");
    return;
  }

  Serial.println("\n---------TIME----------");
  
  Serial.print("Number of years since 1900: ");
  Serial.println(time.tm_year);

  Serial.print("month, from 0 to 11: ");
  Serial.println(time.tm_mon);

  Serial.print("day, from 1 to 31: "); 
  Serial.println(time.tm_mday);

  Serial.print("hour, from 0 to 23: ");
  Serial.println(time.tm_hour);

  Serial.print("minute, from 0 to 59: ");
  Serial.println(time.tm_min);
  
  Serial.print("second, from 0 to 59: ");
  Serial.println(time.tm_sec);
}

The complete code can be seen in the snippet below.

#include <WiFi.h>

const char* ssid = "yourNetworkName";
const char* password =  "yourNetworkPass";

const char* ntpServer = "time.google.com";

void printTime(){
 
  struct tm time;
  
  if(!getLocalTime(&time)){
    Serial.println("Could not obtain time info");
    return;
  }

  Serial.println("\n---------TIME----------");
  
  Serial.print("Number of years since 1900: ");
  Serial.println(time.tm_year);

  Serial.print("month, from 0 to 11: ");
  Serial.println(time.tm_mon);

  Serial.print("day, from 1 to 31: "); 
  Serial.println(time.tm_mday);

  Serial.print("hour, from 0 to 23: ");
  Serial.println(time.tm_hour);

  Serial.print("minute, from 0 to 59: ");
  Serial.println(time.tm_min);
  
  Serial.print("second, from 0 to 59: ");
  Serial.println(time.tm_sec);
}

void setup()
{
  Serial.begin(115200);
  
  WiFi.begin(ssid, password);
  
  while (WiFi.status() != WL_CONNECTED) {
      delay(500);
      Serial.println("Connecting...");
  }
  
  Serial.println("Connected with success");
  
  configTime(0, 3600, ntpServer);
}

void loop()
{
  printTime();
  delay(5000);
}

To test the code, simply compile it and upload it. Once the procedure is finished, open the Arduino IDE serial monitor. You should obtain a result similar to figure 1. As can be seen, we get the time information periodically printed to the monitor.

System time information, printed to the Arduino IDE serial monitor.
Figure 1 – System time information, printed to the Arduino IDE serial monitor.

Analyzing the Arduino functions

In the code from the previous section, we have used two important functions: configTime and getLocalTime. These functions are Arduino abstractions implemented on this file. As such, it is important to understand what they do under the hood, to better understand how our program will behave.

We will start by analyzing the configTime function. This function starts by configuring and initializing the SNTP synchronization. First, it calls the sntp_setoperatingmode function, passing as input the SNTP_OPMODE_POLL mode.

Although I did not find many documentation about the supported modes, the information I’ve found here and here seem to indicate the existence of the two following constants:

  • SNTP_OPMODE_POLL
  • SNTP_OPMODE_LISTENONLY

The difference between these two modes seems to be related with the two operating modes defined in the SNTP RFC: unicast and broadcast. In the case of the Arduino function we have invoked, as already mentioned, the mode is SNTP_OPMODE_POLL, which means the address of the time server needs to be specified.

This is done after with a call to the sntp_setservername function. This function receives as first input the number of the server and as second the address of the server. In the case of the Arduino core, only a single time server is supported.

After this, the sntp_init function is called, which takes care of the initialization of the SNTP module with the previously defined configurations.

Other important aspect to take in consideration is that the application will perform a time synchronization periodically [5]. This time synchronization period is determined by the CONFIG_LWIP_SNTP_UPDATE_DELAY configuration [5], which is set to 1 hour in the Arduino core (the configuration can be seen here and it is defined in milliseconds). The minimum value supported is 15 seconds, as defined in the RFC [6].

Once STNP is configured and initialized, the configTime function still needs to take care of setting the time zone. This is done with a call to the setenv function followed by a call to the tzset function.

The setenv function is responsible for setting the TZ environment variable to the correct value depending on the device location. The format of the time string is the same as described in the GNU libc documentation (although the implementation is different) [7].

The tzset takes care of updating the C library runtime data for the new time zone [7].

In the implementation of the getLocalTime function, the time function is called under the hood. This function returns the current calendar time encoded as a time_t object [8]. Then, the localtime_r function is called, using the previously obtained time_t object. This function converts a time_t object into calendar time, expressed in local time, in the struct tm format [9] (the same struct format we have accessed in our code).

I also recommend you to check this great tutorial, which explains how to work with local time and SNTP from an IDF user’s perspective.

Formatting strings from time structs

In the introductory example we focused on how to configure the time zone and setup SNTP to periodically update the system clock. Nonetheless, we accessed that information directly from the tm struct, which led to some repetitive and tedious code.

Although writing such code may be needed for certain applications, in some cases we may just want to display the system time somewhere (ex: in a serial monitor) in a friendly format. We are going to cover how to do it on this section.

The setup code will be similar to what we have already covered, so we are going to focus our attention on different ways of printing the time.

So, one simple way of converting a tm struct to a human readable format is using the asctime function. This function receives as input the address of a tm structure, interprets it and converts it to a string in the Www Mmm dd hh:mm:ss yyyy format, where Www is the weekday, Mmm the month (in letters), dd the day of the month, hh:mm:ss the time, and yyyy the year. [10].

Serial.println(asctime(&time));

Since this function uses a fixed format, we can use the strftime function for greater flexibility. This function allows to format the contents of a tm struct accordingly to a specification and write the result in a string [11]. You can check all the format specifiers along with examples here.

This function receives the following inputs:

  • A char buffer, where the final string will be written.
  • The size of the char buffer.
  • A string with the format.
  • The address of the tm struct.

For illustration purposes, we will use the 3 following formatting examples:

  • Year/Month/Day Hour:Minute:Second
  • Hour:Minute:Second
  • Year/Month/Day
char buffer[80];
  
strftime(buffer, sizeof(buffer), "%Y/%m/%d %H:%M:%S", &time);
Serial.println(buffer);

strftime(buffer, sizeof(buffer), "%H:%M:%S", &time);
Serial.println(buffer);

strftime(buffer, sizeof(buffer), "%Y/%m/%d", &time);
Serial.println(buffer);

Note from the previous examples that, when specifying the formatters, we don’t need to use all the fields of the tm struct. We can print just what we want.

As an additional note, there are also a print and println methods on the Serial interface, in the Arduino core, that support as first argument the address of a tm struct and as second a formatting string (you can check the header file here, which contains all versions of print and println). Under the hood it uses the strftime method, as can be seen in the implementation.

The complete code can be seen below.

#include <WiFi.h>

const char* ssid = "yourNetworkName";
const char* password =  "yourNetworkPass";

const char* ntpServer = "time.google.com";

void printTime(){
 
  struct tm time;
  
  if(!getLocalTime(&time)){
    Serial.println("Could not obtain time info");
    return;
  }

  Serial.println("\n---------TIME----------");
  
  Serial.println(asctime(&time));
  
  char buffer[80];
  
  strftime(buffer, sizeof(buffer), "%Y/%m/%d %H:%M:%S", &time);
  Serial.println(buffer);

  strftime(buffer, sizeof(buffer), "%H:%M:%S", &time);
  Serial.println(buffer);

  strftime(buffer, sizeof(buffer), "%Y/%m/%d", &time);
  Serial.println(buffer);
}

void setup()
{
  Serial.begin(115200);
  
  WiFi.begin(ssid, password);
  
  while (WiFi.status() != WL_CONNECTED) {
      delay(500);
      Serial.println("Connecting...");
  }
  
  Serial.println("Connected with success");
  
  configTime(0, 3600, ntpServer);
}

void loop()
{
  printTime();
  delay(5000);
}

Upon testing the code, you should obtain a result similar to figure 2. As can be seen, it shows the time and data printed in the different formats we specified in our code.

Output of the program, showing the different time formats.
Figure 2 – Output of the program, showing the different time formats.

Suggested Readings

References

[1] https://www.ibm.com/docs/en/z-netview/6.2.0?topic=statements-gmtoffset

[2] https://en.wikipedia.org/wiki/Daylight_saving_time

[3] https://greenwichmeantime.com/time-zone/gmt-plus-0/

[4] https://github.com/espressif/arduino-esp32/blob/34125cee1d1c6a81cd1da07de27ce69d851f9389/cores/esp32/esp32-hal-time.c#L50

[5] https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/system/system_time.html?highlight=sntp_opmode_poll#sntp-time-synchronization

[6] https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/kconfig.html#config-lwip-sntp-update-delay

[7] https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/system/system_time.html?highlight=setenv#timezones

[8] https://en.cppreference.com/w/c/chrono/time

[9] https://en.cppreference.com/w/c/chrono/localtime

[10] https://www.cplusplus.com/reference/ctime/asctime/

[11] https://man7.org/linux/man-pages/man3/strftime.3.html

Leave a Reply