Sitemap
AndroidCrypto

AndroidCrypto

How to Use the Time Cluster on an ESP32-C6 Zigbee Time Coordinator and End Device

11 min readAug 18, 2026

--

The Time Cluster is a useful feature within the Zigbee ecosystem, as it enables the standardized distribution of the current time from a Zigbee Coordinator to paired routers and end devices. While the Time Cluster appears in a few Arduino Zigbee examples, it is often treated merely as a minor add-on and is not particularly well-documented. In this tutorial, I will show you how to set up the Time Cluster on a Zigbee Coordinator and subsequently utilize that time data on a Zigbee end device.

On the left is the coordinator, and on the right side is the end device.

This tutorial is part of a series on Zigbee, specifically focusing on connecting to the ESP32-C6 processor (overview article: How to use an ESP32-C6 as Zigbee Coordinator or End Device). Since I cover all the basics of Zigbee in the overview article, I strongly recommend reading this article beforehand.

Topics of this tutorial

  • Source for an accurate, up-to-date time
  • Important Warning Regarding Arduino ESP32 Board Versions
  • Setting up the ESP32-C6 (Zigbee Coordinator)
  • What is the Time Cluster used for?
  • Setting up the ESP32-C6 (Zigbee Coordinator) with a one-time Wi-Fi connection to retrieve the current NTP time
  • Setting up the ESP32-C6 Light (Zigbee End Device)
  • Zigbee internal time handling
  • Summary

Source for an accurate, up-to-date time

An important aspect of this project concerns the source for obtaining an accurate, up-to-date time. Typically, we utilize freely available Network Time Protocol (NTP) servers on the internet for this purpose, which can be accessed via a Wi-Fi router. However, a challenge arises here: our Zigbee coordinator fully dedicates its transceiver to the Zigbee application, preventing us from simultaneously utilizing the Wi-Fi interface. For this reason, I have written a supplementary tutorial that describes in detail how to employ a second ESP32 to handle Wi-Fi communication with the NTP server and transmit the time to our ESP32-C6 coordinator via a UART interface; I therefore strongly recommend reading the tutorial “How to create an ESP32-based NTP time server for an ESP32-C6 Zigbee coordinator” in parallel with this guide.

Important Warning Regarding Arduino ESP32 Board Versions

While I was writing this tutorial, Espressif updated its Arduino ESP32 library twice—first from version 3.3.8 to 3.3.9, and later to 3.3.10 and 11. The jump to version 3.3.9 was intended to fix a bug within the Zigbee Time Cluster.

The issue stems from the differing time bases used for the timestamps: while a standard timestamp relies on UTC time—“This count starts at the Unix Epoch on January 1st, 1970 at UTC”—the Zigbee timestamp uses a different starting point: “The Zigbee standard base time is defined as the number of seconds since January 1, 2000, 00:00:00 UTC.”

However, this differing time base was not taken into account within the library. This issue goes unnoticed when both the coordinator and the end device are ESP32-based. Yet, as soon as commercial Zigbee devices are used, the incorrect time base becomes apparent. Unfortunately, a new error crept in while fixing this issue, preventing the use of the Time Cluster.

I have reported this bug to Espressif and opened an issue, which is currently being processed and will get fixed with the next release!

Urgent warning: If you intend to use the Time Cluster in your ESP32 project, you must under no circumstances use versions 3.3.9 or 3.3.10 or 3.3.11. Regarding future versions, I need to wait for the bug to be fixed before I can give the all-clear. The last usable version is 3.3.8.

In addition to downgrading to version 3.3.8, there is a second way to use the Time Cluster with newer Arduino ESP32 versions. If you are using Arduino ESP32 version 3.3.11, there is a fix available in the pull request tab of the GitHub repository: https://github.com/espressif/arduino-esp32/pull/12799/changes. Go to the “Files Changed” tag, download the Zigbee.cpp file, and replace the original one with this one. See the below “third way” to find the file on your computer.

This is the third way, but I do not recommend this. However, to achieve a working Time Cluster in the meantime, you need to modify the Arduino ESP32 library (specifically, the source code) in two places. Here are the instructions:

  1. The most important tip is to use a simple text editor for the modification—such as the Arduino IDE, Notepad, or similar software. Be cautious with “more advanced” software, which might insert unwanted and sometimes invisible control characters into the code, rendering the entire library unusable.
  2. The second most important point: create a backup copy of every file you intend to modify so that you can revert to the original version at any time if your change fails.
  3. To restore full functionality to the Time Cluster, you need to make changes in two places within a single file (ZigbeeEP.cpp). Locating this file on your computer can be slightly tricky because it is classified as a “system” or “hidden” file and is therefore not immediately visible. You should first find out how to display hidden files in your file manager (please use Google or similar).
  4. Where is the file located? Since I use macOS, I typically find library files at the following path: `<Drive>/Users/<Username>/Library/Arduino15/packages/esp32/hardware/esp32/3.3.11/libraries/Zigbee/src/`. Please note that the “3.3.11” in the path refers to the currently installed Arduino ESP32 version. If, for example, you are using “3.3.10,” you must adjust the path accordingly!
  5. Open the file with TextEdit and search for “ZigbeeEP::getTime”. Scroll down to the section: “if (!readClusterAttribute(&read_req)) { return tm(); }”. You need to comment out (or delete) this part: “/* if (!readClusterAttribute(&read_req)) { return tm(); }*/”. In its place, copy “esp_zb_zcl_read_attr_cmd_req(&read_req);”. Now look for the second location: “ZigbeeEP::getTimezone” (in my case, this is in the function immediately below). Look for the line again and make the same changes. I have compiled the necessary changes here:
File ZigbeeEP.cpp
...
tm ZigbeeEP::getTime(uint8_t endpoint, int32_t short_addr, esp_zb_ieee_addr_t ieee_addr) {
...
/*
if (!readClusterAttribute(&read_req)) {
return tm();
}
*/
// taken from version 3.3.8
esp_zb_zcl_read_attr_cmd_req(&read_req);
...
}

int32_t ZigbeeEP::getTimezone(uint8_t endpoint, int32_t short_addr, esp_zb_ieee_addr_t ieee_addr) {
...
/*
if (!readClusterAttribute(&read_req)) {
return 0;
}
*/
// taken from version 3.3.8
esp_zb_zcl_read_attr_cmd_req(&read_req);
...
}

Save the changes and close the file—you're done!

Setting up the ESP32-C6 (Zigbee Coordinator)

There are three important changes to the original switch coordinator sketch:

  1. The sketch is receiving timestamps of the current UTC via the UART interface
  2. Save the UTC time in the Real-Time Clock (RTC) and calculate the local time (which, in the end, gives an offset to the UTC)
  3. Synchronize the time with the Zigbee Time Cluster

Receive timestamps via the UART interface

Data exchange via the UART interface is straightforward: we define two pins for the task (one transmit—TX—and one receive—RX—channel) and wait until we receive data from the other ESP32. As soon as the received number exceeds a certain threshold, we acknowledge receipt with an “OK” and pass it on to the next routine.

in header:
#define SERIAL_UART_RX_PIN 5
#define SERIAL_UART_TX_PIN 4

in setup():
// setup the second UART interface
Serial1.begin(115200, SERIAL_8N1, SERIAL_UART_RX_PIN, SERIAL_UART_TX_PIN);

in setup() and loop():
while (receivedTime < UTC_RECEIVED_TIMESTAMP) {
if (Serial1.available()) {
String ts = Serial1.readStringUntil('\n');
receivedTime = (time_t)ts.toInt();
if (receivedTime > UTC_RECEIVED_TIMESTAMP) {
Serial1.println("OK");
Serial1.println("OK");
Serial1.println("OK");
Serial.println("Send OK to the NTP Timeserver");
}
}
delay(100);
}

Save the UTC time and calculate the local time and offset

We take the received timestamp and store it in a struct, which we then save to the RTC using “settimeofday.” The next step utilizes a time zone string (in my case, for Germany/Europe) to calculate the local time. This is accomplished using the built-in “time.h” library. We then retrieve the local time and subtract the UTC time from it to obtain the offset between the two time zones.

in header:
const char* TZ_INFO = "CET-1CEST,M3.5.0,M10.5.0/3";

in setup() and loop():
void setTimeByUtcTimestamp(time_t newTime) {
// set the system time (UTC)
struct timeval tv = { .tv_sec = newTime, .tv_usec = 0 };
settimeofday(&tv, NULL);
// convert the UTC in local time
setenv("TZ", TZ_INFO, 1);
tzset();
// start Zigbee with the local time
getLocalTime(&timeinfo);
// calculate the offset, e.g., 7200 seconds
localOffset = (int32_t)(newTime - mktime(&timeinfo));
if (localOffset < 0) localOffset *= -1;
Serial.printf("setTimeByUtcTimestamp localOffset: %d\n", localOffset);
}

Synchronize the time with the Zigbee Time Cluster

Unfortunately, this part is not shown in the examples and is therefore easily overlooked. In the “setup()” section, we add the Time Cluster to our Zigbee “zbSwitch” object and populate the current UTC time and offset.

Get AndroidCrypto’s stories in your inbox

Join Medium for free to get updates from this writer.

Although we have supplied our system clock with the current time, the Time Cluster unfortunately does not update subsequently. Users of the ESP32-IDF can utilize automatic synchronization; however, this feature is currently unavailable in the Arduino Zigbee implementation.

in setup():
zbSwitch.addTimeCluster(timeinfo, localOffset);

in loop():
getTimeByUtcTimestamp();
zbSwitch.setTime(timeinfo);
zbSwitch.setTimezone(localOffset);

Therefore, within our program loop, we must regularly update the UTC time—and, where applicable, the offset as well. In my sketch, I do this every 60 seconds; however, this makes it clear that the Zigbee end devices rarely, if ever, receive a “real-time” timestamp but rather rely on a time base that is consistently off by up to 60 seconds.

Where to find the sketches?

You can find the complete combination of a UART transmitter and receiver sketches in my GitHub repository that is used for the tutorial “How to create an ESP32-based NTP time server for an ESP32-C6 Zigbee coordinator”.

What is the Time Cluster used for?

Now, however, we should clarify exactly how the end devices utilize the time provided by the Time Cluster. Consider a smart radiator thermostat that controls the heating system to warm up an apartment in the morning and lower the temperature again in the evening to conserve energy. To achieve this, a “heating schedule” is stored within the thermostat; naturally, the thermostat must know the current time to set the appropriate temperature. We quickly realize that second-by-second precision is not critical in this context, as the heating system responds very sluggishly anyway. If necessary, the update interval can be set to a significantly shorter duration; however—particularly in the case of battery-powered units—the end devices typically query the time only at hourly intervals, rendering a shorter update interval pointless.

Setting up the ESP32-C6 (Zigbee Coordinator) with a one-time Wi-Fi connection to retrieve the current NTP time

While it is fundamentally sound, you likely do not want to create a dedicated ESP32 NTP time server and tightly couple it to the ESP32-C6 for your Zigbee experiments. For this reason, I am presenting a sketch here that combines both functions: synchronizing the ESP32-C6 with the current NTP time and using it as a Zigbee coordinator.

The coordinator is setting up its Wi-Fi channel to retrieve the NTP time.
The coordinator got the time and synchronized it with the internal RTC clock.
The coordinator has started Zigbee and set the Time Cluster data; the end device (right side) is waiting for data

Since it is preferable to avoid having the Wi-Fi interface active simultaneously with the Zigbee environment, I resolve this issue using two sequential processes:

  1. In “setup()”, we initialize the Wi-Fi connection to the router, establish a connection to the NTP time server, synchronize the retrieved time with the ESP32-C6 Real-Time Clock (including the current local time), and then shut down the Wi-Fi environment.
  2. Next, we initiate the setup and initialization of the Zigbee environment and subsequently operate solely as a Zigbee coordinator.

The downside of this method is that the ESP32-C6 system time drifts over the course of days and weeks of continuous operation. However, in my own experiments, the system clock remained well within acceptable limits even after several days, with the discrepancy amounting to seconds rather than minutes or hours. This makes the setup perfect for a small, standalone solution. You can find the sketch itself in my GitHub repository for all ESP32-C6 Zigbee projects (see link below).

Setting up the ESP32-C6 Light (Zigbee End Device)

This point, too, is addressed only peripherally in the official examples. The retrieval of time data from the coordinator is already built into our Switch class—or rather, its predecessor, the ZigbeeED class—and we retrieve both the UTC time and the offset:

in getTimeData():
struct tm timeinfoTc = zbLight.getTime();
int32_t timezoneOffset = zbLight.getTimezone();

We once again use UTC to set our system time. Since we receive the offset directly, we use it to calculate the local time:

time_t t = mktime(&timeinfoTc);
struct timeval tv;
tv.tv_sec = t;
tv.tv_usec = 0;
// set system time (stored in internal RTC memory)
settimeofday(&tv, NULL);
// optional: set timezone globaly for methods like localtime()
char tzConfig[32];
sprintf(tzConfig, "GMT%s%ld", (timezoneOffset >= 0) ? "-" : "+", abs(timezoneOffset / 3600));
setenv("TZ", tzConfig, 1);
tzset();

In my program, this update occurs every 60 seconds; however, this is necessary only for demonstration purposes, as the ESP32 features a rather accurate RTC that drifts by perhaps a few seconds over the course of a day—consequently, an update could be performed just once daily by an end device.

The end device (right display) is connected with the coordinator but has not asked for the Time Cluster data, so the internal RTC clock started at 01.01.1970 0:00

Since the purpose of this program is to demonstrate the Time Cluster, the display shows both the local time (“CET”) and “UTC.” Additionally, the offset in seconds and the number of seconds since the last time update are displayed.

After 60 seconds, the end device is asking for the Time Cluster data and synchronizing its RTC clock. Please note the difference in seconds, as the (static) Time Cluster data is synchronized every minute only.

Zigbee internal time handling

When referring to an accurate time source, we typically utilize Network Time Protocol (NTP) servers. These provide Coordinated Universal Time (UTC) in the form of a timestamp, which counts the number of seconds elapsed since the epoch of January 1, 1970. For instance, we might receive a value of “1778923446” transmitted from our ESP32 NTP time server.

[ZigbeeEP.cpp:402] setTime(): Setting time to 1778923446

https://www.epochconverter.com/ntp
Timestamp 1778923446

Date Time (UTC) May 16. 2026, 09:24:06
Date Time (your time zone) May 16. 2026, 11:24:06

However, Zigbee measures time in seconds since January 1, 2000, at 00:00:00 UTC. Consequently, we must first convert the received UTC timestamp so that we can use it within the Zigbee ecosystem. The formula for this is:

Zigbee Timestamp = UTC Timestamp - 946684800

To stick with our example, the Zigbee timestamp would be 1778923446–946684800 = 1082651024. Fortunately, the two functions, “addTimeCluster()” and “setTime()” in the coordinator sketch, handle this task for us, allowing us to use the received UTC timestamp directly.

Zigbee Time Cluster updated UTC timestamp 1082651024 localOffset 7200
Local time: 16.05.2026 11:24:06
UTC time: 16.05.2026 09:24:06

A second point concerns the time zone, which is also referred to as the time offset. This refers to the number of seconds between UTC time and local time. For example, during daylight saving time, Germany is 2 hours ahead of UTC time—or, converted into seconds: 2 hours * 60 minutes * 60 seconds = 7,200 seconds. In addition to the raw UTC time, you should always include the time zone on the Zigbee Coordinator; since some end devices specifically request the local time from the coordinator, this value should be set.

Summary

Using the Zigbee Time Cluster on an ESP32-C6 coordinator or end device is simpler than many think, yet setting it up requires considerable care. Unfortunately, the current official Arduino ESP32/Zigbee implementation is still buggy, but with the workaround shown, nothing stands in the way of successfully using the Time Cluster.

Source code of the app

You can find the complete code of the apps and additional material in my GitHub repository “ESP32_C6_Zigbee_Coordinator”.

The coordinator part is in the “Esp32_C6_Zigbee_Switch_TimeCluster_WiFi_v06” subfolder; the end device sketch is here: “Esp32_C6_Zigbee_ED_Time_Cluster_OLED_v05”

Happy coding!

--

--