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.
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:
#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:
#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.
#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
#define OPENSANS_REGULAR_IMPLEMENTATION
#include "assets/OpenSans_Regular.hpp" // our font
#define ICONS_IMPLEMENTATION
#include "assets/icons.hpp" // our icons
#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.
#ifdef ARDUINO
using namespace arduino; #else
using namespace esp_idf; #endif
using namespace gfx; using namespace uix;
Our namespace imports are above. These shouldn't require too much explanation.
#ifdef M5STACK_CORE2
using power_t = m5core2_power;
#endif
#ifdef M5STACK_TOUGH
using power_t = m5tough_power;
#endif
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.
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.
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.
static wifi_manager wifi_man;
Here we simple declare the WiFi manager class which is used for managing our WiFi connection.
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.
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.
static void wifi_icon_paint(surface_t& destination,
const srect16& clip,
void* state) {
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.
static void battery_icon_paint(surface_t& destination,
const srect16& clip,
void* state) {
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::icon(destination,point16::zero(),faBatteryEmpty,px);
if(pct==100) {
draw::filled_rectangle(destination,rect16(3,7,22,16),px);
} else {
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.
#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.
power.initialize(); panel_init(); power.lcd_voltage(3.0);
time_rtc.initialize();
puts("Clock booted");
if(power.charging()) {
puts("Charging");
} else {
puts("Not charging"); }
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.
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.
ana_clock.bounds(srect16(0,0,127,127).center_horizontal(main_screen.bounds()));
ana_clock.face_color(color32_t::light_gray);
auto px = ana_clock.second_color();
px.template channel<channel_name::A>(
decltype(px)::channel_by_name<channel_name::A>::max/2);
ana_clock.second_color(px);
px = ana_clock.minute_color();
px.template channelr<channel_name::A>(0.5f);
ana_clock.minute_color(px);
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.
dig_clock.bounds(
srect16(0,0,main_screen.bounds().x2,39)
.offset(0,128));
update_time_buffer(time_rtc.now()); 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.
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.
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.
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.
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.
#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.
void loop()
{
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.
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
.
case CS_CONNECTING:
time_ts = 0; time_fetching = true; wifi_icon.invalidate(); if(wifi_man.state()!=wifi_manager_state::connected &&
wifi_man.state()!=wifi_manager_state::connecting) {
puts("Connecting to network...");
wifi_man.connect(wifi_ssid,wifi_pass);
connection_state =CS_CONNECTED;
} else if(wifi_man.state()==wifi_manager_state::connected) {
connection_state = CS_CONNECTED;
}
break;
Here we handle starting to connect and waiting for the connection both depending on wifi_man.state()
.
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; 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.
case CS_FETCHING:
puts("Retrieving time info...");
connection_refresh_ts = millis();
if(!ip_loc::fetch(nullptr,
nullptr,
&time_offset,
nullptr,
0,
nullptr,
0,
time_zone_buffer,
sizeof(time_zone_buffer))) {
connection_state = CS_FETCHING;
break;
}
time_ts = millis(); 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.
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.");
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.
time_t time = time_rtc.now();
ana_clock.time(time);
if(0==(time%60)) {
update_time_buffer(time);
dig_clock.invalidate();
}
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.
time_server.update();
panel_update();
This just keeps our NTP client and display panel up to date.
include/ui.hpp
#pragma once
#include <gfx.hpp>
#include <uix.hpp>
using color_t = gfx::color<gfx::rgb_pixel<16>>; using color32_t = gfx::color<gfx::rgba_pixel<32>>;
using screen_t = uix::screen<gfx::rgb_pixel<16>>;
using surface_t = screen_t::control_surface_type;
using svg_clock_t = uix::svg_clock<surface_t>;
using label_t = uix::label<surface_t>;
using canvas_t = uix::canvas<surface_t>;
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.
#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.
static esp_lcd_panel_handle_t lcd_handle;
uint8_t* panel_transfer_buffer1 = nullptr;
uint8_t* panel_transfer_buffer2 = nullptr;
static screen_t* panel_active_screen = nullptr;
#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.
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.
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.
static void panel_on_touch(point16* out_locations,
size_t* in_out_locations_size,
void* state) {
*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.
void panel_set_active_screen(screen_t& new_screen) {
if(panel_active_screen!=nullptr) {
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.
void panel_update() {
if(panel_active_screen!=nullptr) {
panel_active_screen->update();
}
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.
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;
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;
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;
esp_lcd_new_panel_ili9342(io_handle, &panel_config, &lcd_handle);
esp_lcd_panel_reset(lcd_handle);
esp_lcd_panel_init(lcd_handle);
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);
#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
Just a shiny lil monster. Casts spells in C++. Mostly harmless.