i2cu:TTGO T1 显示屏的 I2C 总线探测工具





1.00/5 (1投票)
用这个简单的设备轻松确定 I2C 总线上的活动设备。
引言
最近,我一直在为一个工作项目忙碌,其中涉及一些严肃的硬件破解。我们有电路板要测试,电路要验证,有些还要事后重新布线。真是一团糟。
我真正需要的是一个快速而简单的探测工具,用于现场检查我们板上的哪些设备正在响应。我当时手头有一个 Seeduino Xaio,所以就用了它,但老实说,编程它们还有些不足之处,没有屏幕,也没有 LIPO 电池连接,所以唯一的使用方式是通过 USB 数据线连接到串行监视器。这在紧急情况下可以工作。我想要更好的。
必备组件
Lilygo TTGO T1 显示屏是一个极其有用的设备,集成了 ESP32、一个小型彩色显示屏、两个可编程按钮以及连接 LIPO 电池的能力。买 5 个。它们很便宜,特别是在 AliExpress 上购买,大约 12 美元,但你需要等待来自亚洲的运输。你总能找到一些可以用它们的地方。
我们将在这个项目中使用一个。你还需要几根连接到上述设备的导线作为探测线。引脚 21 是 SDA。引脚 22 是 SCL,这两个引脚上方的是 GND。你只需将它们连接到你的电路中即可探测总线。非常简单。
要构建这个项目,你需要安装了 PlatformIO 扩展的 Visual Studio Code。
理解这段乱码
这个项目大量使用了我的物联网生态系统。特别是,它使用 htcw_uix 和 htcw_gfx 来渲染屏幕。htcw_uix 是我最近创建的一个初具规模的基于控件/小部件的用户界面系统。htcw_gfx 是 htcw_uix 用于实际绘制控件的底层图形库。
我们将使用一些先进(对于物联网而言)的技术,如 True Type 字体、可伸缩矢量图形和 alpha 混合。我们还将利用 ESP32 的两个核心。
第一个核心处理 UI/UX 演示,第二个核心处理 I2C 总线扫描,它每秒扫描一次,每次更新活动地址。
一旦你启动它,它会显示标题屏幕,直到有数据进来,此时它会在标题屏幕上叠加包含数据的探测窗口。
LCD 会在一段时间后变暗,最终使显示屏进入睡眠状态。这种情况会持续到 I2C 总线发生某种状态变化,或者直到按下按钮。
编写这个混乱的程序
src/main.cpp
首先,我们来介绍一下所有重要事情发生的 main.cpp。
在文件的顶部,事情相当无聊。我们有一些定义、一些包含和一些命名空间导入。
#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;
上面引入了 Arduino 框架、ESP LCD 面板 API,以及 TTGO 显示屏的相同配置、htcw_uix、我的 LCD 调光器、我的按钮库、一些低级 I2C 功能、用户界面以及我的 FreeRTOS 线程包和一些用于线程和同步的标准库。这都很无聊,但同样必要。
// 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);
上面是一些函数原型。它们在注释中简单描述,但在我们实际在下游介绍它们之前可能意义不大。
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>>;
为了可维护性和节省我的手指,我大量使用 typedef
和 using
。我也倾向于为我的许多代码使用模板类,你可以在这里看到。我们正在设置调光器、用于两种不同二进制像素格式的两种颜色枚举、两个按钮(每个都在中断上)以及一个“multi_button
”来封装这些“原始”按钮,提供额外的功能。最后,我们声明了屏幕类型,这是为了 htcw_uix,以便它知道我们正在绘制的分辨率和像素格式。
static thread updater;
static SemaphoreHandle_t update_sync;
static volatile std::atomic_bool updater_ran;
这三行声明了我们需要在第二个核心上托管线程的变量。第一个是我们将在上面运行的实际线程,第二个是我们用于同步线程定期刷新数据访问的信号量的句柄,最后一个变量将在线程的第一次任务迭代完成后为 true。volatile
很重要,这样编译器就不会尝试插入代码来缓存该值。
struct i2c_data {
uint32_t banks[4];
};
static i2c_data i2c_addresses;
static i2c_data i2c_addresses_old;
这个 struct
及其关联的全局变量声明了我们用于存储总线上当前所有活动 I2C 地址的存储空间。我们将其存储在 4 个 32 位存储区中,总共 128 位——每个地址对应一位。如果设备存在,其关联位将为 1。否则,它将为 0。请注意,我们还保留了前一个(旧)地址的副本,以便我们可以比较它们以查看它们是否已更改。
static char display_text[8 * 1024];
这个 8KB 缓冲区存储我们在屏幕上显示的文本。考虑到显示屏的大小,它很慷慨,但我们仍有足够的内存,所以这不是问题。
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;
这些东西支持我们的 LCD 面板。
第一个声明是我们的显示缓冲区的大小,设置为 64KB。我们可以选择使用两个渲染缓冲区来提高 DMA 性能,所以我们这样做了,因为我们有 128KB 的空闲内存,分成两个 64KB 的块。至少需要一个缓冲区,因此此应用程序的最小连续空闲块是 lcd_buffer_size
,即 64KB,但最佳是两个缓冲区总共 128KB。最理想的设置是容纳显示屏整个帧缓冲区所需的内存量,但在没有 PSRAM 的情况下,保留那么多内存是禁止的。128KB 已经足够了——甚至超出了我们实际需要的。
如果显示屏处于睡眠状态,则 lcd_sleeping
变量为 true,否则为 false。为了节省电池,我们在显示屏淡出后将其置于睡眠状态,然后在需要时将其唤醒。
最后,lcd_dimmer
负责我们的显示超时,它可以像智能手机一样整齐地淡出。
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);
这些是我们的按钮实例。我的按钮库的工作方式是,它被分解为核心按钮功能,然后根据需要可能用扩展功能封装。这里我们有两个“原始”按钮,它们用 button_t
(multi_button
) 封装,以提供多击和长按功能。
现在来点干货。我们来看看 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);
}
我们做的第一件事是分配我们的第一个 LCD 传输缓冲区(lcd_buffer1
)。如果失败——这不应该发生,应用程序将停止并向监视器端口发送错误。
接下来,我们初始化 LCD 调光器。
现在我们将 I2C 地址数据初始化为空,然后设置并启动 updater
线程。
接下来是按钮初始化。
之后,我们继续初始化 LCD 面板,如果失败,应用程序将停止并报错。
之后,我们初始化第二个 LCD 缓冲区。我们稍微晚一点进行,以便其他事物有机会先分配,因为这个缓冲区不是绝对必需的——它只是提高了性能。然而,通常更好的选择是将缓冲区大小减半,这样你就可以使用两个缓冲区。这就是为什么如果无法分配它会发出警告的原因。
现在我们必须用我们的缓冲区指针重新初始化 main_screen
,因为它们已经被分配了。我们将屏幕的回调设置为 uix_on_flush()
,以便发送到显示屏。
现在我们初始化用户界面,它创建了屏幕上出现的标签和图形等。这些乱七八糟的东西包含在 ui.hpp 和 ui.cpp 中。
最后,我们清除 display_text
缓冲区,然后只向监视器打印一些关于我们的 SRAM 使用情况的信息。
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();
}
}
考虑到它是主应用程序循环,这里内容不多。那是因为我们在其他地方做了大量工作。在这里,我们给我们的调光器和按钮协程一个更新的机会,然后我们刷新我们的 I2C 数据,它报告数据是否自上次刷新以来已更改。如果更改了,我们确保显示屏已唤醒并且亮度已调高,将更改的事实打印到串行端口,然后用我们的新显示文本更新标签。最后,我们确保标签可见。如果 LCD 屏幕仍在显示,我们确保显示控制器已唤醒,并更新屏幕。否则,我们将显示屏置于睡眠状态。
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());
}
这负责将 htcw_uix 数据发送到显示屏,因为它更新屏幕。它使用 Espressif ESP LCD 面板 API 进行实际传输,使用 htcw_uix 发送的位图数据和坐标。esp_lcd_panel_draw_bitmap()
例程的一个怪癖是它要求结束的 x 和 y 坐标分别向右和向下超射其目的地 1 个像素。如果 x2
和 y2
的计算看起来“错误”,那就是原因。它们是正确的。
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;
}
此例程由 LCD 面板 API 调用,并通知主屏幕由 esp_lcd_panel_draw_bitmap()
启动的异步 DMA 传输已完成。htcw_uix
使用此信息来协调异步传输。
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;
}
}
这两个例程分别向显示控制器写入原始命令,使其进入睡眠状态或唤醒。它们还跟踪显示器当前是否处于睡眠状态。我们使用这些来节省电池,以防您以这种方式运行 TTGO。
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();
}
这两个例程只是确保显示器已唤醒且屏幕完全点亮。说实话,我可以使用相同的回调来处理两个按钮,我们也不需要扩展的按钮功能。我们可以为此使用一个回调和原始按钮,但我这样做是为了通过更容易地插入代码来扩展它,从而使其面向未来。
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);
}
}
此例程循环往复,每秒扫描一次 I2C 总线。它重新初始化总线,并明确启用上拉电阻*,然后将超时时间一直调到最大。这是为了最大限度地提高它拾取设备的机会。
* 内部上拉电阻据称不足以满足 I2C 规范,但实际上,我发现它们基本上总是有效的。如果您愿意,您可以使用 4.7k 电阻将您的 I2C 线路上拉到 3.3v,但我认为您会没事的,尤其是在大多数分线板 I2C 设备都有上拉电阻的情况下。如果您在检测设备时遇到问题,请添加这些上拉电阻!
初始化总线后,我们创建一个由 4 个无符号 32 位整数组成的存储区,用于存储我们的结果。我们将所有内容清零,然后遍历每个地址,寻找正在监听的设备。如果我们找到一个,我们将相应存储区的相应位设置为 1。
然后,我们使用信号量仔细同步对共享数据的访问,并将结果复制到其中。请注意,我们在扫描总线时没有持有信号量,因为那会损害性能。快速获取和释放信号量比长时间持有信号量效率更高。
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;
}
此例程执行多项操作。首先,它在 updater
线程至少运行一次之前不会执行任何操作。首先,它使用信号量同步对共享数据的访问,然后将其快速复制到本地数组中,最后释放信号量。
然后,我们查看旧数据是否与新数据不同。
如果是,我们将所有地址格式化到 display_text
中。最后,我们将新数据复制到旧数据中,然后 return true
。
否则,我们 return false
,表示没有变化。
/include/ui.hpp
#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();
这个文件非常简短,基本上只是声明我们的 UI 变量,如标签、字体和屏幕。真正重要的是下一个文件。
/src/ui.cpp
#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();
}
我们将一次性地大致介绍所有这些内容,因为它代码简单,但代码量很大。我们包含字体,声明我们的控件,然后设置我们的控件。
我们这样设置,main_screen
以标题 SVG 图形和标题文本开始。请注意,TTF/OTF 字体和 SVG 等资产已使用此工具从原始形式转换为头文件。
probe_label
很有趣。我们所做的是将其叠加在整个屏幕上,但我们将背景颜色设置为半透明黑色,然后将整个标签设置为不可见。
应该注意的是,控件通常以 RGBA8888 32 位颜色格式表示颜色,无论底层屏幕的本机格式如何。这是为了方便诸如 alpha 混合和共享通用系统调色板等功能。我们在为 probe_label.background_color(bg);
创建 bg
颜色时使用了这一点。
另一个需要解释的是 probe_cols
和 probe_rows
的计算。我们实际上还没有在这个应用程序中使用它们,但它们是屏幕上可用文本字符的列数和行数的预计算值。字体宽度变得很奇怪,因为字符宽度不同,但我们使用或多或少标准的测量“M”以获得基本宽度的方法。应该注意的是,探测屏幕应该使用等宽字体。这将消除此计算中的问题。
历史
- 2023 年 3 月 13 日 - 初次提交