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

A Web Enabled Smart Clock and Weather Station

5.00/5 (2 votes)
16 Jun 2022MIT12 min read 7.2K   87  
Create a clock that uses multiple Internet services to detect your weather, date and time
This weather clock project is essentially a run through of using WPS, web services, NTP and GFX on an ESP32. It provides automatic detection of your GPS location using your IP address, and then fetches the weather and time for your time zone, displaying it with simple icons and an analog clock.

Weather Clock

Introduction

IoT devices are fundamentally connectable. Let's do some serious Internetting with an ESP32, to give you your location, weather and local time and date. This project uses multiple technologies, including WPS, REST services, and NTP to provide a fully automatic experience.

Coding This Mess

Note: Be sure to upload the SPIFFs image before running this project.

There are several parts to this project, and we'll tackle them file by file, covering main.cpp last, since it ties everything together.

open_weather_map_api_key.h

Let's start with a file you don't have. You need to create open_weather_api_key.h in your project and add the API key you generated from Open Weather Map:

C++
#ifndef OPEN_WEATHER_API_KEY
#define OPEN_WEATHER_API_KEY "my_api_key"
#endif 

ip_loc.cpp

Now let's check out the geolocation service:

C++
#include <ip_loc.hpp>
#include <HTTPClient.h>
namespace arduino {
bool ip_loc::fetch(float* out_lat,
                float* out_lon, 
                long* out_utc_offset, 
                char* out_region, 
                size_t region_size, 
                char* out_city, 
                size_t city_size) {
    // URL for IP resolution service
    constexpr static const char* url = 
        "http://ip-api.com/csv/?fields=lat,lon,region,city,offset";
    HTTPClient client;
    client.begin(url);
    if(0>=client.GET()) {
        return false;
    }
    Stream& stm = client.getStream();

    String str = stm.readStringUntil(',');
    int ch;
    if(out_region!=nullptr && region_size>0) {
        strncpy(out_region,str.c_str(),min(str.length(),region_size));
    }
    str = stm.readStringUntil(',');
    if(out_city!=nullptr && city_size>0) {
        strncpy(out_city,str.c_str(),min(str.length(),city_size));
    }
    float f = stm.parseFloat();
    if(out_lat!=nullptr) {
        *out_lat = f;
    }
    ch = stm.read();
    f = stm.parseFloat();
    if(out_lon!=nullptr) {
        *out_lon = f;
    }
    ch = stm.read();
    long lt = stm.parseInt();
    if(out_utc_offset!=nullptr) {
        *out_utc_offset = lt;
    }
    client.end();
    return true;
}
}

All of the essential work is done in the fetch() method. What we're doing is making an HTTP request to ip-api.com's service, and getting it back in CSV format. At that point, we simply pick off each of the fields and copy them into our return values.

mpu6886.hpp/mpu6886.cpp

This drives the MPU6886 accelerometer/gyroscope/temperature sensor in the M5 Stack. It's actually a Platform IO library I made, but I copied the operative files locally because I was fiddling with the temperature output. The datasheet strongly implies you need to add 25 degrees celsius to it, but in my experience, that makes it even more inaccurate than it already is. I won't be covering this file here since it's sort of out of the scope of what we're exploring and it's relatively lengthy.

I should point out that in my tests, the temperature sensor is not accurate. However, I've included it in the interest of completeness. You can always modify the software and circuit to include your own temperature sensor.

ntp_time.cpp

This file sends NTP requests and collects the time data. By default, it uses time.nist.gov as its time server, but you can use a different one. The NTP protocol is UDP based so it's potentially lossy and with this library you must poll for a response. Here's the relevant code:

C++
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClient.h>
#include <WiFiUdp.h>

#include <ntp_time.hpp>
namespace arduino {
WiFiUDP g_ntp_time_udp;
void ntp_time::begin_request(IPAddress address, 
                            ntp_time_callback callback, 
                            void* callback_state) {
    memset(m_packet_buffer, 0, 48);
    m_packet_buffer[0] = 0b11100011;   // LI, Version, Mode
    m_packet_buffer[1] = 0;     // Stratum, or type of clock
    m_packet_buffer[2] = 6;     // Polling Interval
    m_packet_buffer[3] = 0xEC;  // Peer Clock Precision
    // 8 bytes of zero for Root Delay & Root Dispersion
    m_packet_buffer[12]  = 49;
    m_packet_buffer[13]  = 0x4E;
    m_packet_buffer[14]  = 49;
    m_packet_buffer[15]  = 52;

    //NTP requests are to port 123
    g_ntp_time_udp.beginPacket(address, 123); 
    g_ntp_time_udp.write(m_packet_buffer, 48);
    g_ntp_time_udp.endPacket();
    m_request_result = 0;
    m_requesting = true;

    m_callback_state = callback_state;
    m_callback = callback;
}

void ntp_time::update() {
    m_request_result = 0;
        
    if(m_requesting) {
        // read the packet into the buffer
        // if we got a packet from NTP, read it
        if (0 < g_ntp_time_udp.parsePacket()) {
            g_ntp_time_udp.read(m_packet_buffer, 48); 

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

            unsigned long hi = word(m_packet_buffer[40], m_packet_buffer[41]);
            unsigned long lo = word(m_packet_buffer[42], m_packet_buffer[43]);
            // combine the four bytes (two words) into a long integer
            // this is NTP time (seconds since Jan 1 1900):
            unsigned long since1900 = hi << 16 | lo;
            // Unix time starts on Jan 1 1970. In seconds, that's 2208988800:
            constexpr const unsigned long seventyYears = 2208988800UL;
            // subtract seventy years:
            m_request_result = since1900 - seventyYears;
            m_requesting = false;
            if(m_callback!=nullptr) {
                m_callback(m_request_result,m_callback_state);
            }
        }
    }
}
}

So here's the deal: First, you can optionally use callback to set a callback method. You call begin_request() to initiate an NTP request for the current time. At that point, you must poll for a response using update(). If you're not using a callback, you check request_received() to see if you got a result. If you did, you can use request_result() to get the result as a time_t in UTC. If you are using a callback, it will be fired as soon as update() is called after the response is received.

Most of this is just mangling data to make NTP work. However, we also manage whether or not we are currently requesting, and in the update() method we check for a packet, decode it, set the relevant variables and then fire the callback if it was set.

open_weather.cpp

This file handles the Open Weather Map API service, which returns JSON. We use ArduinoJSON to process it since it's a relatively small infoset. It's pretty straightforward. We just copy all the relevant JSON fields into a giant struct. Everything is metric internally. The only weirdness is the JSON itself, which is kind of clunky:

C++
#include <open_weather.hpp>
#include <ArduinoJson.hpp>
#include <HTTPClient.h>
#include "open_weather_api_key.h"
using namespace ArduinoJson;
namespace arduino {
bool open_weather::fetch(float latitude, 
                        float longitude,
                        open_weather_info* out_info) {
    constexpr static const char *url_format = 
        "http://api.openweathermap.org/data/2.5/weather?lat=%f&lon=
         %f&units=metric&lang=en&appid=%s";
    if(out_info==nullptr) { 
        return false;
    }
    char url[512];
    sprintf(url,url_format,latitude,longitude,OPEN_WEATHER_API_KEY);
    HTTPClient client;
    client.begin(url);
    if(0>=client.GET()) {
        return false;
    }
    DynamicJsonDocument doc(8192);
    deserializeJson(doc,client.getString());
    client.end();
    JsonObject obj = doc.as<JsonObject>();
    String str = obj[F("name")];
    strncpy(out_info->city,str.c_str(),64);
    out_info->visiblity = obj[F("visibility")];
    out_info->utc_offset = (long)obj[F("timezone")];
    out_info->timestamp = (time_t)(long)obj[F("dt")];
    JsonObject so = obj[F("weather")].as<JsonArray>().getElement(0).as<JsonObject>();
    str=so[F("main")].as<String>();
    strncpy(out_info->main,str.c_str(),32);
    str=so[F("description")].as<String>();
    strncpy(out_info->description,str.c_str(),128);
    str=so[F("icon")].as<String>();
    strncpy(out_info->icon,str.c_str(),8);
    so = obj[F("main")].as<JsonObject>();
    out_info->temperature = so[F("temp")];
    out_info->feels_like  = so[F("feels_like")];
    out_info->pressure = so[F("pressure")];
    out_info->humidity = so[F("humidity")];
    so = obj[F("wind")].as<JsonObject>();
    out_info->wind_speed = so[F("speed")];
    out_info->wind_direction = so[F("deg")];
    out_info->wind_gust = so[F("gust")];
    so = obj[F("clouds")].as<JsonObject>();
    out_info->cloudiness = so[F("all")];
    so = obj[F("rain")];
    out_info->rain_last_hour = so[F("1h")];
    so = obj[F("snow")];
    out_info->snow_last_hour = so[F("1h")];
    so = obj[F("sys")];
    out_info->sunrise = (time_t)(long)so[F("sunrise")];
    out_info->sunset = (time_t)(long)so[F("sunset")];
    return true;
}
}

As you can see, all we do is build the URL and then rip the result apart and pack it into a struct.

wifi_wps.cpp

This file is significantly more complicated than anything we've dealt with so far. The key here is a state machine in update() that handles the various stages of the connection process. The other major thing here is handling the events we get back from the WiFi subsystem. This code automatically scans for WPS signals if it can't connect. If it finds one it uses it, and then stores the credentials for later - said storage is handled automatically by the WiFi subsystem. At any point, if it disconnects, it tries to reconnect. If the WiFi credentials change or are otherwise rendered invalid, again a WPS scan will be initiated. Since we're using events, we can handle everything without freezing up the app waiting for connections to complete. The entire thing operates in the background. You can optionally receive a callback when the WiFi connects or disconnects. Note that we use an atomic integer for our state. This is because the WiFi events can happen on a separate thread so we have to be careful about modifying the field to avoid a race condition. The atomic type handles that for us. We use globals in the source file to simplify everything since the callback doesn't take state.

C++
#include <wifi_wps.hpp>
#include <Wifi.h>
namespace arduino {
std::atomic_int wifi_wps_state;
uint32_t wifi_wps_connect_ts;
static esp_wps_config_t wifi_wps_config;
wifi_wps_callback fn_wifi_wps_callback;
void* wifi_wps_callback_state;
uint32_t wifi_wps_connect_timeout;
void wifi_wps_wifi_event(arduino_event_t* event) {
  switch (event->event_id) {
    case ARDUINO_EVENT_WIFI_STA_GOT_IP:
      wifi_wps_connect_ts = millis();
      wifi_wps_state = 4; // connected
      break;
    case ARDUINO_EVENT_WPS_ER_SUCCESS:
      esp_wifi_wps_disable();
      delay(10);
      wifi_wps_connect_ts = millis();
      wifi_wps_state = 1; // connecting
      WiFi.begin();
      break;
    case ARDUINO_EVENT_WPS_ER_FAILED:
      esp_wifi_wps_disable();
      wifi_wps_connect_ts = millis();
      wifi_wps_state = 0; // connect
      break;
    case ARDUINO_EVENT_WPS_ER_TIMEOUT:
      esp_wifi_wps_disable();
      wifi_wps_connect_ts = millis();
      wifi_wps_state = 0; // connect
      break;
    case ARDUINO_EVENT_WPS_ER_PIN:
      // not used yet
      break;
    default:
      break;
  }
}
wifi_wps::wifi_wps(uint32_t connect_timeout) {
    wifi_wps_state = -1;
    fn_wifi_wps_callback = nullptr;
    wifi_wps_callback_state = nullptr;
    wifi_wps_connect_timeout = connect_timeout;
}
void wifi_wps::callback(wifi_wps_callback callback, void* state) {
    fn_wifi_wps_callback = callback;
    wifi_wps_callback_state = state;
    
}
void wifi_wps::update() {
    if (wifi_wps_state==-1) {
        wifi_wps_config.wps_type = WPS_TYPE_PBC;
        strcpy(wifi_wps_config.factory_info.manufacturer, "ESPRESSIF");
        strcpy(wifi_wps_config.factory_info.model_number, "ESP32");
        strcpy(wifi_wps_config.factory_info.model_name, "ESPRESSIF IOT");
        strcpy(wifi_wps_config.factory_info.device_name, "ESP32");
        WiFi.onEvent( wifi_wps_wifi_event);
        wifi_wps_connect_ts = millis();
        wifi_wps_state=0;
    }
    if(WiFi.status()!= WL_CONNECTED) {
        switch(wifi_wps_state) {
            case 0: // connect start
            wifi_wps_connect_ts = millis();
            WiFi.begin();
            wifi_wps_state = 1;
            wifi_wps_connect_ts = millis();
            break;
        case 1: // connect continue
            if(WiFi.status()==WL_CONNECTED) {
                wifi_wps_state = 4;
                if(fn_wifi_wps_callback != nullptr) {
                    fn_wifi_wps_callback(true,wifi_wps_callback_state);
                }
                Serial.println("WiFi connected to ");
                Serial.println(WiFi.SSID());
                
            } else if(millis()-wifi_wps_connect_ts>=wifi_wps_connect_timeout) {
                WiFi.disconnect();
                wifi_wps_connect_ts = millis();
                // begin wps_search
                wifi_wps_state = 2;
            }
            break;
        case 2: // WPS search
            wifi_wps_connect_ts = millis();
            esp_wifi_wps_enable(&wifi_wps_config);
            esp_wifi_wps_start(0);
            wifi_wps_state = 3; // continue WPS search
            break;
        case 3: // continue WPS search
            // handled by callback
            break;
        case 4:
            wifi_wps_state = 1; // connecting
            if(fn_wifi_wps_callback != nullptr) {
                fn_wifi_wps_callback(false,wifi_wps_callback_state);
            }
            wifi_wps_connect_ts = millis();
            WiFi.reconnect();
        }
    }
}
}  // namespace arduino

draw_screen.hpp

This source file has the code for drawing the clock and the weather. Here's the top of the file where we just include some headers:

C++
#pragma once
#include <Arduino.h>
#include <SPIFFS.h>
#include <open_weather.hpp>
#include <gfx_cpp14.hpp>
#include <telegrama.hpp>

Now, the first bit of actual code draws the weather icon:

C++
template <typename Destination>
void draw_weather_icon(Destination& dst, arduino::open_weather_info& info,
                       gfx::size16 weather_icon_size) {
    const char* path;
    if(!strcmp(info.icon,"01d")) {
        path = "/sun.jpg";
    } else if(!strcmp(info.icon,"01n")) {
        path = "/moon.jpg";
    } else if(!strcmp(info.icon,"02d")) {
        path = "/partly.jpg";
    } else if(!strcmp(info.icon,"02n")) {
        path = "/partlyn.jpg";
    } else if(!strcmp(info.icon,"10d") || !strcmp(info.icon,"10n")) {
        path = "/rain.jpg";
    } else if(!strcmp(info.icon,"04d") || !strcmp(info.icon,"04n")) {
        path = "/cloud.jpg";
    } else if(!strcmp(info.icon,"03d") || !strcmp(info.icon,"03n")) {
        if(info.snow_last_hour>0) {
            path = "/snow.jpg";
        } else if(info.rain_last_hour>0) {
            path = "/rain.jpg";
        } else {
            path = "/cloud.jpg";
        }
    } else {
        path = nullptr;
    }
    
    if(path!=nullptr) {
        File file = SPIFFS.open(path,"rb");
        gfx::draw::image( dst,dst.bounds(),&file);
        file.close();
    } else {
        Serial.print("Icon not recognized: ");
        Serial.println(info.icon);
        Serial.println(info.main);
        gfx::draw::filled_rectangle(dst,
                                    weather_icon_size.bounds(),
                                    gfx::color<typename Destination::pixel_type>::white);
    }
}

What we're doing is getting the icon string from our weather info, and then trying to find the best icon that matches it. The icons are 48x48 JPGs stored in flash via SPIFFS. Then we draw it to the Destination dst. You'll note that this is a template function. The reason these drawing functions are template functions is so they can draw to any type of GFX draw destination. GFX does not use virtual class interfaces to bind. It uses source level binding by way of templates. Therefore, the draw target types are accepted as template parameters.

This routine does not yet cover all possible icons results. If there is one that is unrecognized, it will dump it to the serial port and display no icon.

Next, we draw the temperature text:

C++
template <typename Destination>
void draw_temps(Destination& dst, arduino::open_weather_info& info, float inside) {
    char sz[32];
    float tmpF = info.temperature*1.8+32;
    sprintf(sz,"%.1fF",tmpF);
    float fscale = Telegrama_otf.scale(24);
    gfx::draw::text(dst,
                    dst.bounds().offset(0,12*(inside!=inside)),
                    gfx::spoint16::zero(),
                    sz,
                    Telegrama_otf,
                    fscale,
                    gfx::color<typename Destination::pixel_type>::black);
    if(inside==inside) {
        tmpF = inside*1.8+32;
        sprintf(sz,"%.1fF",tmpF);
        gfx::draw::text(dst,
                        dst.bounds().offset
                        (0,dst.dimensions().height/2).crop(dst.bounds()),
                        gfx::spoint16::zero(),
                        sz,
                        Telegrama_otf,
                        fscale,
                        gfx::color<typename Destination::pixel_type>::blue);
    }
}

This is pretty straightforward. We draw the outside temperature from our weather information, and then the inside temperature if it's available. If it's not, we center the weather temperature vertically. Note that the temperature values are converted from celsius to farenheit.

Now onto drawing the clock. This is a bit more involved:

C++
template <typename Destination>
void draw_clock(Destination& dst, time_t time, const gfx::ssize16& size) {
    using view_t = gfx::viewport<Destination>;
    gfx::srect16 b = size.bounds().normalize();
    uint16_t w = min(b.width(), b.height());
    
    float txt_scale = Telegrama_otf.scale(w/10);
    char* sz = asctime(localtime(&time));
    *(sz+3)=0;
    gfx::draw::text(dst,
            dst.bounds(),
            gfx::spoint16::zero(), 
            sz,
            Telegrama_otf,
            txt_scale,
            gfx::color<typename Destination::pixel_type>::black);
    sz+=4;
    *(sz+6)='\0';

    gfx::ssize16 tsz = Telegrama_otf.measure_text(gfx::ssize16::max(),
                                                gfx::spoint16::zero(),
                                                sz,
                                                txt_scale);
    gfx::draw::text(dst,
            dst.bounds().offset(dst.dimensions().width-tsz.width-1,0).crop(dst.bounds()),
            gfx::spoint16::zero(), 
            sz,
            Telegrama_otf,
            txt_scale,
            gfx::color<typename Destination::pixel_type>::black);

    gfx::srect16 sr(0, 0, w / 16, w / 5);
    sr.center_horizontal_inplace(b);
    view_t view(dst);
    view.center(gfx::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);
        gfx::spoint16 marker_points[] = {
            view.translate(gfx::spoint16(sr.x1, sr.y1)),
            view.translate(gfx::spoint16(sr.x2, sr.y1)),
            view.translate(gfx::spoint16(sr.x2, sr.y2)),
            view.translate(gfx::spoint16(sr.x1, sr.y2))};
        gfx::spath16 marker_path(4, marker_points);
        gfx::draw::filled_polygon(dst, marker_path, 
          gfx::color<typename Destination::pixel_type>::gray);
    }
    sr = gfx::srect16(0, 0, w / 16, w / 2);
    sr.center_horizontal_inplace(b);
    view.rotation(((time%60) / 60.0) * 360.0);
    gfx::spoint16 second_points[] = {
        view.translate(gfx::spoint16(sr.x1, sr.y1)),
        view.translate(gfx::spoint16(sr.x2, sr.y1)),
        view.translate(gfx::spoint16(sr.x2, sr.y2)),
        view.translate(gfx::spoint16(sr.x1, sr.y2))};
    gfx::spath16 second_path(4, second_points);

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

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

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

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

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

There's a lot of code here, but the idea is relatively straightforward. For every marker or hand on the dial, we start it by drawing a rectangle (as a polygon) at the 12:00 position. Then we rotate it using GFX's viewport<> facility. Once it's rotated, we draw it.

We start by drawing the date. We do this by cheesily hacking the result of asctime() to insert null character terminators at the end of the day and date. We then use those partial strings to print the date at the top of the clock face.

Next, we draw the markers around the dial in a loop.

Now onto computing the hands. For each hand, we find the number of units - hours, minutes or seconds that have elapsed, and then adjust that figure to scale between 0 and 1 before multiplying it by 360 to find the angle. We then rotate the rectangle/polygon for that hand by said amount. We do this for each hand.

Finally, we draw the polygon for each hand.

main.cpp

Finally, we can tie everything together. All of the rest of the significant code is in this file. Let's start at the top:

C++
#define M5STACK
#include <Arduino.h>
#include <SPIFFS.h>
#include <WiFi.h>
#include <WiFiMulti.h>
#include <gfx_cpp14.hpp>
#include <ili9341.hpp>
#include <tft_io.hpp>
#include <telegrama.hpp>
#include <wifi_wps.hpp>
#include <ip_loc.hpp>
#include <ntp_time.hpp>
#include <open_weather.hpp>
#include <draw_screen.hpp>
#ifdef M5STACK
#include <mpu6886.hpp>
#endif
using namespace arduino;
using namespace gfx; 

First, there is a #define for M5STACK. Remove it if you are not using an M5 Stack.

Now we have all the includes we need, and then we import the relevant namespaces.

Next we have our compiled configuration values. These dictate pin assignments, refresh times, and the like. We could have used defines here but I prefer using C++ constructs where possible:

C++
// NTP server
constexpr static const char* ntp_server = "time.nist.gov";

// synchronize with NTP every 60 seconds
constexpr static const int clock_sync_seconds = 60;

// synchronize weather every 5 minutes
constexpr static const int weather_sync_seconds = 60 * 5;

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

constexpr static const size16 weather_icon_size = {48, 48};

constexpr static const size16 weather_temp_size = {120, 48};

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;

Now we declare our screen driver stuff. Most GFX drivers employee a bus class that is decoupled from the driver itself. That way, you can use an ILI9341 for example, over either SPI or parallel by changing up the bus type. The driver remains the same. The following sets up a DMA capable SPI link, and the display driver. For different displays, you will have to change the display driver to a different one:

C++
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>;

Now to finish up our GFX declarations:

C++
using color_t = color<typename lcd_t::pixel_type>;

lcd_t lcd;

All we've done there is give ourselves a shorthand for getting at named 16-bit RGB colors, and then declared an instance of our lcd_t to draw to.

The next lines are for the M5 Stack only. They set up the accelerometer/gyroscope/temperature sensor which we use to grab the indoor temperature:

C++
#ifdef M5STACK
mpu6886 mpu(i2c_container<0>::instance());
#endif

See the i2c_container<0>::instance() expression? The bus framework includes a cross platform way to retrieve I2C and SPI class instances for a given host - in this case, the first host - index zero. Since each Arduino framework implementation has its own way of retrieving this, this helps keep the code consistent no matter the platform. The other thing it does is ensure more than one device can share the bus properly. Otherwise, passing this would be like passing Wire.

Next we declare a couple of our services - in this case, the WPS manager and the NTP service manager.

C++
wifi_wps wps;
ntp_time ntp;

Now we can move on to the mess of globals we use to store data for the main application functions:

C++
uint32_t update_ts;
uint32_t clock_sync_count;
uint32_t weather_sync_count;
time_t current_time;
srect16 clock_rect;
srect16 weather_icon_rect;
srect16 weather_temp_rect;
IPAddress ntp_ip;
float latitude;
float longitude;
long utc_offset;
char region[128];
char city[128];
open_weather_info weather_info;

This stuff contains our update timestamp for tracking elapsed seconds, the number of seconds that remain until the next NTP clock sync, and then the weather service sync.

Next, there is the current time in local time specified as the number of seconds since the UNIX epoch.

Then we have some rectangles we compute to indicate the positions of the various things we'll be drawing.

Next, there's the IP of the NTP server which we cache to avoid having to look it up more than once.

Now there is our latitude and longitude.

Next is the UTC offset in seconds for our local time.

After that, we have a couple of fields to store the region and city from our geolocation service.

Finally, we have a struct that holds the weather information.

Now we declare some bitmaps we're going to use for drawing. Rather than draw directly to the screen, we draw to bitmaps, and then blt those bitmaps to the screen. This is known as "double buffering" and prevents flickering while drawing:

C++
using bmp_t = bitmap<typename lcd_t::pixel_type>;
uint8_t bmp_buf[bmp_t::sizeof_buffer(clock_size)];
bmp_t clock_bmp(clock_size, bmp_buf);
bmp_t weather_icon_bmp(weather_icon_size, bmp_buf);
bmp_t weather_temp_bmp(weather_temp_size, bmp_buf);

If you look carefully, you'll notice that all of our bitmaps share the same memory buffer. The reason for that is that for each item, we draw it to a bitmap, draw the bitmap, and then move on to the next. Because of this, we only use one bitmap at any given time, so we can create one buffer large enough to hold the largest of them, and then recycle that buffer as we draw. This code assumes the clock is the largest bitmap.

Now we have our setup function:

C++
void setup() {
    Serial.begin(115200);
    SPIFFS.begin(false);
    lcd.fill(lcd.bounds(),color_t::white);
    Serial.println("Connecting...");
    while(WiFi.status()!=WL_CONNECTED) {
        wps.update();
    }
    clock_rect = srect16(spoint16::zero(), (ssize16)clock_size);
    clock_rect.offset_inplace(lcd.dimensions().width-clock_size.width ,
                            lcd.dimensions().height-clock_size.height);
    weather_icon_rect=(srect16)weather_icon_size.bounds();
    weather_icon_rect.offset_inplace(20,20);
    weather_temp_rect = (srect16)weather_temp_size.bounds().offset(68,20);
    clock_sync_count = clock_sync_seconds;
    WiFi.hostByName(ntp_server,ntp_ip);
    ntp.begin_request(ntp_ip);
    while(ntp.requesting()) {
        ntp.update();
    }
    if(!ntp.request_received()) {
        Serial.println("Unable to retrieve time");
        while (true);
    }
    ip_loc::fetch(&latitude,&longitude,&utc_offset,region,128,city,128);
    weather_sync_count =1; // sync on next iteration
    Serial.println(weather_info.city);
    current_time = utc_offset+ntp.request_result();
    update_ts = millis();
}

The first thing we do is set up our serial output and mount SPIFFS. Then we fill the screen with white, and start trying to connect by pumping the wps service. This will cause the device to scan for a WPS signal unless valid credentials were stored from a previous connection, in which case it will simply connect.

After waiting to be connected, we compute our rectangles for the various screen elements, set up our clock sync, resolve the NTP IP, and then do our initial NTP request.

It should be noted that the NTP request here isn't very robust. It's possible with UDP to drop packets so the response might never be received. There is no logic to handle that scenario, causing an infinite loop in that case. This can be fixed but adds more complexity to setup().

After that, we use our geolocation service to get the information about our location, and then set the weather sync clock to fetch the weather the first time loop() is called.

Next, we dump the city just for debugging, and then set the adjusted time based on the UTC time and offset.

Finally, we set our second counter timestamp.

Next, we have loop():

C++
void loop() {
    wps.update();
    ntp.update();
    if(ntp.request_received()) {
        Serial.println("NTP signal received");
        current_time = utc_offset+ntp.request_result();
    }
    uint32_t ms = millis();
    if (ms - update_ts >= 1000) {
        update_ts = ms;
        ++current_time;
        draw::wait_all_async(lcd);
        draw::filled_rectangle(clock_bmp, 
                              clock_size.bounds(), 
                              color_t::white);
        draw_clock(clock_bmp, current_time, (ssize16)clock_size);
        draw::bitmap_async(lcd, 
                          clock_rect, 
                          clock_bmp, 
                          clock_bmp.bounds());
        if (0 == --clock_sync_count) {
            clock_sync_count = clock_sync_seconds;
            ntp.begin_request(ntp_ip);
        }
        if(0==--weather_sync_count) {
            weather_sync_count = weather_sync_seconds;
            open_weather::fetch(latitude,longitude,&weather_info);
            Serial.println("Fetched weather");
            Serial.println(weather_info.main);
            Serial.println(weather_info.icon);
            draw::wait_all_async(lcd);
            draw_weather_icon(weather_icon_bmp,weather_info,weather_icon_size);
            draw::bitmap(lcd, 
                          weather_icon_rect, 
                          weather_icon_bmp, 
                          weather_icon_bmp.bounds());
            weather_temp_bmp.fill(weather_temp_bmp.bounds(),color_t::white);
            #ifdef M5STACK
            const float indoor_temp = mpu.temp();
            #else
            const float indoor_temp = NAN;
            #endif
            draw_temps(weather_temp_bmp,weather_info,indoor_temp);
            draw::bitmap(lcd, 
                          weather_temp_rect, 
                          weather_temp_bmp, 
                          weather_temp_bmp.bounds());
        }
    }
}

We start by pumping the WPS and NTP services. If we got an NTP response, we update the current time.

Next is the standard pattern for executing once a second, in this case using update_ts for the timestamp.

Now we increment the current time by one second. Since we don't actually have a real clock onboard, we use this to keep time between updates. It works pretty well in practice, as long as the inside of the if doesn't take longer than 1 second to execute.

The next line waits for all pending asynchronous operations to complete. This is because we might still be DMA transfering the clock bitmap over SPI in the background - in practice we aren't, but in theory we could. We're about to modify the memory that is being used for that, so we have to wait for it to finish.

Now we fill the background of the clock bitmap, and then draw the clock to it at the current time.

Once we do that, we fire the clock bitmap off asynchronously, using the ESP32's SPI DMA capabilities. GFX makes that simple, hiding all the complexity behind _async calls. We just have to be careful to make sure we don't write to the bitmap memory again while it's still transfering.

Anyway next, we count down the seconds between our NTP clock sync, and if it reaches zero we send an NTP request.

After that, we do the same thing with the weather sync count, and once it reaches zero we gather and update the weather information. Note that we're waiting for pending operations to lcd to complete again, because we're using bitmaps that share memory with a possibly executing DMA transfer.

Where to Go From Here

Being an example, this project is somewhat minimalist and could easily be made more robust, and richer in terms of the information it provides onscreen. I leave this as an exercise for you, gentle reader. Happy building!

History

  • 6th June, 2022 - Initial submission

License

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