Scan for I2C devices or monitor serial UART activity on a little probe with an integrated screen. It is optionally battery powered, and also connectible to a PC via a USB virtual COM port.
Introduction
This article builds on my previous article about i2cu. It now does rudimentary serial monitoring as well as I2C scanning. Since the source code has grown significantly more complicated, I didn't want to shoehorn the new functionality into the previous article, so here we go again.
Essentially, this is a field tool for debugging hardware. It's especially handy for checking for signs of life from I2C devices or chips like the ESP32 that post to a UART when they boot.
Prerequisites
The prerequisites are the same as the previous article. You'll need a Lilygo TTGO T1 Display, VS Code w/ Platform IO, and 3 or 4 probe wires.
Pardon my fumbling with the wires, but hopefully that shows the concept.
Understanding this Mess
There's a lot going on here, but when you start it up not plugged into an I2C bus or UART, it will show the title screen.
Once data comes in off of either type of input, it gets displayed to the screen whenever the incoming data changes.
This means it automatically detects serial vs. I2C probing, which is a good thing because we have a limited number of buttons upon which we are already stacking functionality.
The screen will dim after a time if there's no changing data to save battery, finally putting the display to sleep entirely until one of the buttons is pressed or more data comes in.
Clicking the left button will wake the display, while holding it down pauses the display so you can examine the screen before the data flies by, which can be useful for serial.
Clicking the right button changes serial from text to binary mode and back. Pressing and holding changes the baud rate. The serial configuration is hardcoded to 8N1.
On the TTGO wire ground to ground in your circuit. Wire SDA to 21, and wire SCL to 22.
For serial, wire 17 to a serial UART TX line.
Architecture
I chose the Arduino framework for this code, as it is my preferred platform for reasons which are beyond the scope of this article.
As far as other technologies, I use htcw_uix and htcw_gfx to render the screen, and my htcw_button library to handle the input. I use my htcw_lcd_miser library to handle the display's backlight. My htcw_free_rtos_thread_pack provides thread support, although we didn't really have to use it - we could have gone to FreeRTOS directly.
Coding this Mess
/src/main.cpp
The meat, like last time, is in main.cpp but we'll go over it again in this article. I've commented things better and restructured the code slightly in addition to adding functionality.
Defines, Includes and Imports
#define MONITOR Serial
#define I2C Wire
#define I2C_SDA 21
#define I2C_SCL 22
#define SER Serial1
#define SER_RX 17
#include <Arduino.h>
#include <Wire.h>
#include <SPIFFS.h>
#include <atomic>
#include <button.hpp>
#include <htcw_data.hpp>
#include <lcd_miser.hpp>
#include <thread.hpp>
#include <uix.hpp>
#include "driver/i2c.h"
#define LCD_IMPLEMENTATION
#include "lcd_init.h"
#include "ui.hpp"
using namespace arduino;
using namespace gfx;
using namespace uix;
using namespace freertos;
Here, we're including a lot, but basically it's just boilerplate. We use this stuff throughout main
, obviously.
Prototypes
Moving on, we have a bunch of function prototypes for later, which I've commented.
static void uix_on_flush(point16 location,
bitmap<rgb_pixel<16>>& bmp,
void* state);
static bool lcd_flush_ready(esp_lcd_panel_io_handle_t panel_io,
esp_lcd_panel_io_event_data_t* edata,
void* user_ctx);
static void lcd_sleep();
static void lcd_wake();
static bool refresh_i2c();
static bool refresh_serial();
static void save_settings();
static void button_a_on_click(int clicks, void* state);
static void button_a_on_long_click(void* state);
static void button_b_on_click(int clicks, void* state);
static void i2c_update_task(void* state);
Aliases
I find templates pretty useful, and type aliases are what's for dinner when templates are at play, so here are a bunch of type aliases for our various things like our LCD backlight and button drivers, as well as two types of color enumeration and the htcw_uix screen type.
using dimmer_t = lcd_miser<4>;
using color16_t = color<rgb_pixel<16>>;
using color32_t = color<rgba_pixel<32>>;
using button_a_raw_t = int_button<35, 10, true>;
using button_b_raw_t = int_button<0, 10, true>;
using button_t = multi_button;
using screen_t = screen<LCD_HRES, LCD_VRES, rgb_pixel<16>>;
Globals
People often frown on using globals - and for good reason. Globals "pollute" or clutter your namespace, conflict with globals declared in other files, and generally just make a mess of things.
However, in IoT, your applications can't be large enough where using globals becomes unmanageable. You don't usually have enormous projects on IoT devices. Unless you're writing a framework or library, go ahead and use globals, but try to keep them contained to your source files.
Anyway, I've loosely sectioned the globals together into related areas which I'll go over.
First, we have the I2C updater thread information. The updater runs on the auxiliary core so that device timeouts don't negatively impact the performance of the primary application thread. To facilitate this, we use a mutex for synchronizing access to shared data, and an atomic bool
for indicating that the updater has run at least once:
static thread i2c_updater;
static SemaphoreHandle_t i2c_update_sync;
static volatile std::atomic_bool i2c_updater_ran;
A note about the use of volatile
here. The compiler doesn't necessarily have to read a value from memory every time you access its corresponding variable. It can, for example, cache the value in a register and return that. Using the "volatile" keyword ensures that the value is always read, and never cached. This is necessary when a value might be accessed from multiple threads, because the compiler doesn't know about threading, so it might try to cache a value, not realizing that it can be updated elsewhere. If we didn't do it this way, the value could end up being stale.
Next, we have our I2C address data. We store the list of addresses not as an array of numbers, but as a series of 128 bits packed into 4 32-bit unsigned integers to save space and make comparing easy and fast. Each bit corresponds to the address of its position, so bit 0 is address 0, and bit 101 is address 101. We keep a current bit set and an "old" bit set so we can see when changes occur, such as when the user of the probe unplugs or plugs in a device.
static uint32_t i2c_addresses[4];
static uint32_t i2c_addresses_old[4];
Next, we have our serial data. This includes the list of selectable baud rates, the currently selected baud index, the mode (binary or text), and a timestamp used for timing out the message boxes that show up when you configure the baud rate or the mode. It also has an indicator that determines if we're already displaying serial data, and three variables that make up the buffer for the serial data.
static const int serial_bauds[] = {
115200,
19200,
9600,
2400
};
static const size_t serial_bauds_size
= sizeof(serial_bauds)/sizeof(int);
static size_t serial_baud_index = 0;
static bool serial_bin = false;
static uint32_t serial_msg_ts = 0;
static bool is_serial = false;
static uint8_t* serial_data = nullptr;
static size_t serial_data_capacity = 0;
static size_t serial_data_size = 0;
Yeah, we keep a separate buffer for incoming serial data. This is necessary, rather than using display_text
since we insert line breaks into that, which will interfere when scrolling the data. It also would complicate switching from text to binary, so we store incoming data in serial_data
and scroll that.
Now here's where we keep the text we're displaying on the probe:
static char* display_text = nullptr;
static size_t display_text_capacity = 0;
Now we have our data for our LCD panel operations and the dimmer:
static constexpr const size_t lcd_buffer_size = 64 * 1024;
static uint8_t* lcd_buffer1 = nullptr;
static uint8_t* lcd_buffer2 = nullptr;
static bool lcd_sleeping = false;
static dimmer_t lcd_dimmer;
Here, we have the size declared for our LCD transfer buffer(s) which are 64KB each. We have a pointer for each transfer buffer, an indicator if the LCD panel is asleep, and finally, the dimmer instance.
Now we have our button declarations:
static button_a_raw_t button_a_raw; static button_b_raw_t button_b_raw; static button_t button_a(button_a_raw); static button_t button_b(button_b_raw);
We have the raw buttons, which just connect the hardware and provide a basic callback, and then the actual more advanced button classes that wrap them and provide things like multi-click and long click capabilities. Note that button A is the right button, while button B is the left button.
Setup
Now to some meat - the setup() routine. This is where we initialize everything, of course:
void setup() {
MONITOR.begin(115200);
SPIFFS.begin(true, "/spiffs", 1);
if (SPIFFS.exists("/settings")) {
File file = SPIFFS.open("/settings");
file.read((uint8_t*)&serial_baud_index, sizeof(serial_baud_index));
file.read((uint8_t*)&serial_bin, sizeof(serial_bin));
file.close();
MONITOR.println("Loaded settings");
}
SER.begin(serial_bauds[serial_baud_index], SERIAL_8N1, SER_RX, -1);
lcd_buffer1 = (uint8_t*)malloc(lcd_buffer_size);
if (lcd_buffer1 == nullptr) {
MONITOR.println("Error: Out of memory allocating lcd_buffer1");
while (1)
;
}
lcd_dimmer.initialize();
memset(&i2c_addresses_old, 0, sizeof(i2c_addresses_old));
memset(&i2c_addresses, 0, sizeof(i2c_addresses));
i2c_updater_ran = false;
i2c_update_sync = xSemaphoreCreateMutex();
if (i2c_update_sync == nullptr) {
MONITOR.println("Could not allocate I2C updater semaphore");
while (1)
;
}
i2c_updater = thread::create_affinity(1 - thread::current().affinity(),
i2c_update_task,
nullptr,
10,
2000);
if (i2c_updater.handle() == nullptr) {
MONITOR.println("Could not allocate I2C updater thread");
while (1)
;
}
i2c_updater.start();
button_a.initialize();
button_b.initialize();
button_a.on_click(button_a_on_click);
button_a.on_long_click(button_a_on_long_click);
button_b.on_click(button_b_on_click);
lcd_panel_init(lcd_buffer_size, lcd_flush_ready);
if (lcd_handle == nullptr) {
MONITOR.println("Could not initialize the display");
while (1)
;
}
lcd_buffer2 = (uint8_t*)malloc(lcd_buffer_size);
if (lcd_buffer2 == nullptr) {
MONITOR.println("Warning: Out of memory allocating lcd_buffer2.");
MONITOR.println("Performance may be degraded. Try a smaller lcd_buffer_size");
}
main_screen = screen_t(lcd_buffer_size, lcd_buffer1, lcd_buffer2);
main_screen.on_flush_callback(uix_on_flush);
ui_init();
display_text_capacity = probe_cols * (probe_rows + 1) + 1;
display_text = (char*)malloc(display_text_capacity);
if (display_text == nullptr) {
MONITOR.println("Could not allocate display text");
while (1)
;
}
*display_text = '\0';
serial_data_capacity = probe_cols * probe_rows;
serial_data = (uint8_t*)malloc(serial_data_capacity);
if (serial_data == nullptr) {
MONITOR.println("Could not allocate serial data");
while (1)
;
}
MONITOR.printf("SRAM free: %0.1fKB\n",
(float)ESP.getFreeHeap() / 1024.0);
MONITOR.printf("SRAM largest free block: %0.1fKB\n",
(float)ESP.getMaxAllocHeap() / 1024.0);
MONITOR.println();
}
I've tried to comment the relevant bits above because it's usually easier to follow descriptions alongside the code. Here's what we do:
- Load the settings from SPIFFS. We persist your serial mode and selected baud rate, so here we load it if it has been previously stored.
- Start the serial probe line.
- Allocate the primary LCD transfer buffer.
- Initialize the LCD dimmer.
- Set the I2C address lists to cleared.
- Start the I2C updater thread.
- Connect and initialize the buttons.
- Initialize the LCD display.
- Try to allocate a secondary LCD transfer buffer (for best performance).
- Reinitialize the main screen with valid transfer buffer pointers.
- Initialize the user interface components.
- Compute and allocate our display text buffer.
- Compute and allocate our serial data buffer.
- Report memory statistics.
Loop
Here lies the main logic of the application. Again, I've tried to provide commentary along with the code:
void loop() {
if (serial_msg_ts && millis() > serial_msg_ts + 1000) {
probe_msg_label1.visible(false);
probe_msg_label2.visible(false);
serial_msg_ts = 0;
}
while (button_a.pressed() || button_b.pressed()) {
button_a.update();
button_b.update();
}
lcd_dimmer.update();
button_a.update();
button_b.update();
if (refresh_i2c()) {
is_serial = false;
probe_label.text_color(color32_t::green);
probe_label.text(display_text);
probe_label.visible(true);
lcd_wake();
lcd_dimmer.wake();
} else if (refresh_serial()) {
is_serial = true;
probe_label.text_color(color32_t::yellow);
probe_label.text(display_text);
probe_label.visible(true);
lcd_wake();
lcd_dimmer.wake();
}
if (lcd_dimmer.faded()) {
lcd_sleep();
} else {
lcd_wake();
main_screen.update();
}
}
It's surprisingly simple, given it drives the whole application, but we'll go over it. The first part of the code is a trivial timer that shuts off probe_msg_label1
and probe_msg_label2
when they time out. These labels hold the display for the settings you can change by pressing the right button. Once they are displayed, that timer is started. Once it elapses, the messages go away.
Next, we have a loop that only takes place if one of the buttons is held down. We use this loop to pause everything such that when you hold a button down the display stops updating. This is a really dirty, yet absolutely sufficient way to do this in this case. The "downside" at least in some cases, is this renders the entire application unresponsive so there is no opportunity to do background processing (except for the I2C updater which runs on another core) but here that suits us fine. Note that we keep firing the button update()
calls in the loop so that pressed()
will change at the appropriate time, so I guess technically, the app isn't entirely frozen.
Now we give the dimmer and buttons a chance to update themselves.
After that, we have a couple of major calls wrapped in an if
/else if
amalgamation: refresh_i2c()
and refresh_serial()
. Each of these checks for changed data coming off their respective noun and if there is fresh data, it updates display_text
with that data and returns true. Otherwise, it returns false. In reach case, we update is_serial
because for serial when you first switch to it, it empties the display. probe_label
gets updated either way, and then the display and dimmer are ensured to be awake.
After that, we either put the LCD to sleep if the dimmer has faded all the way, or ensure the LCD is awake and update the screen otherwise.
htcw_uix/ESP LCD Panel API Interconnection
We need our user interface rendering library (htcw_uix) to talk to the actual display hardware, which we're using the ESP LCD Panel API to control. We do that by implementing a couple of callbacks, one for htcw_uix, and one for the ESP LCD Panel API:
static void uix_on_flush(point16 location,
bitmap<rgb_pixel<16>>& bmp,
void* state) {
int x1 = location.x;
int y1 = location.y;
int x2 = x1 + bmp.dimensions().width;
int y2 = y1 + bmp.dimensions().height;
esp_lcd_panel_draw_bitmap(lcd_handle,
x1,
y1,
x2,
y2,
bmp.begin());
}
static bool lcd_flush_ready(esp_lcd_panel_io_handle_t panel_io,
esp_lcd_panel_io_event_data_t* edata,
void* user_ctx) {
main_screen.set_flush_complete();
return true;
}
The first function is invoked by htcw_uix, and provides a bitmap and a location to draw to. We convert that data into coordinates and color information that the ESP LCD Panel API accepts. This is trivial. The only wrinkle is that the ESP LCD Panel API expects x2
and y2
to overshoot their destination by one pixel in either direction. That's handled here.
The second function is invoked by the ESP LCD Panel API and it tells us when a previous call to esp_lcd_panel_draw_bitmap()
has completed. We simply forward that information to our htcw_uix main_screen
which handles our rendering.
LCD Power Management
We put the LCD to sleep whenever the screen is fully blanked, and we wake it up whenever we need it again. To do so, we send some commands to the ST7789 display controller to tell it what to do. These two functions serve that purpose:
static void lcd_sleep() {
if (!lcd_sleeping) {
uint8_t params[] = {};
esp_lcd_panel_io_tx_param(lcd_io_handle,
0x10,
params,
sizeof(params));
delay(5);
lcd_sleeping = true;
}
}
static void lcd_wake() {
if (lcd_sleeping) {
uint8_t params[] = {};
esp_lcd_panel_io_tx_param(lcd_io_handle,
0x11,
params,
sizeof(params));
delay(120);
lcd_sleeping = false;
}
}
Persistence
We handle the persistence of configuration data with the save_settings()
routine:
static void save_settings() {
File file;
if (!SPIFFS.exists("/settings")) {
file = SPIFFS.open("/settings", "wb", true);
} else {
file = SPIFFS.open("/settings", "wb");
file.seek(0);
}
file.write((uint8_t*)&serial_baud_index, sizeof(serial_baud_index));
file.write((uint8_t*)&serial_bin, sizeof(serial_bin));
file.close();
}
Button Handling
When either button is pressed, two things happen: The screen pauses regardless of what it was doing - which we already went over, and the screen wakes up. For the left button, this is all it does, so we won't do much to cover the routine. The interesting stuff happens when the right button is manipulated.
First, when it is simply pressed in a rapid click, we want it to change the serial mode. That's what happens here. When we wake the display, we subtract a click. That way, if you just intended to wake the display, that's all it will do. Then we do a bit of bit twiddling to reduce all of the clicks that were reported down to a simple even/odd proposition (&1
) which we then add to a boolean, limiting it once again to even or odd, or rather true or false, in this case. This sets our serial mode from binary to text or vice versa. After we do that, we display our probe_msg_label
s with the appropriate text, indicating the current setting. We start the timer for the message display to go away. We update the screen so it doesn't have to wait for another loop iteration, causing the message to show up right away, and then save the settings.
static void button_a_on_click(int clicks, void* state) {
if (lcd_dimmer.dimmed()) {
lcd_wake();
lcd_dimmer.wake();
--clicks;
}
serial_bin = (serial_bin + (clicks & 1)) & 1;
probe_msg_label1.text("[ mode ]");
probe_msg_label2.text(serial_bin ? "bin" : "txt");
probe_msg_label1.visible(true);
probe_msg_label2.visible(true);
serial_msg_ts = millis();
main_screen.update();
save_settings();
}
If it's a long click, we change the baud rate. That's handled below. A lot of it is basically the same, sans needing to fiddle with the multiple click even/odd scenario:
static void button_a_on_long_click(void* state) {
if (lcd_dimmer.dimmed()) {
lcd_wake();
lcd_dimmer.wake();
return;
}
if (++serial_baud_index == serial_bauds_size) {
serial_baud_index = 0;
}
probe_msg_label1.text("[ baud ]");
char buf[16];
int baud = (int)serial_bauds[serial_baud_index];
itoa((int)baud, buf, 10);
probe_msg_label2.text(buf);
probe_msg_label1.visible(true);
probe_msg_label2.visible(true);
serial_msg_ts = millis();
SER.updateBaudRate(baud);
main_screen.update();
save_settings();
}
What's a little different here is firstly we're indexing into an array of baud rates, and finally, we set the baud rate whenever we change it here. Other than that, the routine is functionally very similar to the regular click one.
The left button is trivial, but provided here for completeness. Remember the actual pause functionality of this button is handled inside loop()
rather than the click handler:
static void button_b_on_click(int clicks, void* state) {
lcd_wake();
lcd_dimmer.wake();
}
I2C Updater Thread
An I2C bus is a finicky thing, particularly when there are misbehaving devices, or too many devices on a bus. Rather than risking hanging the primary application thread, we let the otherwise dormant second core handle periodic scanning of the I2C bus.
void i2c_update_task(void* state) {
while (true) {
vTaskDelay(1);
I2C.begin(I2C_SDA, I2C_SCL);
i2c_set_pin(0, I2C_SDA, I2C_SCL, true, true, I2C_MODE_MASTER);
I2C.setTimeOut(uint16_t(-1));
uint32_t banks[4];
memset(banks, 0, sizeof(banks));
for (byte i = 0; i < 127; i++) {
I2C.beginTransmission(i);
if (I2C.endTransmission() == 0) {
banks[i / 32] |= (1 << (i % 32));
}
}
I2C.end();
xSemaphoreTake(i2c_update_sync, portMAX_DELAY);
memcpy(i2c_addresses, banks, sizeof(banks));
xSemaphoreGive(i2c_update_sync);
i2c_updater_ran = true;
delay(1000);
}
}
Once a second, this scans the bus, packing any responsive addresses into the address bit bank we declared in our globals earlier. Once a scan completes, we lock access to our shared memory (very important!) and copy our new data into it. Finally, we update the I2C_updater_ran
value. We don't do anything else here. We use that data elsewhere. This just keeps it up to date.
I2C Refresh
This routine takes the data gathered by the updater thread from earlier and puts it into display_text
, but only if the data has changed since the last time it checked. We accomplish this with a simple memcmp()
on i2c_addresses
vs. i2c_addresses_old
, which is why we have that one.
static bool refresh_i2c() {
uint32_t banks[4];
if (i2c_updater_ran) {
xSemaphoreTake(i2c_update_sync, portMAX_DELAY);
memcpy(banks, i2c_addresses, sizeof(banks));
xSemaphoreGive(i2c_update_sync);
if (memcmp(banks, i2c_addresses_old, sizeof(banks))) {
char buf[32];
*display_text = '\0';
int count = 0;
for (int i = 0; i < 128; ++i) {
int mask = 1 << (i % 32);
int bank = i / 32;
if (banks[bank] & mask) {
if (count < probe_rows - 1) {
if (count) {
strcat(display_text, "\n");
}
++count;
snprintf(buf, sizeof(buf), "0x%02X:%d", i, i);
strncat(display_text, buf, sizeof(buf));
}
MONITOR.printf("0x%02X:%d\n", i, i);
}
}
if (!count) {
memcpy(display_text, "<none>\0", 7);
MONITOR.println("<none>");
}
MONITOR.println();
memcpy(i2c_addresses_old, banks, sizeof(banks));
return true;
}
}
return false;
}
If we did see a change between them, we basically go through all the possible bits, and if one is set we add a row of text to display_text
to account for it. You may be wondering what happens if there are two many addresses to display on the screen. The answer is the monitor will still output them, and capacitance is probably destroying your bus at that point anyway. More than 5 devices is not a great idea on I2C, not that you can't get away with more in certain circumstances.
Serial Refresh
This is probably the most complicated bit of code, due to the scrolling of incoming data and the multiple modes:
static bool refresh_serial() {
size_t available = (size_t)SER.available();
size_t advanced = 0;
if (available > 0) {
if (available > serial_data_capacity) {
available = serial_data_capacity;
}
if (!is_serial) {
serial_data_size = 0;
}
size_t serial_remaining = serial_data_capacity - serial_data_size;
uint8_t* p;
if (serial_remaining < available) {
size_t to_scroll = available - serial_remaining;
if (to_scroll < serial_data_size) {
memmove(serial_data, serial_data + to_scroll,
serial_data_size - to_scroll);
}
serial_data_size -= to_scroll;
}
p = serial_data + serial_data_size;
serial_data_size += SER.read(p, available);
if (!serial_bin) { char* sz = display_text;
uint8_t* pb = serial_data;
size_t pbc = serial_data_size;
*sz = '\0';
int cols = 0, rows = 0;
do {
if (pbc == 0) {
break;
}
uint8_t b = *pb++;
--pbc;
if (b == ' ' || isprint(b)) {
*sz++ = (char)b;
MONITOR.print((char)b);
} else {
*sz = '.';
if (b == '\n' || b == '\r' || b == '\t') {
MONITOR.print((char)b);
} else {
MONITOR.print('.');
}
}
if (rows < probe_rows - 1 && ++cols == probe_cols) {
cols = 0;
*sz++ = '\n';
++rows;
}
++advanced;
} while (pbc);
*sz = '\0';
} else { int bin_cols = probe_cols / 3, rows = 0;
int count_bin = (bin_cols)*probe_rows;
int mon_cols = 0;
uint8_t* pb = serial_data;
size_t pbc = serial_data_size;
char* sz = display_text;
*sz = '\0';
int cols = 0;
do {
if (pbc == 0) {
break;
}
uint8_t b = *pb++;
--pbc;
char buf[4];
if (bin_cols - 1 == cols) {
snprintf(buf, sizeof(buf), "%02X", b);
strcpy(sz, buf);
sz += 2;
} else {
snprintf(buf, sizeof(buf), "%02X ", b);
strcpy(sz, buf);
sz += 3;
}
if (rows < probe_rows - 1 && ++cols == bin_cols) {
cols = 0;
*sz++ = '\n';
++rows;
}
MONITOR.printf("%02X ", b);
if (++mon_cols == 10) {
MONITOR.println();
mon_cols = 0;
}
++advanced;
} while (--count_bin);
*sz = '\0';
MONITOR.println();
}
return true;
}
return false;
}
Gosh, where do I even begin? This routine happens in roughly two phases. The first phase is gathering incoming serial data, potentially scrolling old data into the trash to make room. I use memmove()
for this instead of a more efficient queue because I didn't want to debug one, and it's not a lot of data. A lot of the noise in there is just bookkeeping. I tried to keep the variable names somewhat descriptive but it's still a zoo. Anyway, after that, the goal is to rebuild display_text
with the data. This is different depending on whether the mode is text or binary. In the case of text, we just spit out all printable characters, or otherwise dots. In the case of binary, we output a series of hex values. In either case, we report true indicating that the data has changed.
Note: If serial data is coming in too fast, the tiny MCU UART buffers get overrun which happens quite easily because it takes time to update the display and we can't display that many characters at once. In these cases, you'll get skips in your serial output. It's not ideal, but this is a tiny, simple device. Not PuTTY.
/include/lcd_config.h
This file contains some defines we use in our ESP LCD Panel API initialization routine. It's configured for a Lilygo TTGO T1 Display:
#ifndef LCD_CONFIG_H
#define LCD_CONFIG_H
#ifdef TTGO_T1
#define LCD_SPI_HOST SPI3_HOST
#define LCD_BCKL_ON_LEVEL 1
#define LCD_BCKL_OFF_LEVEL !LCD_BCKL_ON_LEVEL
#define PIN_NUM_MOSI 19
#define PIN_NUM_CLK 18
#define PIN_NUM_CS 5
#define PIN_NUM_DC 16
#define PIN_NUM_RST 23
#define LCD_PANEL esp_lcd_new_panel_st7789
#define LCD_HRES 135
#define LCD_VRES 240
#define LCD_COLOR_SPACE ESP_LCD_COLOR_SPACE_RGB
#define LCD_PIXEL_CLOCK_HZ (40 * 1000 * 1000)
#define LCD_GAP_X 52
#define LCD_GAP_Y 40
#define LCD_MIRROR_X false
#define LCD_MIRROR_Y false
#define LCD_INVERT_COLOR true
#define LCD_SWAP_XY false
#endif // TTGO_T1
#endif // LCD_CONFIG_H
Essentially, these are just pin constants and errata about the display controller.
/include/lcd_init.h
This file contains a generic initialization routine that can handle most types of LCD panels. I wrote it for use in a lot of projects, and I use it here. It uses the configuration information we just covered above:
#ifndef LCD_INIT_H
#define LCD_INIT_H
#ifdef LCD_IMPLEMENTATION
#include "lcd_config.h"
#include <string.h>
#include "driver/spi_master.h"
#include "driver/gpio.h"
#include "esp_lcd_panel_ops.h"
#include "esp_lcd_panel_vendor.h"
#endif // LCD_IMPLEMENTATION
#include "esp_lcd_panel_io.h"
void lcd_panel_init(size_t max_transfer_size,
esp_lcd_panel_io_color_trans_done_cb_t done_callback);
#if !defined(LCD_IMPLEMENTATION)
extern esp_lcd_panel_handle_t lcd_handle;
extern esp_lcd_panel_io_handle_t lcd_io_handle;
extern int lcd_width;
extern int lcd_height;
#else
esp_lcd_panel_handle_t lcd_handle;
esp_lcd_panel_io_handle_t lcd_io_handle;
#ifdef LCD_SWAP_XY
int lcd_width = LCD_VRES; int lcd_height = LCD_HRES;
#else
int lcd_width = LCD_HRES;
int lcd_height = LCD_VRES;
#endif // LCD_SWAP_XY
void lcd_panel_init(size_t max_transfer_size,
esp_lcd_panel_io_color_trans_done_cb_t done_callback) {
#ifdef PIN_NUM_BCKL
gpio_set_direction((gpio_num_t)PIN_NUM_BCKL,GPIO_MODE_OUTPUT);
#endif // PIN_NUM_BCKL
#ifdef LCD_SPI_HOST // 1-bit SPI
spi_bus_config_t bus_config;
memset(&bus_config, 0, sizeof(bus_config));
bus_config.sclk_io_num = PIN_NUM_CLK;
bus_config.mosi_io_num = PIN_NUM_MOSI;
#ifdef PIN_NUM_MISO
bus_config.miso_io_num = PIN_NUM_MISO;
#else
bus_config.miso_io_num = -1;
#endif // PIN_NUM_MISO
#ifdef PIN_NUM_QUADWP
bus_config.quadwp_io_num = PIN_NUM_QUADWP;
#else
bus_config.quadwp_io_num = -1;
#endif
#ifdef PIN_NUM_QUADHD
bus_config.quadhd_io_num = PIN_NUM_QUADHD;
#else
bus_config.quadhd_io_num = -1;
#endif
bus_config.max_transfer_sz = max_transfer_size + 8;
spi_bus_initialize(LCD_SPI_HOST, &bus_config, SPI_DMA_CH_AUTO);
esp_lcd_panel_io_spi_config_t io_config;
memset(&io_config, 0, sizeof(io_config));
io_config.dc_gpio_num = PIN_NUM_DC,
io_config.cs_gpio_num = PIN_NUM_CS,
io_config.pclk_hz = LCD_PIXEL_CLOCK_HZ,
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 = done_callback;
esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)LCD_SPI_HOST,
&io_config, &
lcd_io_handle);
#elif defined(PIN_NUM_D07) // 8 or 16-bit i8080
gpio_set_direction((gpio_num_t)PIN_NUM_RD,GPIO_MODE_OUTPUT);
gpio_set_level((gpio_num_t)PIN_NUM_RD,1);
esp_lcd_i80_bus_handle_t i80_bus = NULL;
esp_lcd_i80_bus_config_t bus_config;
memset(&bus_config,0,sizeof(bus_config));
bus_config.clk_src = LCD_CLK_SRC_PLL160M;
bus_config.dc_gpio_num = PIN_NUM_RS;
bus_config.wr_gpio_num = PIN_NUM_WR;
bus_config.data_gpio_nums[0] = PIN_NUM_D00;
bus_config.data_gpio_nums[1] = PIN_NUM_D01;
bus_config.data_gpio_nums[2] = PIN_NUM_D02;
bus_config.data_gpio_nums[3] = PIN_NUM_D03;
bus_config.data_gpio_nums[4] = PIN_NUM_D04;
bus_config.data_gpio_nums[5] = PIN_NUM_D05;
bus_config.data_gpio_nums[6] = PIN_NUM_D06;
bus_config.data_gpio_nums[7] = PIN_NUM_D07;
#ifdef PIN_NUM_D15
bus_config.data_gpio_nums[8] = PIN_NUM_D08;
bus_config.data_gpio_nums[9] = PIN_NUM_D09;
bus_config.data_gpio_nums[10] = PIN_NUM_D10;
bus_config.data_gpio_nums[11] = PIN_NUM_D11;
bus_config.data_gpio_nums[12] = PIN_NUM_D12;
bus_config.data_gpio_nums[13] = PIN_NUM_D13;
bus_config.data_gpio_nums[14] = PIN_NUM_D14;
bus_config.data_gpio_nums[15] = PIN_NUM_D15;
bus_config.bus_width = 16;
#else
bus_config.bus_width = 8;
#endif // PIN_NUM_D15
bus_config.max_transfer_bytes = max_transfer_size;
esp_lcd_new_i80_bus(&bus_config, &i80_bus);
esp_lcd_panel_io_i80_config_t io_config;
memset(&io_config,0,sizeof(io_config));
io_config.cs_gpio_num = PIN_NUM_CS;
io_config.pclk_hz = LCD_PIXEL_CLOCK_HZ;
io_config.trans_queue_depth = 20;
io_config.dc_levels.dc_idle_level=0;
io_config.dc_levels.dc_idle_level = 0;
io_config.dc_levels.dc_cmd_level = 0;
io_config.dc_levels.dc_dummy_level = 0;
io_config.dc_levels.dc_data_level = 1;
io_config.lcd_cmd_bits = 8;
io_config.lcd_param_bits = 8;
io_config.on_color_trans_done = done_callback;
io_config.user_ctx = nullptr;
#ifdef LCD_SWAP_COLOR_BYTES
io_config.flags.swap_color_bytes = LCD_SWAP_COLOR_BYTES;
#else
io_config.flags.swap_color_bytes = false;
#endif // LCD_SWAP_COLOR_BYTES
io_config.flags.cs_active_high = false;
io_config.flags.reverse_color_bits = false;
esp_lcd_new_panel_io_i80(i80_bus, &io_config, &lcd_io_handle);
#endif // PIN_NUM_D15
lcd_handle = NULL;
esp_lcd_panel_dev_config_t panel_config;
memset(&panel_config, 0, sizeof(panel_config));
#ifdef PIN_NUM_RST
panel_config.reset_gpio_num = PIN_NUM_RST;
#else
panel_config.reset_gpio_num = -1;
#endif
panel_config.color_space = LCD_COLOR_SPACE;
panel_config.bits_per_pixel = 16;
LCD_PANEL(lcd_io_handle, &panel_config, &lcd_handle);
#ifdef PIN_NUM_BCKL
gpio_set_level((gpio_num_t)PIN_NUM_BCKL,LCD_BCKL_OFF_LEVEL);
#endif // PIN_NUM_BCKL
esp_lcd_panel_reset(lcd_handle);
esp_lcd_panel_init(lcd_handle);
esp_lcd_panel_swap_xy(lcd_handle, LCD_SWAP_XY);
esp_lcd_panel_set_gap(lcd_handle, LCD_GAP_X, LCD_GAP_Y);
esp_lcd_panel_mirror(lcd_handle, LCD_MIRROR_X, LCD_MIRROR_Y);
esp_lcd_panel_invert_color(lcd_handle, LCD_INVERT_COLOR);
esp_lcd_panel_disp_off(lcd_handle, false);
#ifdef PIN_NUM_BCKL
gpio_set_level((gpio_num_t)PIN_NUM_BCKL,LCD_BCKL_ON_LEVEL);
#endif // PIN_NUM_BCKL
}
#endif // LCD_IMPLEMENTATION
#endif // LCD_INIT_H
I'm providing it here in the interest of completeness but the details of the ESP LCD Panel API are beyond the scope of this article.
/include/ui.hpp
This file contains declarations used for our htcw_uix user interface components.
#pragma once
#include "lcd_config.h"
#include <uix.hpp>
using ui_screen_t = uix::screen<LCD_HRES,LCD_VRES,gfx::rgb_pixel<16>>;
using ui_label_t = uix::label<typename ui_screen_t::pixel_type,
typename ui_screen_t::palette_type>;
using ui_svg_box_t = uix::svg_box<typename ui_screen_t::pixel_type,
typename ui_screen_t::palette_type>;
extern const gfx::open_font& title_font;
extern const gfx::open_font& probe_font;
extern ui_screen_t main_screen;
extern uint16_t probe_cols;
extern uint16_t probe_rows;
extern ui_label_t title_label;
extern ui_svg_box_t title_svg;
extern ui_label_t probe_label;
extern ui_label_t probe_msg_label1;
extern ui_label_t probe_msg_label2;
extern uint16_t probe_cols;
extern uint16_t probe_rows;
void ui_init();
We're just declaring our screen and our controls so that they can be referenced in main.cpp.
/src/ui.cpp
This file complements the above by actually declaring and initializing all of our controls:
#include "lcd_config.h"
#include <ui.hpp>
#include <uix.hpp>
#include "probe.hpp"
#include <fonts/OpenSans_Regular.hpp>
#include <fonts/Telegrama.hpp>
const gfx::open_font& title_font = OpenSans_Regular;
const gfx::open_font& probe_font = Telegrama;
using namespace gfx;
using namespace uix;
using scr_color_t = color<typename ui_screen_t::pixel_type>;
using ctl_color_t = color<rgba_pixel<32>>;
svg_doc title_doc;
ui_screen_t main_screen(0,nullptr,nullptr);
ui_label_t title_label(main_screen);
ui_svg_box_t title_svg(main_screen);
ui_label_t probe_label(main_screen);
ui_label_t probe_msg_label1(main_screen);
ui_label_t probe_msg_label2(main_screen);
uint16_t probe_cols = 0;
uint16_t probe_rows = 0;
static void ui_init_main_screen() {
rgba_pixel<32> trans;
trans.channel<channel_name::A>(0);
title_label.background_color(trans);
title_label.border_color(trans);
title_label.text_color(ctl_color_t::black);
title_label.text("i2cu");
title_label.text_open_font(&title_font);
title_label.text_line_height(40);
title_label.text_justify(uix_justify::bottom_middle);
title_label.bounds(main_screen.bounds());
main_screen.register_control(title_label);
gfx_result res = svg_doc::read(&probe,&title_doc);
if(res!=gfx_result::success) {
Serial.println("Could not load title svg");
} else {
title_svg.doc(&title_doc);
title_svg.bounds(main_screen.bounds()
.offset(main_screen.dimensions().height/16,
main_screen.dimensions().height/4));
main_screen.register_control(title_svg);
}
rgba_pixel<32> bg = ctl_color_t::black;
bg.channelr<channel_name::A>(.85);
probe_label.background_color(bg);
probe_label.border_color(bg);
probe_label.text_color(ctl_color_t::white);
probe_label.text_open_font(&probe_font);
probe_label.text_line_height(20);
probe_label.text_justify(uix_justify::center_left);
probe_label.bounds(main_screen.bounds());
probe_label.visible(false);
main_screen.register_control(probe_label);
probe_rows = (main_screen.dimensions().height-
probe_label.padding().height*2)/
probe_label.text_line_height();
int probe_m;
ssize16 tsz = probe_font.measure_text(ssize16::max(),
spoint16::zero(),
"M",
probe_font.scale(
probe_label.text_line_height()));
probe_cols = (main_screen.dimensions().width-
probe_label.padding().width*2)/
tsz.width;
srect16 b = main_screen.bounds();
b=srect16(b.x1,
b.y1,
b.x2,
b.y1+probe_msg_label1.text_line_height()+
probe_msg_label1.padding().height*2).
center_vertical(main_screen.bounds());
b.offset_inplace(0,-(b.height()/2));
rgba_pixel<32> mbg = ctl_color_t::silver;
mbg.channelr<channel_name::A>(.87);
probe_msg_label1.background_color(mbg);
probe_msg_label1.border_color(mbg);
probe_msg_label1.text_color(ctl_color_t::black);
probe_msg_label1.text_open_font(&title_font);
probe_msg_label1.text_line_height(25);
probe_msg_label1.text_justify(uix_justify::center);
probe_msg_label1.bounds(b);
probe_msg_label1.visible(false);
main_screen.register_control(probe_msg_label1);
probe_msg_label2.background_color(mbg);
probe_msg_label2.border_color(mbg);
probe_msg_label2.text_color(ctl_color_t::black);
probe_msg_label2.text_open_font(&probe_font);
probe_msg_label2.text_line_height(25);
probe_msg_label2.text_justify(uix_justify::center);
b.offset_inplace(0,probe_msg_label1.bounds().height());
probe_msg_label2.bounds(b);
probe_msg_label2.visible(false);
main_screen.register_control(probe_msg_label2);
main_screen.background_color(scr_color_t::white);
}
void ui_init() {
ui_init_main_screen();
}
What we're doing here is laying out and setting up our controls. We start with a label
and an svg_box
for the title screen. We move on to creating the probe label and alpha blending the background so that the title screen shows through. Then we create a couple of message labels we use for displaying information while changing settings. During this mess, we compute the number of available columns and rows we can use for laying out the text.
/platformio.ini
Where would we be without this configuration file? Here's the magic sauce for our project:
[env:ttgo-t1]
platform = espressif32
board = ttgo-t1
framework = arduino
monitor_speed = 115200
monitor_filters = esp32_exception_decoder
upload_speed = 921600
lib_ldf_mode = deep
lib_deps = codewitch-honey-crisis/htcw_uix
codewitch-honey-crisis/htcw_button
codewitch-honey-crisis/htcw_lcd_miser
codewitch-honey-crisis/htcw_freertos_thread_pack
build_unflags = -std=gnu++11
build_flags = -std=gnu++17
-DTTGO_T1
;upload_port = COM3
;monitor_port = COM3
You'll note that we've updated the compiler standards to GNU C++17. htcw_gfx requires C++14 or better to compile, and htcw_uix currently requires C++17 or better, but I may relax that in the future.
The only other significant files are our font and SVG headers all of which were generated using my online font/image converter tool.
History
- 16th March, 2023 - Initial submission