An Internet Enabled Analog Clock for IoT (ESP32)

Updated on 2022-06-11

Create a simple synchronized analog clock using GFX, and a bit of code

Introduction

Clocks are pretty useful, but most IoT devices do not have built in clocks to keep the time. Even if you add one, you still need to set it somehow, and input on most IoT devices leaves a lot to be desired.

Also, just to be fancy, we'll display the time in analog form.

Note that this project was written for an M5 Stack, but it can easily be used with any ESP32. You just need to change the driver. Switching to an ILI9341 requires changing a couple of characters in one line of code.

Background

Worldtime API is a service that allows you to retrieve the time using a JSON based REST service. We'll be using that to sync the clock.

Worldtime API

In addition, we'll be using my GFX library to do the drawing of the clock. Aside from that, things are fairly straightforward.

GFX

I originally favored Worldtime API over NTP because of timezones, but I found that IP detection is very unreliable - with mine coming up several timezones away from where I live. Still, using it gives us a baseline for more advanced and complete web based time services, should it become available.

One advantage still of using this method is flash space. NTP requires UDP, but most services use TCP. You'd have to include both for many applications, and that's not insignificant. If you're already using web services, the additional overhead of this library is trivial.

Update: I've updated the code at GitHub to use NTP. It was done to test the feasibility of it for this clock, and it works great so I'm keeping it. I've kept the worldtime code in the project, but it's no longer being used.

Coding this Mess

worldtime.cpp

#include <Arduino.h>
#ifdef ESP32
#include <pgmspace.h>
#include <HTTPClient.h>
#else
#include <avr/pgmspace.h>
#endif
#include <worldtime.hpp>
#ifdef ESP32
time_t worldtime::now(int8_t utc_offset) {
    constexpr static const char* url = "http://worldtimeapi.org/api/timezone/Etc/UTC";
    HTTPClient client;
    client.begin(url);
    if(0>=client.GET()) {
        return time_t(0);
    }
    time_t result = parse(client.getStream());
    client.end();
    return (time_t)result+(utc_offset*3600);
}

#endif
time_t worldtime::parse(Stream& stm) {
    if(!stm.available()) {
        return (time_t)0;
    }
    if(!stm.find("unixtime\":")) {
        return (time_t)0;
    }
    int ch = stm.read();
    long long lt = 0;
    while(ch>='0' && ch<='9') {
        lt*=10;
        lt+=(ch-'0');
        ch=stm.read();
    }
    return (time_t)lt;
}

You can see the actual web request only works with an ESP32, but I've included the parse() method for any platform, in case you have an alternative library for making such a request.

The URL fetches the UTC time from the server and offsets it by utc_offset. Each hour is 3600 seconds, so you just add or subtract for each offset.

We do not use the timezone feature of the service. Originally, I was going to, but it doesn't have standard timezone names, and the IP detection is thoroughly unreliable. Furthermore, the unixtime returned is not offset by the time zone. To get that offset would require retrieving multiple fields, which can be returned in any order. That creates a serious complication in terms of parsing the document. I wanted to avoid a dependency on a full on JSON parser, preferring to scrape from machine services to save memory and flash. It's not quite as robust as a real JSON reader but it works, and for machine generated content, it's fairly reliable.

parse() is fairly simple. We return 0 on error, otherwise we look for the string unixtime": and then parse the number that comes immediately after it. Parsing an integer is pretty easy. Each time we multiply the accumulator by 10, and add the result of the character minus the character 0 (ASCII 48) to get the value of each digit.

main.cpp

First, we have to include and configure everything. You will probably have to alter this code a little bit if you use an ILI9341 or ILI9341V, or a bit more, as well as platformio.ini if you're using a different display.

You'll also have to plug in your SSID, password, and UTC offset for your timezone. Most of this however, is boilerplate:

#include <Arduino.h>
#include <WiFi.h>
#include <WiFiMulti.h>

#include <gfx_cpp14.hpp>
#include <ili9341.hpp>
#include <tft_io.hpp>
#include <worldtime.hpp>
using namespace arduino;
using namespace gfx;

// wifi
constexpr static const char* ssid = "SSID";
constexpr static const char* password = "PASSWORD";

// timezone
constexpr static const int8_t utc_offset = 0; // UTC

// synchronize with worldtime every 60 seconds
constexpr static const int sync_seconds = 60;

constexpr static const size16 clock_size = {120, 120};

constexpr static const uint8_t spi_host = VSPI;
constexpr static const int8_t lcd_pin_bl = 32;
constexpr static const int8_t lcd_pin_dc = 27;
constexpr static const int8_t lcd_pin_cs = 14;
constexpr static const int8_t spi_pin_mosi = 23;
constexpr static const int8_t spi_pin_clk = 18;
constexpr static const int8_t lcd_pin_rst = 33;
constexpr static const int8_t spi_pin_miso = 19;

using bus_t = tft_spi_ex<spi_host,
                        lcd_pin_cs,
                        spi_pin_mosi,
                        spi_pin_miso,
                        spi_pin_clk,
                        SPI_MODE0,
                        false,
                        320 * 240 * 2 + 8, 2>;
using lcd_t = ili9342c<lcd_pin_dc,
                      lcd_pin_rst,
                      lcd_pin_bl,
                      bus_t,
                      1,
                      true,
                      400,
                      200>;
using color_t = color<typename lcd_t::pixel_type>;

lcd_t lcd;

Next is where we do the drawing and keep the time. I could have factored out the drawing of the clock, but I'm not entirely satisfied with the code yet in terms of encapsulating it. For one thing, it only draws at origin (0,0). That's great for this application since we double buffer anyway, but is not suitable for a library:

template <typename Destination>
void draw_clock(Destination& dst, tm& time, const ssize16& size) {
    using view_t = viewport<Destination>;
    srect16 b = size.bounds().normalize();
    uint16_t w = min(b.width(), b.height());
    srect16 sr(0, 0, w / 16, w / 5);
    sr.center_horizontal_inplace(b);
    view_t view(dst);
    view.center(spoint16(w / 2, w / 2));
    static const float rot_step = 360.0/6.0;
    for (float rot = 0; rot < 360; rot += rot_step) {
        view.rotation(rot);
        spoint16 marker_points[] = {
            view.translate(spoint16(sr.x1, sr.y1)),
            view.translate(spoint16(sr.x2, sr.y1)),
            view.translate(spoint16(sr.x2, sr.y2)),
            view.translate(spoint16(sr.x1, sr.y2))};
        spath16 marker_path(4, marker_points);
        draw::filled_polygon(dst, marker_path,
          color<typename Destination::pixel_type>::gray);
    }
    sr = srect16(0, 0, w / 16, w / 2);
    sr.center_horizontal_inplace(b);
    view.rotation((time.tm_sec / 60.0) * 360.0);
    spoint16 second_points[] = {
        view.translate(spoint16(sr.x1, sr.y1)),
        view.translate(spoint16(sr.x2, sr.y1)),
        view.translate(spoint16(sr.x2, sr.y2)),
        view.translate(spoint16(sr.x1, sr.y2))};
    spath16 second_path(4, second_points);

    view.rotation((time.tm_min / 60.0) * 360.0);
    spoint16 minute_points[] = {
        view.translate(spoint16(sr.x1, sr.y1)),
        view.translate(spoint16(sr.x2, sr.y1)),
        view.translate(spoint16(sr.x2, sr.y2)),
        view.translate(spoint16(sr.x1, sr.y2))};
    spath16 minute_path(4, minute_points);

    sr.y1 += w / 8;
    view.rotation(((time.tm_hour%12) / 12.0) * 360.0);
    spoint16 hour_points[] = {
        view.translate(spoint16(sr.x1, sr.y1)),
        view.translate(spoint16(sr.x2, sr.y1)),
        view.translate(spoint16(sr.x2, sr.y2)),
        view.translate(spoint16(sr.x1, sr.y2))};
    spath16 hour_path(4, hour_points);

    draw::filled_polygon(dst,
                        minute_path,
                        color<typename Destination::pixel_type>::black);

    draw::filled_polygon(dst,
                        hour_path,
                        color<typename Destination::pixel_type>::black);

    draw::filled_polygon(dst,
                        second_path,
                        color<typename Destination::pixel_type>::red);
}

The GFX viewport<> allows you to create a rotated and/or offset draw target over an existing draw target. We don't need all that, and it works better in theory than in practice, but we can use its translate() function to rotate some points for our clock hands and face markers. First, we draw the face markers every 60 degrees. Next, we create paths for each of our hands. We do this by creating each hand (re)using our rectangle sr at the 12:00:00 position. We then rotate it by a fractional amount based on how many hours, minutes, or seconds there are. Finally, we draw the polygons for the hands.

Next, we have our globals and setup():

uint32_t update_ts;
uint32_t sync_count;
time_t current_time;
srect16 clock_rect;
using clock_bmp_t = bitmap<typename lcd_t::pixel_type>;
uint8_t clock_bmp_buf[clock_bmp_t::sizeof_buffer(clock_size)];
clock_bmp_t clock_bmp(clock_size, clock_bmp_buf);
void setup() {
    Serial.begin(115200);
    lcd.fill(lcd.bounds(),color_t::white);
    Serial.print("Connecting");
    WiFi.begin(ssid, password);
    while (!WiFi.isConnected()) {
        Serial.print(".");
        delay(1000);
    }
    Serial.println();
    Serial.println("Connected");
    clock_rect = srect16(spoint16::zero(), (ssize16)clock_size);
    clock_rect.center_inplace((srect16)lcd.bounds());
    sync_count = sync_seconds;
    current_time = worldtime::now(utc_offset);
    update_ts = millis();
}

In the globals, we keep a timestamp, and the count of seconds until we sync, followed by the current time and a rectangle where the clock goes, so we only have to compute it once.

We also declare a bitmap and a buffer to hold it. We use this bitmap to draw to and then blt that to the screen so the drawing is smoother. This is called double buffering.

In the setup() method, we fill the screen with white, connect to the WiFi, and then compute the clock rectangle, set the countdown until the next sync, sync the time, and then prepare the timestamp.

Next, we have the loop() method:

void loop() {
    uint32_t ms = millis();
    if (ms - update_ts >= 1000) {
        update_ts = ms;
        ++current_time;
        tm* t = localtime(&current_time);
        Serial.println(asctime(t));
        draw::wait_all_async(lcd);
        draw::filled_rectangle(clock_bmp,
                              clock_size.bounds(),
                              color_t::white);
        draw_clock(clock_bmp, *t, (ssize16)clock_size);
        draw::bitmap_async(lcd,
                          clock_rect,
                          clock_bmp,
                          clock_bmp.bounds());
        if (0 == --sync_count) {
            sync_count = sync_seconds;
            current_time = worldtime::now(utc_offset);
        }
    }
}

We use the standard pattern for only running once a second, which is what update_ts was for. During that second, we increment the current time, and create a tm structure out of it. We dump that to the serial port. Next, we tell GFX to wait until all of the pending operations to lcd are complete. This is because we draw asynchronously for the best performance, and while it should never take more than a second, if it ever did and we didn't have this line, it would cause memory corruption and possibly an exception. The reason is you can't write to a buffer as it's being transferred to a device in the background.

Next, we clear the clock bitmap we created earlier by filling it with a white rectangle. Then we draw the clock to it at the current time.

Now we send the bitmap asynchronously to the device. This uses DMA to do the transfer so the call returns immediately and the transfer continues in the background, yielding slightly better performance.

Finally, we make the request to sync the clock if necessary. This is where our DMA transfer from before comes in handy, as it allows us to talk to the display as well as the WiFi hardware concurrently instead of consecutively, hopefully eliminating the potential for a "skip" in the clock when it synchronizes, but that all depends on network bandwidth and latency.

Points of Interest

I've included a small True Type font called "telegrama" in the include folder. This can be used to print text messages, or a digital clock to the display using GFX. I avoided cutting down on complexity for this project.

History

  • 10th June, 2022 - Initial submission
  • 11th June, 2022 - Updated GitHub version to use NTP