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

Core 2 Clock - A dive into my IoT ecosystem

5.00/5 (6 votes)
21 Jun 2024MIT18 min read 8.7K   62  
Using my IoT ecosystem to produce an Internet enabled clock
Manage WiFi connections, control touch panels and power management features, connect to NTP and a web service, and display fancy graphics and text using my ecosystem. This project is an example.

Image 1

Introduction

Update: A dependent LCD driver has changed. If your screen ends up blank, download this code again, or get it from Github.

I like clocks for demo code. It tends to exercise quite a few features without being overly complicated considering we're putting the ESP32 through its paces. Here we'll be exploring my clock code with an eye toward using my ecosystem to build ESP32 or other MCU projects using Arduino or (ESP32 only) the ESP-IDF.

Here we'll be using my graphics library, htcw_gfx, my UI library, htcw_uix, my wifi management library, htcw_esp_wifi_manager, my NTP time library, htcw_esp_ntp_time, and ip geolocation library, htcw_esp_ip_loc as well as my cross platform I2C initialization library, htcw_esp_i2c, and a few hardware drivers.

Prerequisites

  • You'll need the latest Python installed and added to your PATH (for Platform IO - if you already have Platform IO working this isn't necessary)
  • You'll need VS Code with the Platform IO IDE extension installed
  • You'll need an M5 Stack Core 2 or an M5 Stack Tough
  • You'll need to provide your WiFi credentials in a file include/wifi_creds.h - define WIFI_SSID "my_ssid" and WIFI_PASS "my_password" in that file.

Here's a template for wifi_creds.h:

C++
#ifndef WIFI_CREDS_H
#define WIFI_CREDS_H
#define WIFI_SSID "my_ssid"
#define WIFI_PASS "my_password"
#endif

Background

Basically the project is a clock. It uses ip-api.com to do IP geolocation and ntp.org to fetch the time. It displays an analog clock, the battery meter, a WiFi indicator icon, the date and time as text and the time zone. It stores the time in the internal clock which it uses to keep time, although we didn't need to do that since we're Internet connected. Still, this way the clock, once set, works without an active Internet connection.

I2C Initialization

Arduino, the ESP-IDF 4.0+ and the ESP-IDF 5.0+ each have different mechanisms for driving I2C. I've abstracted the differences in initialization into an esp_i2c<> template that takes a port number (0 or 1), the SDA pin and the SCL pin and initializes the bus, reporting a static instance handle that can be passed to the constructors of my I2C based driver libraries. This makes it easy to initialize drivers regardless of platform.

Display Panel

The project uses the ESP LCD Panel API to send bitmaps of parts of the screen to the display.

It uses my htcw_uix UI library screen objects to generate those bitmaps based on controls/widgets laid out on each screen (in this project we only use one screen.)

htcw_uix uses my graphics library, htcw_gfx to do the actual drawing to those bitmaps.

It uses my htcw_ft6336 library (Core 2) or my htcw_chsc6540 (Tough) touch panel drivers for the touch input. The touches get fed into htcw_uix, which dispatches touches to the relevant controls/widgets.

The relevant code for connecting all this together is in include/panel.hpp and src/panel.cpp.

WiFi Management

This code uses my wifi_manager to manage the WiFi connection under the ESP-IDF or Arduino. It provides a simple, consistent interface regardless of platform. This project uses it to briefly connect to a network and fetch the relevant time information from online services before turning the radio off again.

Power Management

Both the M5 Stack Core 2 and the Tough each have an integrated battery and AXP192 power management IC. It is required to tickle the AXP192 in these devices each time you use it or the screen won't display anything and other ugly business. To that end I have m5stack_power and m5tough_power classes which handle the appropriate tickling on initializations, and then give you access to the battery status and AC status which we use to display battery information.

Time Management

This is the most involved portion of the code, and it's due to the use of multiple online services plus some hardware to drive a clock.

The ip_loc class is used to query ip-api.com. Under the covers that uses my JSON pull parser library and my embedded IO stream library to read the result. The API is exposed using a single fetch method that optionally takes several arguments for the various information the API can return.

The ntp_time class is used to query pool.ntp.org for the current time, which is offset for your time zone based on the information returned from ip_loc. Since the domain is a pool as the name suggests, the class does domain name resolution during each lookup. That can be just a little bit slow, but since the updates are so infrequent (every 10 minutes by default) it probably doesn't matter. What's slower in the end is the actual NTP UDP back and forth, which our code attempts to compensate for when fetching the time.

The bm8563 class manages the real-time clock peripheral built into the device. Every time it fetches the time from the online services (again, every 10 minutes by default) it sets the clock. Otherwise, it reads the clock each iteration of the firmware master loop and updates the screen as it changes.

User Interface

The user interface is comprised of several controls aka widgets: There are a couple of canvas controls for the wifi and battery indicators, a couple of labels for the date/time and time zone, and an SVG based analog clock. My graphics library supports SVG, and can build SVGs in memory without going to XML, although it can also parse simple SVGs from XML. There are several controls including this clock which are built into my user interface library. The ESP32's floating point processor is terrible, and so it's just barely fast enough to draw a few interactive SVG controls on the screen at once. Be frugal. Also it's best to make sure your panel transfer buffer(s) are large enough to contain your largest SVG control. Doing so will prevent UIX from having to redraw the control multiple times to update the display.

The main thing here is creating our template instantiation aliases, and declaring the screen(s) and control(s) which in this project are in include/ui.hpp. The actual implementation of these items is in src/main.cpp.

In the setup()/app_main() initialization code we set up the screen and the controls we'll be using. This basically consists of setting various properties including the various colors and the bounds that indicate where the control is laid out on the screen. For the canvas controls we set the paint callbacks.

Using the code

src/main.cpp

We're going to spend the bulk of our effort exploring src/main.cpp since that's where most of the action is. We'll cover other files as necessary. Starting from the top:

C++
#if __has_include(<Arduino.h>)
#include <Arduino.h>
#else
#include <freertos/FreeRTOS.h>
#include <stdint.h>
void loop();
static uint32_t millis() {
    return pdTICKS_TO_MS(xTaskGetTickCount());
}
#endif

This is a little bit of magic sauce to make this code work under the ESP-IDF or Arduino. If the Arduino.h header is available, we assume Arduino. Otherwise we assume the ESP-IDF. In the case of the latter, we provide a prototype for loop() so we can call it later, and a wrapper that exposes the number of milliseconds since boot for compatibility with Arduino.

C++
#include <esp_i2c.hpp> // i2c initialization
#ifdef M5STACK_CORE2
#include <m5core2_power.hpp> // AXP192 power management (core2)
#endif
#ifdef M5STACK_TOUGH
#include <m5tough_power.hpp> // AXP192 power management (tough)
#endif
#include <bm8563.hpp> // real-time clock
#include <uix.hpp> // user interface library
#include <gfx.hpp> // graphics library
#include <wifi_manager.hpp> // wifi connection management
#include <ip_loc.hpp> // ip geolocation service
#include <ntp_time.hpp> // NTP client service
// font is a TTF/OTF from downloaded from fontsquirrel.com
// converted to a header with https://honeythecodewitch.com/gfx/converter
#define OPENSANS_REGULAR_IMPLEMENTATION
#include "assets/OpenSans_Regular.hpp" // our font
// icons generated using https://honeythecodewitch.com/gfx/iconPack
#define ICONS_IMPLEMENTATION
#include "assets/icons.hpp" // our icons
// include this after everything else except ui.hpp
#include "config.hpp" // time and font configuration
#include "ui.hpp" // ui declarations
#include "panel.hpp" // display panel functionality

These are our includes. There are quite a few, but I've briefly summarized what each does in the comments above.

C++
// namespace imports
#ifdef ARDUINO
using namespace arduino; // libs (arduino)
#else
using namespace esp_idf; // libs (idf)
#endif
using namespace gfx; // graphics
using namespace uix; // user interface

Our namespace imports are above. These shouldn't require too much explanation.

C++
#ifdef M5STACK_CORE2
using power_t = m5core2_power;
#endif
#ifdef M5STACK_TOUGH
using power_t = m5tough_power;
#endif
// for AXP192 power management
static power_t power(esp_i2c<1,21,22>::instance);

Here is our power management class declaration. Depending on the device, we choose the appropriate class. Note how we're using the esp_i2c API to initialize I2C on the specified host and pins, and then passing instance to the constructor.

C++
// for the time stuff
static bm8563 time_rtc(esp_i2c<1,21,22>::instance);
static char time_buffer[64];
static long time_offset = 0;
static ntp_time time_server;
static char time_zone_buffer[64];
static bool time_fetching=false;

Here we declare our clock, again using esp_i2c to initialize it. We declare a buffer to hold the time string, the UTC offset in seconds, the NTP time client class, a buffer to hold the time zone string, and a flag indicating whether or not we're in the middle of fetching the time.

C++
// connection state for our state machine
typedef enum {
    CS_IDLE,
    CS_CONNECTING,
    CS_CONNECTED,
    CS_FETCHING,
    CS_POLLING
} connection_state_t;
static connection_state_t connection_state = CS_IDLE;

We use a simple state machine in loop() to manage the WiFi connection and fetching of online data. Doing this allows us to avoid blocking during this possibly lengthy operation so that the clock continues to work smoothly while the fetch is in progress.

C++
static wifi_manager wifi_man;

Here we simple declare the WiFi manager class which is used for managing our WiFi connection.

C++
// the screen/control definitions
screen_t main_screen;
svg_clock_t ana_clock(main_screen);
label_t dig_clock(main_screen);
label_t time_zone(main_screen);
canvas_t wifi_icon(main_screen);
canvas_t battery_icon(main_screen);

These are our UIX control and screen definitions. They are declared in include/ui.hpp but implemented here. We have the main screen where all the controls are laid out. We have the analog clock, the "digital" clock (which is just a label), the time zone label, and canvases to draw the wifi and battery icons.

C++
// updates the time string with the current time
static void update_time_buffer(time_t time) {
    char sz[64];
    tm tim = *localtime(&time);
    *time_buffer = 0;
    strftime(sz, sizeof(sz), "%D ", &tim);
    strcat(time_buffer,sz);
    strftime(sz, sizeof(sz), "%I:%M %p", &tim);
    if(*sz=='0') {
        *sz=' ';
    }
    strcat(time_buffer,sz);
}

This routine takes a time_t and converts it to a 12-hour format date and time string stored in time_buffer. Toward the last bit of the code we eliminate the leading zero from the hour, since it looks ugly.

C++
static void wifi_icon_paint(surface_t& destination, 
                            const srect16& clip, 
                            void* state) {
    // if we're using the radio, indicate it 
    // with white. otherwise dark gray
    auto px = rgb_pixel<16>(3,6,3);
    if(time_fetching) {
        px = color_t::white;
    }
    draw::icon(destination,point16::zero(),faWifi,px);
}

This handles a canvas control's "on paint" callback. The destination is the draw surface we're targeting, the clip is the rectangle within the destination that we need to draw - you can ignore it, but it's there for performance reasons. The state is a user defined value that is passed with each call. We don't use it here.

What we're doing is declaring a dark gray pixel in RGB565 format. R=3 (0-31), G=6 (0-63), B=3 (0-31). If we're in the middle of fetching the time, we turn it white. Note we just use the color_t enumeration (declared in include/ui.hpp) for this, since it's simple.

Finally we simply draw the faWiFi icon (include/assets/icons.hpp) at (0,0) to the destination with the specified color pixel, px. You should note that the icons are just alpha transparency maps. They do not have any intrinsic color. You provide the color when you draw the icon, as we did here.

C++
static void battery_icon_paint(surface_t& destination, 
                                const srect16& clip, 
                                void* state) {
    // show in green if it's on ac power.
    int pct = power.battery_level();
    auto px = power.ac_in()?color_t::green:color_t::white;
   if(!power.ac_in() && pct<25) {
        px=color_t::red;
    }
    // draw an empty battery
    draw::icon(destination,point16::zero(),faBatteryEmpty,px);
    // now fill it up
    if(pct==100) {
        // if we're at 100% fill the entire thing
        draw::filled_rectangle(destination,rect16(3,7,22,16),px);
    } else {
        // otherwise leave a small border
        draw::filled_rectangle(destination,rect16(4,9,4+(0.18f*pct),14),px);
   }
}

We do similar here, except we're dealing with the battery, and there are some additional steps. If the device is plugged into external power, ac_in() will report true, in which case we make the battery green, otherwise white. We also sample the battery percentage. If it's less than 25% and not plugged in we make the whole thing red.

Next we draw an empty battery icon (faBatteryEmpty). We then fill that battery based on the percentage we got back. We're using some magic numbers here to lay out a little filled rectangle inside the battery icon itself.

C++
#ifdef ARDUINO
void setup() {
    Serial.begin(115200);
#else
extern "C" void app_main() {
#endif

This is more sauce for Arduino/ESP-IDF compatibility. It declares our initialization routine, either setup() (Arduino) or app_main() (ESP-IDF) accordingly.

C++
power.initialize(); // do this first
panel_init(); // do this next
power.lcd_voltage(3.0);
time_rtc.initialize();
puts("Clock booted");
if(power.charging()) {
    puts("Charging");
} else {
    puts("Not charging"); // M5 Tough doesn't charge!?
}

Here we're initializing some things. The first thing is the power management, which must come first. After that, we initialize the LCD panel and transfer buffers, which must come next. We set the LCD voltage to 3.0 just because. Honestly this isn't necessary, but I think it saves a little power. Next comes the clock hardware, after which we indicate that we've booted and whether or not the battery is charging. My implementation of the AXP192 library for the Tough is leaving the battery in a non-charging state, and I don't know why. Eventually I'll figure it out, and this code will just work.

C++
// init the screen and callbacks
main_screen.dimensions({320,240});
main_screen.buffer_size(panel_transfer_buffer_size);
main_screen.buffer1(panel_transfer_buffer1);
main_screen.buffer2(panel_transfer_buffer2);
main_screen.background_color(color_t::black);

Our screen needs some information in order to work. It needs to know the size of the screen, the size of the transfer buffer(s), the pointer(s) to the transfer buffer(s) which were created by panel_init() - the second one is optional but facilitates DMA. Finally, we set the background color. It's black by default so that line is optional.

Let's step back. If you're familiar with LVGL this works in a similar way. It uses one or two transfer buffers to back bitmaps which it draws the controls to, and then it sends those bitmaps to the display. We've created 2 32KB transfer buffers for maximum performance on the ESP32 whose DMA is limited to 32KB transfers. The reason we use two buffers is so UIX can draw to one while sending the other, in order to fully utilize DMA performance.

C++
// init the analog clock, 128x128
ana_clock.bounds(srect16(0,0,127,127).center_horizontal(main_screen.bounds()));
ana_clock.face_color(color32_t::light_gray);
// make the second hand semi-transparent
auto px = ana_clock.second_color();
// use pixel metadata to figure out what half of the max value is
// and set the alpha channel (A) to that value
px.template channel<channel_name::A>(
    decltype(px)::channel_by_name<channel_name::A>::max/2);
ana_clock.second_color(px);
// do similar with the minute hand as the second hand
px = ana_clock.minute_color();
// same as above, but it handles it for you, using a scaled float
px.template channelr<channel_name::A>(0.5f);
ana_clock.minute_color(px);
// make the whole thing dark
ana_clock.hour_border_color(color32_t::gray);
ana_clock.minute_border_color(ana_clock.hour_border_color());
ana_clock.face_color(color32_t::black);
ana_clock.face_border_color(color32_t::black);
main_screen.register_control(ana_clock);

The uix::svg_clock<> has a lot of properties. Here we're setting it up. LVGL has Squareline Studio for this kind of thing. No such luck with my library, though eventually I hope to produce an online web based designer for this.

The first thing we do for this (and pretty much any control) is tell UIX where on the screen it belongs. This is done by providing a rectangle to the bounds() accessor. In our case we start with a 128x128 rectangle and then center that horizontally on the screen, passing the result to the bounds() accessor method.

Now we set a bunch of colors. Note that we're using RGBA8888 pixel format here, or rgba_pixel<32> in htcw_gfx vernacular. All of UIX except the screen background color (which uses the native format) takes color values in this 32-bit format. 

The only thing that's not entirely straightforward here is the alpha blending. We make the second and minute hands semi-transparent. We do this by setting the alpha channel (A) to a value of less than 255 (integer) or 1.0 (scaled real). using the channel<>() or channelr<>() template accessor methods off a pixel instance. Here we do it twice - one each for second and minute respectively, the first time by computing half of the maximum value for that channel (255) which resolves to 127. We could have just specified 127, but the above technique works regardless of pixel type/format.

The easier way is the second way, but it requires floating point scaling. You can set any channel's "real value" to a floating point number between 0 and 1.0. For example, 0.5 would half, or 127 scaled to our pixel format.

Anyway, once we're done setting all the colors we register the control with the screen (required or it won't show up). Note that the default colors for the clock work in many situations, but here we wanted a dark theme.

C++
// init the digital clock, (screen width)x40, below the analog clock
dig_clock.bounds(
    srect16(0,0,main_screen.bounds().x2,39)
        .offset(0,128));
update_time_buffer(time_rtc.now()); // prime the digital clock
dig_clock.text(time_buffer);
dig_clock.text_open_font(&text_font);
dig_clock.text_line_height(35);
dig_clock.text_color(color32_t::white);
dig_clock.text_justify(uix_justify::top_middle);
main_screen.register_control(dig_clock);

Now we set up the label for the "digital" clock that displays the date and time. We set the bounds() like before, arranging label below the clock, to the width of the screen. Note that we're building everything relative to everything else. This not only makes it easier than calculating a bunch of magic coordinates, it also means in theory that the same code could be used for screens with different resolutions. In this case, that doesn't matter, but for other projects that run on many devices, it might matter a lot.

Now we update the time_buffer using the update_time_buffer() method from earlier so our label starts with a meaningful value on startup before setting the text() accessor for the label.

The label needs a font. We're using an gfx::open_font so we set the text_open_font() accessor to a pointer to our text_font open_font instance. If this seems a bit weird in terms of naming, consider that UIX and GFX support three different types of font format - TTF/OTF vector which we're using, FON raster, and VLW anti-aliased raster. 

Since it's a vector font, we set the height of it in pixels so it scales how we want. In some applications it may make sense to base the line height on the size of the screen, but here I took a shortcut and just set it to 35 pixels

The color is set to white using the color32_t enumeration (include/ui.hpp) and then we set the justification to center the text along the x axis.

Finally, we register the control so it can appear on the screen.

C++
const uint16_t tz_top = dig_clock.bounds().y1+dig_clock.dimensions().height;
time_zone.bounds(srect16(0,tz_top,main_screen.bounds().x2,tz_top+40));
time_zone.text_open_font(&text_font);
time_zone.text_line_height(30);
time_zone.text_color(color32_t::light_sky_blue);
time_zone.text_justify(uix_justify::top_middle);
main_screen.register_control(time_zone);

We're doing similar with the time zone, except the color is different, the font is slightly smaller (time zone strings can be long) and we have no initial text() to give it.

C++
// set up a custom canvas for displaying our wifi icon
wifi_icon.bounds(
    srect16(spoint16(0,0),(ssize16)wifi_icon.dimensions())
        .offset(main_screen.dimensions().width-
            wifi_icon.dimensions().width,0));
wifi_icon.on_paint_callback(wifi_icon_paint);
wifi_icon.on_touch_callback([](size_t locations_size, 
                                const spoint16* locations, 
                                void* state){
    if(connection_state==CS_IDLE) {
        connection_state = CS_CONNECTING;
    }
},nullptr);
main_screen.register_control(wifi_icon);

This is actually pretty simple. We put the bounding box to the top right of the screen and set the paint callback to the method we covered earlier before registering the control. One wrinkle is we've handled the touch callback by updating the connection_state to CS_CONNECTING if it's idle.

C++
// set up a custom canvas for displaying our battery icon
battery_icon.bounds(
    (srect16)faBatteryEmpty.dimensions().bounds());
battery_icon.on_paint_callback(battery_icon_paint);
main_screen.register_control(battery_icon);

The battery canvas is similar to the WiFi canvas without the touch event, and it's placed at the top left of the screen.

C++
panel_set_active_screen(main_screen);

Finally we tell the panel code that we're using the main screen currently. We'll never change this in this app.

C++
#ifndef ARDUINO
    while(1) {
        loop();
        vTaskDelay(5);
    }
#endif

If we're running under the ESP-IDF we don't want app_main() to exit, so we spin an infinite loop and call the loop() method, yielding to the RTOS between loop() calls.

C++
void loop()
{
    ///////////////////////////////////
    // manage connection and fetching
    ///////////////////////////////////
    static uint32_t connection_refresh_ts = 0;
    static uint32_t time_ts = 0;
    switch(connection_state) { 

Under our loop() method we declare a couple of static bookkeeping variables to hold timestamps, and then we enter our connection state machine.

C++
case CS_IDLE:
if(connection_refresh_ts==0 || millis() > (connection_refresh_ts+
                                            (time_refresh_interval*
                                                1000))) {
    connection_refresh_ts = millis();
    connection_state = CS_CONNECTING;
}
break;

In our idle case we just check to see if our time_refresh_interval (specified in seconds in include/config.hpp) has elapsed, and if so we set the state to CS_CONNECTING.

C++
case CS_CONNECTING:
time_ts = 0; // for latency correction
time_fetching = true; // indicate that we're fetching
wifi_icon.invalidate(); // tell wifi_icon to repaint
// if we're not in process of connecting and not connected:
if(wifi_man.state()!=wifi_manager_state::connected && 
    wifi_man.state()!=wifi_manager_state::connecting) {
    puts("Connecting to network...");
    // connect
    wifi_man.connect(wifi_ssid,wifi_pass);
    connection_state =CS_CONNECTED;
} else if(wifi_man.state()==wifi_manager_state::connected) {
    // if we went from connecting to connected...
    connection_state = CS_CONNECTED;
}
break;

Here we handle starting to connect and waiting for the connection both depending on wifi_man.state().

C++
case CS_CONNECTED:
if(wifi_man.state()==wifi_manager_state::connected) {
    puts("Connected.");
    connection_state = CS_FETCHING;
} else if(wifi_man.state()==wifi_manager_state::error) {
    connection_refresh_ts = 0; // immediately try to connect again
    connection_state = CS_IDLE;
    time_fetching = false;
}
break;

Once we are connected we begin to fetch. Otherwise if we got an error, we try to connect again by resetting the state machine and the refresh timestamp.

C++
case CS_FETCHING:
puts("Retrieving time info...");
connection_refresh_ts = millis();
// grabs the timezone offset based on IP
if(!ip_loc::fetch(nullptr,
                    nullptr,
                    &time_offset,
                    nullptr,
                    0,
                    nullptr,
                    0,
                    time_zone_buffer,
                    sizeof(time_zone_buffer))) {
    // retry
    connection_state = CS_FETCHING;
    break;
}
time_ts = millis(); // we're going to correct for latency
time_server.begin_request();
connection_state = CS_POLLING;
break;

This is where we begin to fetch time information. We start by resetting the refresh timestamp, and then immediately go to ip-api.com to get our time zone offset and time zone string. This is unfortunately, a synchronous operation but in practice it doesn't take very long so you shouldn't notice lag. If it fails we try again. Otherwise we take the current timestamp for latency correction and begin our asynchronous NTP request before moving on.

C++
case CS_POLLING:
if(time_server.request_received()) {
    const int latency_offset = ((millis()-time_ts)+500)/1000;
    time_rtc.set((time_t)(time_server.request_result()+
                    time_offset+latency_offset));
    puts("Clock set.");
    // set the digital clock - otherwise it only updates once a minute
    update_time_buffer(time_rtc.now());
    dig_clock.invalidate();
    time_zone.text(time_zone_buffer);
    connection_state = CS_IDLE;
    puts("Turning WiFi off.");
    wifi_man.disconnect(true);
    time_fetching = false;
    wifi_icon.invalidate();
} else if(millis()>time_ts+(wifi_fetch_timeout*1000)) {
    puts("Retrieval timed out. Retrying.");
    connection_state = CS_FETCHING;
}
break;

Now we keep looking for an NTP response, or until the timeout occurs.

If we got a response we set the clock with it, adjusting for our latency and time zone offset. Then we update our time_buffer with the new date and time. We invalidate the dig_clock label to indicate that the text has changed and it should redraw. Then we set the time zone label's text() accessor with the new time zone string, which will trigger a redraw of it. Finally, we reset the state machine to the idle state and turn off the WiFi radio. We have to tell the wifi_icon canvas to repaint as a consequence.

If we time out we simply fetch again.

That's it for the state machine.

C++
///////////////////
// update the UI
//////////////////
time_t time = time_rtc.now();
ana_clock.time(time);
// only update every minute (efficient)
if(0==(time%60)) {
    update_time_buffer(time);
    // tell the label the text changed
    dig_clock.invalidate();
}
// update the battery level
static int bat_level = power.battery_level();
if((int)power.battery_level()!=bat_level) {
    bat_level = power.battery_level();
    battery_icon.invalidate();
}
static bool ac_in = power.ac_in();
if(power.ac_in()!=ac_in) {
    ac_in = power.ac_in();
    battery_icon.invalidate();
}

Now we update the UI, first by fetching the current time from the clock. We then set the ana_clock to that time. Next, once every 60 seconds on the minute we update the time_buffer with the new value, and force dig_clock to redraw since the text buffer has changed.

Now we display the battery level. We start by taking an integer percentage of the level and storing it statically. We then compare that with the current battery level, and if it has changed we force the battery icon to invalidate.

We do similar with the ac_in() status.

C++
//////////////////////////
// pump various objects
/////////////////////////
time_server.update();
panel_update();

This just keeps our NTP client and display panel up to date.

include/ui.hpp

C++
#pragma once
#include <gfx.hpp>
#include <uix.hpp>
// colors for the UI
using color_t = gfx::color<gfx::rgb_pixel<16>>; // native
using color32_t = gfx::color<gfx::rgba_pixel<32>>; // uix

// the screen template instantiation aliases
using screen_t = uix::screen<gfx::rgb_pixel<16>>;
using surface_t = screen_t::control_surface_type;

// the control template instantiation aliases
using svg_clock_t = uix::svg_clock<surface_t>;
using label_t = uix::label<surface_t>;
using canvas_t = uix::canvas<surface_t>;

// the screen/control declarations
extern screen_t main_screen;
extern svg_clock_t ana_clock;
extern label_t dig_clock;
extern label_t time_zone;
extern canvas_t wifi_icon;
extern canvas_t battery_icon;

This is basically boilerplate declarations. We've created color enumerations for 16-bit RGB pixels as used by the display, and for 32-bit RGBA pixels as used by UIX.

Next we instantiate the screen<> template feeding it our display's native pixel type. We then alias its control_surface_type because we'll need it later when instantiating our control types.

Then we declare aliases for each control type we are using, passing surface_t as an argument.

Finally, we declare our actual instances of those types, extern so they can be implemented in another file and referenced throughout the project.

This is pretty common when using UIX, so bear that in mind, as your UI headers will contain something like this in the least.

src/panel.cpp

This file handles our display and touch panel at the driver level. It handles the initialization and connects the screens to the LCD driver and touch panel driver. While the actual LCD initialization varies depending on the display, most of this code can be used in other projects with very little modification so getting familiar with it at least can only help.

C++
#include "panel.hpp"
#include "ui.hpp"
#include <driver/gpio.h>
#include <driver/spi_master.h>
#include <esp_lcd_panel_io.h>
#include <esp_lcd_panel_ops.h>
#include <esp_lcd_panel_vendor.h>
#include <esp_lcd_panel_ili9342.h>
#include <esp_i2c.hpp>
#ifdef M5STACK_CORE2
#include <ft6336.hpp>
#endif
#ifdef M5STACK_TOUGH
#include <chsc6540.hpp>
#endif
#ifdef ARDUINO
using namespace arduino;
#else
using namespace esp_idf;
#endif
using namespace uix;

This is basically boilerplate includes and imports for handling the ESP LCD Panel API and our touch driver.

C++
// handle to the display
static esp_lcd_panel_handle_t lcd_handle;
// the transfer buffers
uint8_t* panel_transfer_buffer1 = nullptr;
uint8_t* panel_transfer_buffer2 = nullptr;
// the currently active screen
static screen_t* panel_active_screen = nullptr;
// for the touch panel
#ifdef M5STACK_CORE2
using touch_t = ft6336<320,280>;
#endif
#ifdef M5STACK_TOUGH
using touch_t = chsc6540<320,240,39>;
#endif
static touch_t touch(esp_i2c<1,21,22>::instance);

These are our definitions for the panel, which include the handle, the transfer buffers, the active screen, and the touch driver.

C++
// tell UIX the DMA transfer is complete
static bool panel_flush_ready(esp_lcd_panel_io_handle_t panel_io, 
                                esp_lcd_panel_io_event_data_t* edata, 
                                void* user_ctx) {
    if(panel_active_screen!=nullptr) {
        panel_active_screen->flush_complete();
    }
    return true;
}

Since we're using DMA, UIX needs to know when the DMA transfer is finished. The ESP LCD Panel API uses this callback to notify that a transfer is complete, we then tell that to the currently active screen.

C++
// tell the lcd panel api to transfer data via DMA
static void panel_on_flush(const rect16& bounds, const void* bmp, void* state) {
    int x1 = bounds.x1, y1 = bounds.y1, x2 = bounds.x2 + 1, y2 = bounds.y2 + 1;
    esp_lcd_panel_draw_bitmap(lcd_handle, x1, y1, x2, y2, (void*)bmp);
}

This routine is called by UIX to send a bitmap to the display. It uses the ESP LCD Panel API to do so. One quirk of that API is that x2 and y2 must extend 1 past the actual destination. It's corrected for here.

C++
// for the touch panel
static void panel_on_touch(point16* out_locations,
                            size_t* in_out_locations_size,
                            void* state) {
    // UIX supports multiple touch points. 
    // so does the FT6336 so we potentially have
    // two values
    *in_out_locations_size = 0;
    uint16_t x,y;
    if(touch.xy(&x,&y)) {
        out_locations[0]=point16(x,y);
        ++*in_out_locations_size;
        if(touch.xy2(&x,&y)) {
            out_locations[1]=point16(x,y);
            ++*in_out_locations_size;
        }
    }
}

This is also called by UIX to get touch input. The touch panels support two finger touches simultaneously so we handle that even though for this application we never use the second set of coordinates.

C++
void panel_set_active_screen(screen_t& new_screen) {
    if(panel_active_screen!=nullptr) {
        // wait until any DMA transfer is complete
        while(panel_active_screen->flushing()) {
            vTaskDelay(5);
        }
        panel_active_screen->on_flush_callback(nullptr);
        panel_active_screen->on_touch_callback(nullptr);
    }
    panel_active_screen=&new_screen;
    new_screen.on_flush_callback(panel_on_flush);
    new_screen.on_touch_callback(panel_on_touch);
    panel_active_screen->invalidate();
}

Here we connect the active screen to our flush callback. Before we do so, if there's an existing screen we wait for it to finish any DMA transfer before switching over. Once we've hooked and unhooked as necessary we invalidate the screen to force a repaint of the whole thing.

C++
void panel_update() {
    if(panel_active_screen!=nullptr) {
        panel_active_screen->update();    
    }
    // FT6336 chokes if called too quickly
    static uint32_t touch_ts = 0;
    if(pdTICKS_TO_MS(xTaskGetTickCount())>touch_ts+13) {
        touch_ts = pdTICKS_TO_MS(xTaskGetTickCount());
        touch.update();
    }
}

Here we just update the screen, and every 13ms we update the touch panel.

C++
// initialize the screen using the esp panel API
void panel_init() {
    panel_transfer_buffer1 = (uint8_t*)heap_caps_malloc(panel_transfer_buffer_size,MALLOC_CAP_DMA);
    panel_transfer_buffer2 = (uint8_t*)heap_caps_malloc(panel_transfer_buffer_size,MALLOC_CAP_DMA);
    if(panel_transfer_buffer1==nullptr||panel_transfer_buffer2==nullptr) {
        puts("Out of memory allocating transfer buffers");
        while(1) vTaskDelay(5);
    }
    spi_bus_config_t buscfg;
    memset(&buscfg, 0, sizeof(buscfg));
    buscfg.sclk_io_num = 18;
    buscfg.mosi_io_num = 23;
    buscfg.miso_io_num = -1;
    buscfg.quadwp_io_num = -1;
    buscfg.quadhd_io_num = -1;
    buscfg.max_transfer_sz = panel_transfer_buffer_size + 8;

    // Initialize the SPI bus on VSPI (SPI3)
    spi_bus_initialize(SPI3_HOST, &buscfg, SPI_DMA_CH_AUTO);

    esp_lcd_panel_io_handle_t io_handle = NULL;
    esp_lcd_panel_io_spi_config_t io_config;
    memset(&io_config, 0, sizeof(io_config));
    io_config.dc_gpio_num = 15,
    io_config.cs_gpio_num = 5,
    io_config.pclk_hz = 40*1000*1000,
    io_config.lcd_cmd_bits = 8,
    io_config.lcd_param_bits = 8,
    io_config.spi_mode = 0,
    io_config.trans_queue_depth = 10,
    io_config.on_color_trans_done = panel_flush_ready;
    // Attach the LCD to the SPI bus
    esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)SPI3_HOST, &io_config, &io_handle);

    lcd_handle = NULL;
    esp_lcd_panel_dev_config_t panel_config;
    memset(&panel_config, 0, sizeof(panel_config));
    panel_config.reset_gpio_num = -1;
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0)
    panel_config.rgb_endian = LCD_RGB_ENDIAN_BGR;
#else
    panel_config.color_space = ESP_LCD_COLOR_SPACE_BGR;
#endif
    panel_config.bits_per_pixel = 16;

    // Initialize the LCD configuration
    esp_lcd_new_panel_ili9342(io_handle, &panel_config, &lcd_handle);

    // Reset the display
    esp_lcd_panel_reset(lcd_handle);

    // Initialize LCD panel
    esp_lcd_panel_init(lcd_handle);
    // esp_lcd_panel_io_tx_param(io_handle, LCD_CMD_SLPOUT, NULL, 0);
    //  Swap x and y axis (Different LCD screens may need different options)
    esp_lcd_panel_swap_xy(lcd_handle, false);
    esp_lcd_panel_set_gap(lcd_handle, 0, 0);
    esp_lcd_panel_mirror(lcd_handle, false, false);
    esp_lcd_panel_invert_color(lcd_handle, true);
    // Turn on the screen
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0)
    esp_lcd_panel_disp_on_off(lcd_handle, true);
#else
    esp_lcd_panel_disp_off(lcd_handle, true);
#endif
    touch.initialize();
    touch.rotation(0);
}

There's a lot of code here, but fortunately a lot of it is boilerplate. Unfortunately, covering the intricacies of the ESP LCD Panel API is beyond the scope of this article. For information on it, please refer to the documentation.

History

  • 12th June, 2024 - Initial submission

License

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