Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / IoT

An IoT Smart Clock Using an ESP32 Development Board

5.00/5 (6 votes)
22 Nov 2020MIT9 min read 11.5K   184  
Revisiting our WiFi enabled smart clock project with more modern hardware
Using an ESP32 dev board, learn how to create a smart clock that displays the UTC time, the temperature and humidity information while syncing the time using the Internet and exposing a small local website that reports the same information.

ESP32 IoT Smart Clock

Introduction

In this article, we revisit the IoT Smart Clock using newer hardware. Specifically, we'll be using an ESP32 on a development board rather than an Arduino Mega 2560 WiFi and we'll be using a fresh 128x32 pixel monochrome OLED display instead of the old liquid crystal display. Like the parts they replace, these new parts are inexpensive and readily available at online electronics retailers.

The Espressif ESP32 is the successor to the ESP8266. Remember the WiFi module whose CPU we hijacked in the prior project in order to run a web server on it? That one. This time, there's no hijacking required, as the ESP32 was designed from the start not as a WiFi module but as a first class IoT board, with WiFi capabilities.

The stock ESP32 chips are 3.3vdc SoCs that ship with 4MB of flash, with custom order chips supporting as much as 16MB. They also feature advanced "deep sleep" capabilities aided by a tiny coprocessor that can wake up the CPU on a number of configurable events and can operate at a max frequency of 240Mhz. In addition to WiFi b/g/n, they support Bluetooth, including BLE, as well as ESP-NOW - a built in proprietary radio communication protocol. The chips contain a hall effect sensor that can detect magnetic fields, several touch capacitive pins, I/O pin muxing, and more.

They usually ship on development boards that contain a USB bridge for programming or serial access and can also be used to power the board in lieu of another 3.3vdc source. I recently bought 5 of these for $5 each.

Prerequisites

You'll need an ESP32 based development board such as a NodeMCU ESP32s or DOIT devkit 01.

You'll need a DS1307 or similar Real-time Clock.

You'll need a DHT11 temperature and humidity sensor.

You'll need a SSD1306 128x32 OLED display with I2C interface.

You'll need the usual wires and prototype board.

You'll need the Arduino IDE and you'll need to install the "esp32" board manager from Tools|Board Managers...

You will also need to download several libraries, but I've included the links to the libraries in the source code above each include. Download them to a folder and then in the directory where your sketches are stored, look for or create a "libraries" folder and add each library in its own folder.

Conceptualizing this Mess

Finding Your Way Around the ESP32

The ESP32 is a powerful 32-bit 240MHz max processor with integrated WiFi and Bluetooth as well as ESP-NOW, a proprietary radio communication facility. It typically ships with 4MB of flash and has a generous set of muxed I/O pins that you can use to interface with whatever.

With our previous article, we had to do something of a hack to get the project to work right because we used the WiFi processor to do most of the actual work and slaved the ATmega processor to it. You also had to fiddle with dip switches in order to program the thing. With an ESP32 dev board, all of that is over. We can use the ESP32 directly without hacking anything and it drives the I/O itself, unlike before where the ESP8266 delegated using serial to the ATmega to drive I/O.

An Important Notice About Voltage

Unlike the typical Arduino offerings, the ESP32 is a 3.3VDC chip, not 5VDC. Most dev boards come with a 5VDC supply that runs off the USB but do not feed that voltage back into the input pins. You probably won't damage your chip if you feed 5VDC into it but you don't need to be taking those kinds of unnecessary risks with your hardware, either. It's not so much the cost of replacing a $5 chip that's the worry, but all the time you'll waste tracking down a wiring problem when the real issue is a fried chip. You don't want that.

A Minor Fix For Older Boards

You don't need to mess with any dip switches although if the board gives you timeouts trying to program it, then putting a 10uF capacitor between EN and GND should sort things out nicely. If you don't have a cap, you'll simply have to hold the boot button on the dev board during the entire uploading process. Newer boards don't put you through this.

Which Pin is What?

Another caveat is that each dev board has its own pin layout and not all of them are readily identifiable. Sometimes, if you get a generic board, it will not come with pinouts or labels on the board and you'll have to go to a datasheet or find the board it is a clone of (all the generics seem to be clones of "name brand" boards like NodeMCU but they don't usually tell you which they are a clone of). Some generic boards like the Hiletgo come with a pinout.

Regardless of pin layout, pins have labels that are more or less universal. "GPIO1" (sometimes called "D1") will always be GPIO1 regardless of its physical location on the board. When wiring, try to find out which GPIO number the pin is. That way, you wire things consistently no matter which board you are using.

A Note About Libraries and Tools

Some of the libraries and tools for the ESP32 aren't as mature as they are for the Arduino and the ESP8266. This means occasionally rolling our own functionality such as with WiFi WPS support. Another tool that is lacking for the ESP32 is a working SPIFFS file uploader. The SPIFFS library itself works with ESP32 but the uploader tool does not work with this setup. We won't be using SPIFFS here for that reason.

Working With the Form Factor

These dev boards can be a little wide for when it comes to prototyping on a standard solderless breadboard. Often there is only one row of pins exposed on one side when using these chips. In that situation, you may find it's best to straddle the chip across two prototype boards, probably removing the power rail on at least one of them.

The Clock Features

This is largely a rehash of information in the previous IoT clock article linked to earlier but it is included here for completeness and updated to reflect the new design.

Our clock reports the UTC time in 24 hour format, the temperature and the humidity. It does this on the OLED display, as well as http://clock.local. HTTPS is not yet supported. The "webservice" for this clock is simply http://clock.local/time.json.

When it is first powered on, and then every 5 minutes the clock will sync with an NTP server so that the time is always up to date.

In addition to the display items already mentioned, the screen will display a small icon when connected and an additional icon when syncing the time. Each of these will be displayed on the right hand side of the screen.

To configure the clock for your network, simply turn the clock on and then touch the WPS button on your wireless router.

How it Works

The most involved part is managing the WiFi connection without using delay(). We want everything to happen without blocking so that we keep the CPU free to update the clock every second. We use a state machine and some timestamp variables to do the heavy lifting. When looking to connect, the clock will cycle between attempting to connect and trying to find a WPS signal.

The hardest part of what's left is formatting and display. I simply settled on the final result after some trial and error to see what I liked.

Using the various libraries, gathering the information from the clock and the sensor is really simple. So is waiting for an incoming HTTP request on clock.local.

The main wrinkle here is waiting until we connect to the network before initializing the UDP or the web server.

That's about all there is so let's get to building.

Building this Mess

Wiring this Mess

I've included the wiring instructions at the end of the .ino file but we'll go over them here. This wiring is simpler than the last project, primarily due to the screen using an I2C interface instead of a Hitachi 16 pin interface.

SSD1306

GND ➜ ESP32 GND
VCC ➜ ESP32 3.3VDC
SCL ➜ ESP32 GPIO22
SDA ➜ ESP32 GPIO21

DS1307

GND ➜ ESP32 GND
VCC ➜ ESP32 5VDC VIN
SCL ➜ SSD1306 SCL
SDA ➜ SSD1306 SDA

DHT11

GND (right) ➜ ESP32 GND
VCC (middle) ➜ ESP32 3.3VDC
S (left) ➜ ESP32 GPIO4

Coding this Mess

The code here is in some ways simpler than the last project due to the lack of hacks, but because we can't use SPIFFS efficiently, the web content and the screen icons have been embedded directly in the source code. This makes the source code somewhat awkward to just jam into an article in its entirety so we'll be exploring sections at a time. Let's start with some of the initialization code in setup():

C++
// SSD1306_SWITCHCAPVCC = generate display voltage from 3.3V internally
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { // Address 0x3C for 128x32
    Serial.println(F("SSD1306 allocation failed"));
    while (true);
}
// look for the clock
if (! rtc.begin()) {
    Serial.println(F("Couldn't find RTC"));
while (true);
}
// try to jumpstart if not running
if (! rtc.isrunning()) {
    rtc.adjust(DateTime((uint32_t)0));
}
// halt if not running
if (! rtc.isrunning()) {
    Serial.println(F("RTC is NOT running!"));
    while(true);
}
// Clear the buffer
display.setTextColor(SSD1306_WHITE);
_tmp = DHT.temperature;
_hum = DHT.humidity;
display.clearDisplay();
display.setTextSize(2);

DateTime now = rtc.now();

char sz[16];
sprintf(sz, "%d:%02d:%02d",
        now.hour(),
        now.minute(),
        now.second());
uint16_t h = drawCentered(sz, display.width(), 0);
display.setTextSize(1);

h = drawCentered("Temp: --/--", display.width(), h) + h;
drawCentered("Humidity: --", display.width(), h);
display.display();
_ts = _ntpts= millis();

Mostly what we're doing here is initializing the hardware. If the RTC hardware is not running, we reset the time so that it is. Finally, we put the initial display up on the screen. The last line simply primes the various timestamps we use.

Next, there is the loop() routine where we do most of our work. First, there's a state machine that gets run through whenever we're not connected. The job of the state machine is to move through connecting and WPS searching phases until a network connection can be made. Inside the routine, we use _connect_ts to track when we need to time out the connection attempt:

C++
if (WL_CONNECTED != WiFi.status())
{
  switch (_connect_state)
  {
  case 0: // connect start
    _connect_ts = millis();
    WiFi.begin();
    _connect_state = 1; // connecting
    _connect_ts = millis();
    break;
  case 1: // connect continue
    if (WL_CONNECTED == WiFi.status())
    {
      _connect_state = 4; // connected
    }
    else if (millis() - _connect_ts > (CONNECT_TIMEOUT * 1000))
    {
      WiFi.disconnect();
      _connect_ts = millis();
      _connect_state = 2; // begin wps search
    }
    break;
  case 2: // begin WPS search
    _connect_ts = millis();
    esp_wifi_wps_enable(&_wps_config);
    esp_wifi_wps_start(0);
    _connect_state = 3; // continue WPS search
    break;
  case 3: // continue WPS search
    // handled by callback
    break;
  case 4: // got disconnected
    _connect_state = 1; // connecting
    _connect_ts = millis();
    WiFi.reconnect();
    break;
  }
}

Next, if 5 minutes have passed, we fire off an NTP packet - I should really put that in a #define:

C++
// if we are connected, send an NTP request every 5 minutes
if (WL_CONNECTED == WiFi.status())
{
  if (millis() - _ntpts > 300000)
  {
    _ntpts = millis();
    _trafficNTP = true;
    WiFi.hostByName(NTP_SERVER, _ntpIP);
    sendNTPpacket(_ntpIP); // send an NTP packet to a time server
  }
}

Now for every second that elapses, we need to read the RTC and the DHT11's values and then update the display:

C++
// update the display every second
if (millis() - _ts > 1000)
{
  _ts = millis();
  DateTime now = rtc.now();
  DHT.read11(DHT11PIN);
  uint16_t h = 0;
  float t = DHT.temperature;
  float hm = DHT.humidity;
  // sometimes the values the DHT11
  // returns are fugazi so we have
  // to check for that
  if (-999.0 < t)
    _tmp = t;
  if (-999.0 < hm)
    _hum = hm;
  // now draw the clock:
  display.clearDisplay();
  display.setTextSize(2);
  char sz[16];
  sprintf(sz, "%d:%02d:%02d",
          now.hour(),
          now.minute(),
          now.second());
  h = drawCentered(sz, display.width(), h);
  display.setTextSize(1);
  if (-999.0 < _tmp)
  {
    sprintf(sz, "Temp: %dC/%dF", (int)(_tmp + .5), (int)((_tmp * 1.8) + 32.5));
    h = drawCentered(sz, display.width(), h) + h;
  }
  else
    h = drawCentered("Temp: --/--", display.width(), h) + h;
  if (-999.0 < _hum)
  {
    sprintf(sz, "Humidity: %d%%", (int)(_hum + .5));
    h = drawCentered(sz, display.width(), h) + h;
  }
  else
    h = drawCentered("Humidity: --", display.width(), h);

Note above that none of that changes what's on the display until display.display() is called. That way, we can queue up several draw operations and have them draw all at once, reducing flicker.

Now, if we're connected, we need to start up the webserver once we've realized we're connected. We need to draw the online and sync indicators if needed. Finally, we need to listen for incoming NTP packets we can use to sync the clock:

C++
// if we're connected
if (WL_CONNECTED == WiFi.status())
{
  if (_firstConnect)
  {
    // if this is the first connection:
    _firstConnect = false;
    // start our UDP listener
    udp.begin(UDP_LOCALPORT);
    // start clock.local domain
    MDNS.begin("clock");
    // web handlers
    _server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
      digitalWrite(2, HIGH);
      request->send_P(200, "text/html", index_html, process);
      digitalWrite(2, LOW);
    });
    _server.on("/time.json", HTTP_GET, [](AsyncWebServerRequest *request) {
      digitalWrite(2, HIGH);
      request->send_P(200, "application/json", time_json, process);
      digitalWrite(2, LOW);
    });
    // start HTTP server
    _server.begin();
    _ntpts = millis();
    // sync the clock
    WiFi.hostByName(NTP_SERVER, _ntpIP);
    _trafficNTP = true;
    sendNTPpacket(_ntpIP); // send an NTP packet to a time server
  }
  // if we're online, show the connected bmp
  display.drawBitmap(
      display.width() - 10,
      0,
      wifi_bmp, 8, 8, 1);
  // if we're waiting for an NTP response
  // and it hasn't been 5 seconds yet display
  // the syncing icon
  if (_trafficNTP && millis() - _ntpts < 5000)
  {
    display.drawBitmap(
        display.width() - 10,
        10,
        sync_bmp, 8, 8, 1);
  }
  // if we got a packet from NTP, read it
  if (0 < udp.parsePacket())
  {
    _trafficNTP = false;
    udp.read(_packetBuffer, NTP_PACKET_SIZE); // read the packet into the buffer

    //the timestamp starts at byte 40 of the received packet and is four bytes,
    // or two words, long. First, esxtract the two words:

    unsigned long highWord = word(_packetBuffer[40], _packetBuffer[41]);
    unsigned long lowWord = word(_packetBuffer[42], _packetBuffer[43]);
    // combine the four bytes (two words) into a long integer
    // this is NTP time (seconds since Jan 1 1900):
    unsigned long secsSince1900 = highWord << 16 | lowWord;
    // Unix time starts on Jan 1 1970. In seconds, that's 2208988800:
    const unsigned long seventyYears = 2208988800UL;
    // subtract seventy years:
    unsigned long epoch = secsSince1900 - seventyYears;
    // use the data to set the clock
    rtc.adjust(DateTime(epoch));
  }
}
display.display();

One of the things above that might not be so obvious is our timeout on the drawing of sync_bmp. The reason we do this is because we're using UDP and that's lossy. If we lose a packet, we don't want the sync icon to stay lit for 5 minutes - when the next sync packet is sent. We want it to go away instead. That's why it times out.

We haven't covered the HTML and JSON content here, but it's a cut and paste job from the last project, and it works the exact same way. The only difference is instead of being served out of a file in flash, it's embedded directly into the top of the source code.

History

  • 22nd November, 2020 - Initial submission

License

This article, along with any associated source code and files, is licensed under The MIT License