Click here to Skip to main content
15,613,250 members
Articles / Internet of Things
Posted 16 Mar 2023

Tagged as


8 bookmarked

i2cu Take Two: Serial and I2C Probe in a Handy Package

Rate me:
Please Sign up or sign in to vote.
4.83/5 (5 votes)
16 Mar 2023MIT14 min read
Debug your circuits with this handy device
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.

i2cu serial output example


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.


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.


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


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

// where the serial monitor output
// goes
#define MONITOR Serial
// the I2C probe connections
#define I2C Wire
#define I2C_SDA 21
#define I2C_SCL 22
// the serial probe connections
#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"
#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.


Moving on, we have a bunch of function prototypes for later, which I've commented.

// htcw_uix calls this to send a bitmap to the LCD Panel API
static void uix_on_flush(point16 location,
                         bitmap<rgb_pixel<16>>& bmp,
                         void* state);
// the ESP Panel API calls this when the bitmap has been sent
static bool lcd_flush_ready(esp_lcd_panel_io_handle_t panel_io,
                            esp_lcd_panel_io_event_data_t* edata,
                            void* user_ctx);
// put the display controller and panel to sleep
static void lcd_sleep();
// wake up the display controller and panel
static void lcd_wake();
// check if the i2c address list has changed and
// rebuild the list if it has
static bool refresh_i2c();
// check if there is serial data incoming
// rebuild the display if it has
static bool refresh_serial();
// saves the settings
static void save_settings();
// click handler for button a
static void button_a_on_click(int clicks, void* state);
// long click handler for button a
static void button_a_on_long_click(void* state);
// click handler for button b (not necessary, but
// for future proofing in case the buttons get used
// later)
static void button_b_on_click(int clicks, void* state);
// thread routine that scans the bus and
// updates the i2c address list
static void i2c_update_task(void* state);


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


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:

// i2c update thread data
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.

// i2c address data
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.

// serial data
static const int serial_bauds[] = {
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:

// probe display data
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:

// lcd panel ops and dimmer data
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:

// button data
static button_a_raw_t button_a_raw;      // right
static button_b_raw_t button_b_raw;      // left
static button_t button_a(button_a_raw);  // right
static button_t button_b(button_b_raw);  // left

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.


Now to some meat - the setup() routine. This is where we initialize everything, of course:

void setup() {
    // load our previous settings
    SPIFFS.begin(true, "/spiffs", 1);
    if (SPIFFS.exists("/settings")) {
        File file ="/settings");*)&serial_baud_index, sizeof(serial_baud_index));*)&serial_bin, sizeof(serial_bin));
        MONITOR.println("Loaded settings");
    // begin serial probe
    SER.begin(serial_bauds[serial_baud_index], SERIAL_8N1, SER_RX, -1);

    // allocate the primary display buffer
    lcd_buffer1 = (uint8_t*)malloc(lcd_buffer_size);
    if (lcd_buffer1 == nullptr) {
        MONITOR.println("Error: Out of memory allocating lcd_buffer1");
        while (1)
    // clear the i2c data
    memset(&i2c_addresses_old, 0, sizeof(i2c_addresses_old));
    memset(&i2c_addresses, 0, sizeof(i2c_addresses));
    // start up the i2c updater
    i2c_updater_ran = false;
    i2c_update_sync = xSemaphoreCreateMutex();
    if (i2c_update_sync == nullptr) {
        MONITOR.println("Could not allocate I2C updater semaphore");
        while (1)
    // 1-affinity = use the core that isn't this one:
    i2c_updater = thread::create_affinity(1 - thread::current().affinity(),
    if (i2c_updater.handle() == nullptr) {
        MONITOR.println("Could not allocate I2C updater thread");
        while (1)
    // hook up the buttons
    // init the lcd
    lcd_panel_init(lcd_buffer_size, lcd_flush_ready);
    if (lcd_handle == nullptr) {
        MONITOR.println("Could not initialize the display");
        while (1)
    // allocate the second display buffer (optional)
    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");
    // reinitialize the screen with valid pointers
    main_screen = screen_t(lcd_buffer_size, lcd_buffer1, lcd_buffer2);
    // initialize the UI components
    // compute the amount of string we need to fill the display
    display_text_capacity = probe_cols * (probe_rows + 1) + 1;
    // and allocate it (shouldn't be much)
    display_text = (char*)malloc(display_text_capacity);
    if (display_text == nullptr) {
        MONITOR.println("Could not allocate display text");
        while (1)
    *display_text = '\0';
    // compute and allocate our serial buffer
    // similar to above
    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)
    // report the memory vitals
    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);

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:

  1. 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.
  2. Start the serial probe line.
  3. Allocate the primary LCD transfer buffer.
  4. Initialize the LCD dimmer.
  5. Set the I2C address lists to cleared.
  6. Start the I2C updater thread.
  7. Connect and initialize the buttons.
  8. Initialize the LCD display.
  9. Try to allocate a secondary LCD transfer buffer (for best performance).
  10. Reinitialize the main screen with valid transfer buffer pointers.
  11. Initialize the user interface components.
  12. Compute and allocate our display text buffer.
  13. Compute and allocate our serial data buffer.
  14. Report memory statistics.


Here lies the main logic of the application. Again, I've tried to provide commentary along with the code:

void loop() {
    // timeout the serial settings display
    // if it's showing
    if (serial_msg_ts && millis() > serial_msg_ts + 1000) {
        serial_msg_ts = 0;
    // pause the app while the buttons are pressed
    while (button_a.pressed() || button_b.pressed()) {
    // give everything a chance to update
    // if the i2c has changed, update the display
    if (refresh_i2c()) {
        is_serial = false;
        // otherwise if the serial has changed,
        // update the display
    } else if (refresh_serial()) {
        is_serial = true;
    // if we're dimmed all the way, just
    // sleep, and stop updating the
    // screen. Otherwise ensure
    // the display controller
    // is awake and update
    if (lcd_dimmer.faded()) {
    } else {

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:

// writes bitmap data to the 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;
// informs UIX that a previous flush was complete
static bool lcd_flush_ready(esp_lcd_panel_io_handle_t panel_io,
                            esp_lcd_panel_io_event_data_t* edata,
                            void* user_ctx) {
    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:

// puts the ST7789 to sleep
static void lcd_sleep() {
    if (!lcd_sleeping) {
        uint8_t params[] = {};
        lcd_sleeping = true;
// wakes the ST7789
static void lcd_wake() {
    if (lcd_sleeping) {
        uint8_t params[] = {};
        lcd_sleeping = false;


We handle the persistence of configuration data with the save_settings() routine:

// saves the current configuration to flash
static void save_settings() {
    File file;
    if (!SPIFFS.exists("/settings")) {
        file ="/settings", "wb", true);
    } else {
        file ="/settings", "wb");;
    file.write((uint8_t*)&serial_baud_index, sizeof(serial_baud_index));
    file.write((uint8_t*)&serial_bin, sizeof(serial_bin));

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_labels 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.

// right button on click
static void button_a_on_click(int clicks, void* state) {
    // if it's dimmed, wake it and eat one
    // click
    if (lcd_dimmer.dimmed()) {
    // eat all the clicks, setting serial_bin
    // accordingly
    serial_bin = (serial_bin + (clicks & 1)) & 1;
    // update the message controls
    probe_msg_label1.text("[ mode ]");
    probe_msg_label2.text(serial_bin ? "bin" : "txt");
    // start the message timeout
    serial_msg_ts = millis();
    // ensure the screen is up to date
    // save the config

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:

// right button on long click
static void button_a_on_long_click(void* state) {
    // wake if necessary, and return if
    // that's the case
    if (lcd_dimmer.dimmed()) {
    // otherwise, change baud rate
    if (++serial_baud_index == serial_bauds_size) {
        serial_baud_index = 0;
    // update the message controls
    probe_msg_label1.text("[ baud ]");
    char buf[16];
    int baud = (int)serial_bauds[serial_baud_index];
    itoa((int)baud, buf, 10);
    // start the message timeout
    serial_msg_ts = millis();
    // update the baud rate
    // update the main screen
    // save the config

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:

// left button on click
static void button_b_on_click(int clicks, void* state) {
    // just wake the display

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.

// scan the i2c bus periodically
// (runs on alternative core)
void i2c_update_task(void* state) {
    while (true) {
        I2C.begin(I2C_SDA, I2C_SCL);
        // ensure pullups
        i2c_set_pin(0, I2C_SDA, I2C_SCL, true, true, I2C_MODE_MASTER);
        // catch slow devices
        // clear the banks
        uint32_t banks[4];
        memset(banks, 0, sizeof(banks));
        // for every address
        for (byte i = 0; i < 127; i++) {
            // start a transmission, and see
            // if it's successful
            if (I2C.endTransmission() == 0) {
                // if so, set the corresponding bit
                banks[i / 32] |= (1 << (i % 32));
        // safely update the main address list
        xSemaphoreTake(i2c_update_sync, portMAX_DELAY);
        memcpy(i2c_addresses, banks, sizeof(banks));
        // say we ran
        i2c_updater_ran = true;

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.

// refresh the i2c display if it has changed,
// reporting true if so
static bool refresh_i2c() {
    uint32_t banks[4];
    // don't try anything until we've run once
    if (i2c_updater_ran) {
        // safely copy out the share address list
        xSemaphoreTake(i2c_update_sync, portMAX_DELAY);
        memcpy(banks, i2c_addresses, sizeof(banks));
        // if our addresses have changed
        if (memcmp(banks, i2c_addresses_old, sizeof(banks))) {
            char buf[32];
            *display_text = '\0';
            int count = 0;
            // for each address
            for (int i = 0; i < 128; ++i) {
                int mask = 1 << (i % 32);
                int bank = i / 32;
                // if its bit is set
                if (banks[bank] & mask) {
                    // if we still have room
                    if (count < probe_rows - 1) {
                        // insert newlines at the end of the
                        // previous row, if there was one
                        if (count) {
                            strcat(display_text, "\n");
                        // display an address
                        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) {
                // display none if there weren't any
                memcpy(display_text, "<none>\0", 7);
            // set the old addresses to the latest
            memcpy(i2c_addresses_old, banks, sizeof(banks));
            // return true, indicating a change
            return true;
    // no change
    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:

// refresh the serial display if it has changed
// reporting true if so
static bool refresh_serial() {
    // get the available data count
    size_t available = (size_t)SER.available();
    size_t advanced = 0;
    // if we have incoming data
    if (available > 0) {
        if (available > serial_data_capacity) {
            available = serial_data_capacity;
        // start over if we're just switching to serial
        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;
            // scroll the serial buffer
            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 +=, available);
        if (!serial_bin) {  // text
            // pointer to our display text
            char* sz = display_text;
            uint8_t* pb = serial_data;
            size_t pbc = serial_data_size;
            // null terminate it
            *sz = '\0';
            int cols = 0, rows = 0;
            do {
                // get the next serial
                if (pbc == 0) {
                uint8_t b = *pb++;
                // if it's printable, print it
                // otherwise, print '.'
                if (b == ' ' || isprint(b)) {
                    *sz++ = (char)b;
                } else {
                    // monitor follows slightly different rules
                    *sz = '.';
                    if (b == '\n' || b == '\r' || b == '\t') {
                    } else {
                // insert newlines as necessary
                if (rows < probe_rows - 1 && ++cols == probe_cols) {
                    cols = 0;
                    *sz++ = '\n';
            } while (pbc);
            *sz = '\0';
        } else {  // binary
            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;
            // our display pointer
            char* sz = display_text;
            // null terminate it
            *sz = '\0';
            int cols = 0;
            do {
                if (pbc == 0) {
                uint8_t b = *pb++;
                char buf[4];
                // format the binary column
                // inserting spaces as necessary
                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;
                // insert newlines as necessary
                if (rows < probe_rows - 1 && ++cols == bin_cols) {
                    cols = 0;
                    *sz++ = '\n';
                // dump to the monitor
                MONITOR.printf("%02X ", b);
                if (++mon_cols == 10) {
                    mon_cols = 0;
            } while (--count_bin);
            *sz = '\0';
        // report a change
        return true;
    // no change
    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.


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 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 PIN_NUM_BCKL 4
#define LCD_PANEL esp_lcd_new_panel_st7789
#define LCD_HRES 135
#define LCD_VRES 240
#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.


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:

// Generic ESP LCD Panel API initialization code
#ifndef LCD_INIT_H
#define LCD_INIT_H
// define LCD_IMPLEMENTATION in exactly one source file
#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"
#include "esp_lcd_panel_io.h"

// global so it can be used after init
void lcd_panel_init(size_t max_transfer_size,
        esp_lcd_panel_io_color_trans_done_cb_t done_callback);
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;
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; // swapped
int lcd_height = LCD_HRES;
int lcd_width = LCD_HRES;
int lcd_height = LCD_VRES;
#endif                    // LCD_SWAP_XY

// initialize the screen using the esp lcd panel API
void lcd_panel_init(size_t max_transfer_size,
        esp_lcd_panel_io_color_trans_done_cb_t done_callback) {
#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;
    bus_config.miso_io_num = PIN_NUM_MISO;
    bus_config.miso_io_num = -1;
#endif // PIN_NUM_MISO
    bus_config.quadwp_io_num = PIN_NUM_QUADWP;
    bus_config.quadwp_io_num = -1;
    bus_config.quadhd_io_num = PIN_NUM_QUADHD;
    bus_config.quadhd_io_num = -1;
    bus_config.max_transfer_sz = max_transfer_size + 8;

    // Initialize the SPI bus on LCD_SPI_HOST
    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;
    // Attach the LCD to the SPI bus
                            &io_config, &
#elif defined(PIN_NUM_D07) // 8 or 16-bit i8080
    esp_lcd_i80_bus_handle_t i80_bus = NULL;
    esp_lcd_i80_bus_config_t 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;
    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;
    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_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;
    io_config.flags.swap_color_bytes = LCD_SWAP_COLOR_BYTES;
    io_config.flags.swap_color_bytes = false;
    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;
    panel_config.reset_gpio_num = -1;
    panel_config.color_space = LCD_COLOR_SPACE;
    panel_config.bits_per_pixel = 16;

    // Initialize the LCD configuration
    LCD_PANEL(lcd_io_handle, &panel_config, &lcd_handle);
    // Turn off backlight to avoid unpredictable display on 
    // the LCD screen while initializing
    // the LCD panel driver. (Different LCD screens may need different levels)
#endif // PIN_NUM_BCKL
    // Reset the display

    // Initialize LCD panel

    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);
    // Turn on the screen
    esp_lcd_panel_disp_off(lcd_handle, false);
    // Turn on backlight (Different LCD screens may need different levels)
#endif // PIN_NUM_BCKL
#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.


This file contains declarations used for our htcw_uix user interface components.

#pragma once
#include "lcd_config.h"
#include <uix.hpp>
// user interface controls
// and screen declarations
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;
// main screen
extern ui_label_t title_label;
extern ui_svg_box_t title_svg;
// probe screen
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.


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;
// declare native pixel type color enum
// for the screen
using scr_color_t = color<typename ui_screen_t::pixel_type>;
// declare 32-bit pixel color enum
// for controls
using ctl_color_t = color<rgba_pixel<32>>;

// our title SVG
svg_doc title_doc;

// the screen
ui_screen_t main_screen(0,nullptr,nullptr);

// main screen controls
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);
// holds how many cols and rows
// are available
uint16_t probe_cols = 0;
uint16_t probe_rows = 0;

// set up all the main screen
// controls
static void ui_init_main_screen() {
    // create a transparent color
    rgba_pixel<32> trans;<channel_name::A>(0);
    // load the SVG
    gfx_result res = svg_doc::read(&probe,&title_doc);
    if(res!=gfx_result::success) {
        Serial.println("Could not load title svg");
    } else {
    // create the probe text label
    rgba_pixel<32> bg = ctl_color_t::black;


    // compute the probe columns and rows
    probe_rows = (main_screen.dimensions().height-
    int probe_m;
    // we use the standard method of measuring M
    // to determine the font width. This really
    // should be used with monospace fonts though
    ssize16 tsz = probe_font.measure_text(ssize16::max(),
    probe_cols = (main_screen.dimensions().width-
    // now compute where our probe
    // configuration message labels
    // go
    srect16 b = main_screen.bounds();
    rgba_pixel<32> mbg = ctl_color_t::silver;



void ui_init() {

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.


Where would we be without this configuration file? Here's the magic sauce for our project:

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
build_unflags = -std=gnu++11
build_flags = -std=gnu++17 
;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.


  • 16th March, 2023 - Initial submission


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

Written By
United States United States
Just a shiny lil monster. Casts spells in C++. Mostly harmless.

Comments and Discussions

QuestionContact Pin
Johann Nothnagel24-Mar-23 2:54
professionalJohann Nothnagel24-Mar-23 2:54 
AnswerRe: Contact Pin
honey the codewitch24-Mar-23 5:36
mvahoney the codewitch24-Mar-23 5:36 
GeneralRe: Contact Pin
Johann Nothnagel26-Mar-23 12:05
professionalJohann Nothnagel26-Mar-23 12:05 
GeneralRe: Contact Pin
honey the codewitch26-Mar-23 17:59
mvahoney the codewitch26-Mar-23 17:59 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.