Updated on 2023-03-13
Easily determine active devices on an I2C bus with this simple device
Recently, I've been slamming on a project for work with some serious hardware hacking involved. We've got boards to test, circuits to verify, some to rewire after the fact. It's a zoo.
One thing I really needed was a quick and dirty probe to use in the field to check which devices on our boards were responding. I used a Seeduino Xaio because I had one handy at the time, but to be honest, programming them leaves something to be desired, there's no screen, nor a LIPO battery connection, so the only way to use it is connected via a USB cable to a serial monitor. That worked in a pinch. I wanted something better.
The Lilygo TTGO T1 Display is a ridiculously useful device containing an integrated ESP32, a small color display, and two programmable buttons and the ability to connect a LIPO battery. Buy 5. They're cheap, particularly if you source them on AliExpress where they are about $12 USD, but you have to wait for shipping from Asia. You will always find something you can use them for.
We'll be using one for this project. You'll also need a few wires connected to the above device to use as probe lines. Pin 21 is SDA. Pin 22 is SCL, and the pin above those two is GND. You simply wire those into your circuit to probe a bus. Simple simple.
To build this project you'll need Visual Studio Code with the PlatformIO extension installed.
This project makes heavy use of my IoT ecosystem. Particularly, it's using htcw_uix with htcw_gfx to render the screens. htcw_uix is a fledgling control/widget based user interface system I created recently. htcw_gfx is the underlying graphics library htcw_uix uses to do actual draw of controls.
We'll be using several advanced (for IoT) technologies like True Type fonts, Scalable Vector Graphics, and alpha blending. We'll also be taking advantage of both cores of the ESP32.
The first core handles the UI/UX presentation, and the 2nd core handles scanning the I2C bus, which it does once a second, updating the active addresses each time.
Once you start the thing it displays the title screen until some data comes in, at which point it overlays the title screen with the probe window containing the data.
The LCD will dim after awhile, eventually putting the display to sleep. This happens until the I2C bus changes state somehow, or until a button is pressed.
First, let's cover main.cpp where everything important happens.
At the top of the file, things are pretty boring. We have some defines, some includes, and some namespace imports.
#define I2C Wire
#define I2C_SDA 21
#define I2C_SCL 22
#include <Arduino.h>
#include <Wire.h>
#include <atomic>
#include <button.hpp>
#include <lcd_miser.hpp>
#include <thread.hpp>
#include <uix.hpp>
#define LCD_IMPLEMENTATION
#include "lcd_init.h"
#include "driver/i2c.h"
#include "ui.hpp"
using namespace arduino;
using namespace gfx;
using namespace uix;
using namespace freertos;
The above pulls in the Arduino framework, the ESP LCD Panel API, and the configuration of the same for the TTGO's display, htcw_uix, my LCD dimmer, my button library, a little bit of lower level I2C stuff, the user interface and my FreeRTOS Thread Pack and some of the standard library for threading and synchronization. This is all very boring, but just as necessary.
// 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();
// click handler for button a
static void button_a_on_click(int clicks, 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 update_task(void* state);
Above are some function prototypes. They're described in the comments briefly, but probably won't make much sense until we actually cover them downstream.
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>>;
For maintainability and to save my fingers, I use typedef and using a lot. I also tend to use template classes for a lot of my code, as you can see here. We're setting up the dimmer, two color enumerations for two different binary pixel formats, two buttons, each on an interrupt, and a "multi_button" that wraps those "raw" buttons, providing extra functionality. Finally, we declare the screen type, which is for htcw_uix so it knows the resolution and pixel format of what we're drawing to.
static thread updater;
static SemaphoreHandle_t update_sync;
static volatile std::atomic_bool updater_ran;
These three lines declare the variables we need to host a thread on the second core. The first is the actual thread we'll be spinning on, the second is the handle for the semaphore we use to synchronize access to the data that the thread refreshes periodically, and the final variable will be true after the first task iteration of the thread completes. volatile is important so the compiler doesn't try to insert code to cache the value of that.
struct i2c_data {
uint32_t banks[4];
};
static i2c_data i2c_addresses;
static i2c_data i2c_addresses_old;
This struct and its associated global variables declare our storage for all of the active I2C addresses currently on the bus. We store it in 4 32-bit banks, for a total of 128 bits - one for each address. If the device is present, its associated bit will be one. Otherwise, it will be zero. Note that we also hold a copy of the previous (old) addresses so we can compare them to see if they have changed.
static char display_text[8 * 1024];
This 8KB buffer holds our text that we display on the screen. It's generous, given the size of the display, but we still have plenty of memory so it's not a bother.
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;
This stuff supports our LCD panel.
The first declaration is the size of our display buffer(s), which is set to 64KB. We can optionally use two rendering buffers to increase DMA performance, so we do that, given we have 128KB to spare across two 64KB chunks. At least one buffer is required, so the minimum free contiguous block for this application is lcd_buffer_size, or 64KB, but the optimal is 128KB over two buffers. The most optimal setting would be the size required to hold the entire frame buffer for the display, but reserving that amount of memory is prohibitive without PSRAM. 128KB is plenty adequate - even more than we really need.
The lcd_sleeping variable is true if the display is asleep, or false if it's awake. We put the display to sleep after we fade it out to save battery, and then wake it back up on demand.
Finally, lcd_dimmer is what handles timing out our display, which it can fade out neatly, like a smartphone would.
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);
These are our button instances. The way my button library works is it is segregated into core button functionality, and then potentially wrapped with extended functionality as required. Here we have two "raw" buttons, and those are wrapped with a button_t (multi_button) in order to provide multi-click and long click functionality.
And now for some red meat. Let's cover setup():
void setup() {
Serial.begin(115200);
lcd_buffer1 = (uint8_t*)malloc(lcd_buffer_size);
if (lcd_buffer1 == nullptr) {
Serial.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));
updater_ran = false;
update_sync = xSemaphoreCreateMutex();
updater = thread::create_affinity(1 - thread::current().affinity(),
update_task,
nullptr,
10,
2000);
updater.start();
button_a.initialize();
button_b.initialize();
button_a.on_click(button_a_on_click);
button_b.on_click(button_b_on_click);
lcd_panel_init(lcd_buffer_size, lcd_flush_ready);
if (lcd_handle == nullptr) {
Serial.println("Could not init the display");
while (1)
;
}
lcd_buffer2 = (uint8_t*)malloc(lcd_buffer_size);
if (lcd_buffer2 == nullptr) {
Serial.println("Warning: Out of memory allocating lcd_buffer2.");
Serial.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 = '\0';
Serial.printf("SRAM free: %0.1fKB\n",
(float)ESP.getFreeHeap() / 1024.0);
Serial.printf("SRAM largest free block: %0.1fKB\n",
(float)ESP.getMaxAllocHeap() / 1024.0);
}
The first thing we do is allocate our first LCD transfer buffer (lcd_buffer1). If this fails - which it never should, the application will halt with an error to the monitor port.
Next we initialize the lcd dimmer.
Now we initialize the I2C address data to empty, before setting up and starting the updater thread.
Button initialization comes next.
Following that, we continue by initializing the LCD panel, which will halt the application with an error if it fails.
After that, we initialize the second LCD buffer. We do it a bit later so that other things have a chance to allocate first, because this buffer isn't absolutely required - it just makes performance better. However, it's usually the better option to halve your buffer size so that you can use two of them. That's why this spits a warning if it can't allocate it.
Then we have to reinitialize our main_screen with our buffer pointer(s) now that they've been allocated. We set the callback for the screen to uix_on_flush() in order to send to the display.
Now we initialize the user interface, which creates the labels and graphics and such that appear on the screen. This mess is contained in ui.hpp and ui.cpp.
Finally, we clear the display_text buffer, and then just print some information to the monitor about our SRAM usage.
void loop() {
lcd_dimmer.update();
button_a.update();
button_b.update();
if (refresh_i2c()) {
lcd_wake();
lcd_dimmer.wake();
Serial.println("I2C changed");
probe_label.text(display_text);
probe_label.visible(true);
}
if (lcd_dimmer.faded()) {
lcd_sleep();
} else {
lcd_wake();
main_screen.update();
}
}
There's not a lot here given it's the main application loop. That's because we do the heavy lifting elsewhere. Here, we're giving our dimmer and button coroutines a chance to update, and then we refresh our I2C data, which reports if the data has changed since the last time it was refreshed. If it has, we ensure the display is awake and brightness is turned up, print the fact that it has changed to the serial port, and then update the label with our new display text. Finally, we ensure the label is visible. If the LCD screen is still showing, we make sure the display controller is awake, and update the screen. Otherwise, we put the display to sleep.
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());
}
This handles sending htcw_uix data to the display as it updates the screen. It uses the Espressif ESP LCD Panel API to do the actual transfer, using the bitmap data and coordinates sent by htcw_uix. One quirk of the esp_lcd_panel_draw_bitmap() routine is that it requires the ending x and y coordinates to overshoot their destination by 1 pixel to the right, and to the bottom, respectively. If the computation of x2 and y2 look "wrong", that's why. They're correct.
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;
}
This routine is invoked by the LCD Panel API and it notifies the main screen when the asynchronous DMA transfer initiated by esp_lcd_panel_draw_bitmap() has completed. htcw_uix uses this information to coordinate asynchronous transfers.
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;
}
}
These two routines write raw commands to the display controller to put it to sleep or wake it up, respectively. They also track whether the display is currently asleep or not. We use these to spare battery, in case you're running the TTGO that way.
static void button_a_on_click(int clicks, void* state) {
lcd_wake();
lcd_dimmer.wake();
}
static void button_b_on_click(int clicks, void* state) {
lcd_wake();
lcd_dimmer.wake();
}
These two routines simply ensure the display is awake and the screen is fully lit. Truth be told, I could have used the same callback to handle both buttons, and we didn't need the extended button functionality either. We could have used one callback and raw buttons for this, but I did it this way to future proof it by making it easier to just drop in code to extend it.
void update_task(void* state) {
while (true) {
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(update_sync, portMAX_DELAY);
memcpy(i2c_addresses.banks, banks, sizeof(banks));
xSemaphoreGive(update_sync);
updater_ran = true;
delay(1000);
}
}
This routine loops over and over and scans the I2C bus once a second. It reinitializes the bus, and explicitly enables the pullups*, and then it turns the timeout all the way up. This is to maximize the chance that it will pick up devices.
After we initialize the bus, we create a bank of 4 unsigned 32-bit integers which we use to store our result. We clear it all to zeroes and then loop through every address, looking for a listening device. If we find one, we set the corresponding bit of the corresponding bank to 1.
We then carefully synchronize access to our shared data using a semaphore, and copy our result into it. Note that we didn't hold the semaphore while scanning the bus, because that would hurt performance. It's much more efficient to quickly take and release a semaphore than it is to hold onto it for a long time.
static bool refresh_i2c() {
uint32_t banks[4];
if (updater_ran) {
xSemaphoreTake(update_sync, portMAX_DELAY);
memcpy(banks, i2c_addresses.banks, sizeof(banks));
xSemaphoreGive(update_sync);
if (memcmp(banks, i2c_addresses_old.banks, sizeof(banks))) {
char buf[32];
*display_text = '\0';
bool found = false;
for (int i = 0; i < 128; ++i) {
int mask = 1 << (i % 32);
int bank = i / 32;
if (banks[bank] & mask) {
if (found) {
strcat(display_text, "\n");
}
found = true;
snprintf(buf, sizeof(buf), "0x%02X (%d)", i, i);
strncat(display_text, buf, sizeof(buf));
}
}
if (!found) {
strncpy(display_text, "<none>", sizeof(display_text));
}
memcpy(i2c_addresses_old.banks, banks, sizeof(banks));
Serial.println(display_text);
return true;
}
}
return false;
}
This routine does several things. First of all, it won't do anything until the updater thread ran at least once. First, it synchronizes access to the shared data using a semaphore, and then copies it out quickly into a local array, before releasing the semaphore.
Then, we see if our old data is different than our new data.
If it is, we format all the addresses into display_text. Finally, we copy our new data to the old data, and then return true.
Otherwise, we return false, indicating no change.
#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;
// 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();
This file is pretty brief and basically just declares our UI variables like our labels, fonts and the screen. The real meat is in the next file.
#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);
// main screen
ui_label_t title_label(main_screen);
ui_svg_box_t title_svg(main_screen);
// probe screen
ui_label_t probe_label(main_screen);
//ui_label_t probe_msg_label1(probe_screen);
//ui_label_t probe_msg_label2(probe_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;
main_screen.background_color(scr_color_t::white);
}
void ui_init() {
ui_init_main_screen();
}
We'll be covering this all in one shot, in pretty broad strokes since it's simple code, but there is a lot of it. We include our fonts, declare our controls and then set up our controls.
We've set this up such that the main_screen starts with the title SVG graphic and the title text. Note that the assets like TTF/OTF fonts and SVGs were converted from their raw form to a header file using this tool.
The probe_label is interesting. What we've done is overlaid it over the entire screen, but we've set the background color to a semi-transparent black, and then made the whole label invisible.
It should be noted that controls typically express colors in RGBA8888 32-bit color format regardless of the underlying screen's native format. This is to facilitate things like alpha blending and sharing a common system color palette for example. We used that when we created the bg color for probe_label.background_color(bg);
Another thing that could use explanation is probe_cols and probe_rows computation. We actually do not use these in this application yet, but what they are is precomputed values for the number of available columns and rows on the screen in terms of textual characters. Font width gets weird since characters vary in width but we use the more or less standard technique of measuring "M" to get a base width. It should be noted that the probe screen should be a monospaced font. That will eliminate issues with this computation.