i2cu 再次出击:便携式串行和 I2C 探测器






4.83/5 (5投票s)
用这个方便的设备调试您的电路。
引言
本文基于我关于 i2cu 的上一篇文章。现在它具备了基本的串行监控和 I2C 扫描功能。由于源代码变得复杂得多,我不想将新功能硬塞进上一篇文章,所以我们重新开始。
本质上,这是一个用于调试硬件的现场工具。它对于检查 I2C 设备或像 ESP32 这样的芯片在启动时是否能与 UART 通信的迹象特别有用。
必备组件
前提条件与上一篇文章相同。你需要一个 Lilygo TTGO T1 Display,VS Code w/ Platform IO,以及 3 或 4 根探针线。
请原谅我接线时的笨拙,但希望这能展示这个概念。
理解这段乱码
这里有很多内容,但当你启动它并未连接到 I2C 总线或 UART 时,它会显示标题屏幕。
一旦数据从任何一种输入类型传入,它就会在传入数据发生变化时显示在屏幕上。
这意味着它会自动检测串行或 I2C 探测,这很好,因为我们的按钮数量有限,并且已经堆满了功能。
如果没有任何数据变化,屏幕会过一段时间变暗以节省电池,最终完全进入休眠状态,直到按下某个按钮或有更多数据传入。
单击左侧按钮可以唤醒显示屏,长按则暂停显示屏,以便在数据快速滚动时进行检查,这对于串行通信非常有用。
单击右侧按钮将串行模式从文本切换到二进制模式,反之亦然。长按则更改波特率。串行配置被硬编码为 8N1。
对于 I2C,将 TTGO 的地线连接到电路中的地线。SDA 连接到 21,SCL 连接到 22。
对于串行通信,将 17 连接到串行 UART 的 TX 线。
架构
我为这段代码选择了 Arduino 框架,因为这是我喜欢的平台,原因超出了本文的范围。
至于其他技术,我使用 htcw_uix 和 htcw_gfx 来渲染屏幕,使用我的 htcw_button 库来处理输入。我使用 htcw_lcd_miser 库来处理显示器的背光。我的 htcw_free_rtos_thread_pack 提供线程支持,尽管我们并没有真正使用它 - 我们可以直接使用 FreeRTOS。
编写这个混乱的程序
/src/main.cpp
和上次一样,核心代码在 main.cpp 文件中,但我们将在本文中再次对其进行回顾。我添加了更好的注释并稍微重构了代码,同时增加了功能。
定义、包含和导入
// 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"
#define LCD_IMPLEMENTATION
#include "lcd_init.h"
#include "ui.hpp"
using namespace arduino;
using namespace gfx;
using namespace uix;
using namespace freertos;
在这里,我们包含了大量内容,但基本上只是样板代码。我们显然在 main
函数中处处使用这些内容。
协议(Prototypes)
继续前进,我们有一堆函数原型用于后续内容,我已经进行了注释。
// 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);
别名
我认为模板非常有用,而类型别名是在使用模板时不可或缺的,所以这里有很多类型别名,用于我们各种事物,如 LCD 背光和按钮驱动程序,以及两种颜色枚举类型和 htcw_uix 屏幕类型。
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>>;
全局变量
人们常常不喜欢使用全局变量——并且有充分的理由。全局变量会“污染”或混乱你的命名空间,与其他文件声明的全局变量冲突,并且通常只会把事情搞得一团糟。
然而,在物联网应用中,你的应用程序不可能大到使用全局变量变得难以管理。在物联网设备上,你通常不会有庞大的项目。除非你在编写框架或库,否则可以继续使用全局变量,但尽量将它们限制在你的源文件中。
总之,我已将全局变量大致地分为相关区域,我将一一介绍。
首先,我们有 I2C 更新器线程信息。更新器在辅助核心上运行,这样设备超时就不会对主应用程序线程的性能产生负面影响。为了实现这一点,我们使用互斥量来同步对共享数据的访问,并使用一个原子布尔值来指示更新器是否至少运行过一次。
// i2c update thread data
static thread i2c_updater;
static SemaphoreHandle_t i2c_update_sync;
static volatile std::atomic_bool i2c_updater_ran;
关于此处使用 volatile
的说明。编译器不一定每次访问变量时都要从内存中读取其值。例如,它可以将值缓存在寄存器中并返回该值。使用“volatile”关键字可确保始终读取该值,而不是缓存。当一个值可能从多个线程访问时,这是必要的,因为编译器不知道多线程的存在,所以它可能会尝试缓存一个值,而没有意识到它可能在别处被更新。如果我们不这样做,最终该值可能会过时。
接下来,我们有 I2C 地址数据。我们将地址列表存储为 128 位打包到 4 个 32 位无符号整数中,而不是数字数组,以节省空间并使比较容易且快速。每一位对应其位置的地址,所以位 0 是地址 0,位 101 是地址 101。我们保持一个当前的位集和一个“旧”位集,以便能够看到变化何时发生,例如当探测器的用户拔掉或插入设备时。
// i2c address data
static uint32_t i2c_addresses[4];
static uint32_t i2c_addresses_old[4];
接下来,我们有串行数据。这包括可选择的波特率列表、当前选择的波特率索引、模式(二进制或文本)以及一个时间戳,用于设置波特率或模式的弹出消息框的超时。它还有一个指示器,用于确定我们是否已显示串行数据,以及三个变量,构成串行数据的缓冲区。
// 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;
是的,我们为传入的串行数据保留了一个单独的缓冲区。这很有必要,而不是使用 display_text
,因为我们会在其中插入换行符,这会在滚动数据时造成干扰。这也将使从文本切换到二进制变得复杂,所以我们将传入的数据存储在 serial_data
中并进行滚动。
现在这里是我们存放探测器上要显示文本的地方
// probe display data
static char* display_text = nullptr;
static size_t display_text_capacity = 0;
现在我们有了 LCD 面板操作和调光器的数据
// 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;
这里,我们为 LCD 传输缓冲区(每个 64KB)声明了大小。我们为每个传输缓冲区都有一个指针,一个指示 LCD 面板是否处于睡眠状态的指示器,最后是调光器实例。
现在我们有了按钮声明
// 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
我们有原始按钮,它们仅连接硬件并提供基本的 callback,然后是实际的更高级的按钮类,它们封装了它们并提供了多击和长按功能。请注意,按钮 A 是右侧按钮,按钮 B 是左侧按钮。
安装
现在进入正题——setup() 例程。当然,这是我们初始化所有内容的地方
void setup() {
MONITOR.begin(115200);
// load our previous settings
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");
}
// 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)
;
}
lcd_dimmer.initialize();
// 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(),
i2c_update_task,
nullptr,
10,
2000);
if (i2c_updater.handle() == nullptr) {
MONITOR.println("Could not allocate I2C updater thread");
while (1)
;
}
i2c_updater.start();
// hook up the buttons
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);
// 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);
main_screen.on_flush_callback(uix_on_flush);
// initialize the UI components
ui_init();
// 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);
MONITOR.println();
}
我已尝试在上方对相关部分进行注释,因为通常更容易在代码旁边阅读说明。以下是我们要做的:
- 从 SPIFFS 加载设置。我们持久化你的串行模式和选定的波特率,所以如果之前存储过,我们在此加载它。
- 启动串行探测线。
- 分配主 LCD 传输缓冲区。
- 初始化 LCD 调光器。
- 将 I2C 地址列表设置为清除状态。
- 启动 I2C 更新器线程。
- 连接并初始化按钮。
- 初始化 LCD 显示屏。
- 尝试分配第二个 LCD 传输缓冲区(以获得最佳性能)。
- 使用有效的传输缓冲区指针重新初始化主屏幕。
- 初始化用户界面组件。
- 计算并分配我们的显示文本缓冲区。
- 计算并分配我们的串行数据缓冲区。
- 报告内存统计信息。
循环
这里是应用程序的主逻辑。同样,我尝试在代码旁边提供注释。
void loop() {
// timeout the serial settings display
// if it's showing
if (serial_msg_ts && millis() > serial_msg_ts + 1000) {
probe_msg_label1.visible(false);
probe_msg_label2.visible(false);
serial_msg_ts = 0;
}
// pause the app while the buttons are pressed
while (button_a.pressed() || button_b.pressed()) {
button_a.update();
button_b.update();
}
// give everything a chance to update
lcd_dimmer.update();
button_a.update();
button_b.update();
// if the i2c has changed, update the display
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();
// otherwise if the serial has changed,
// update the display
} 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 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()) {
lcd_sleep();
} else {
lcd_wake();
main_screen.update();
}
}
鉴于它驱动着整个应用程序,它却出奇地简单,但我们会对其进行回顾。代码的第一部分是一个简单的计时器,它在 probe_msg_label1
和 probe_msg_label2
超时时将其关闭。这些标签用于显示通过按右侧按钮可以更改的设置。一旦它们显示出来,该计时器就会启动。一旦它失效,消息就会消失。
接下来,我们有一个仅在按下某个按钮时才会执行的循环。我们使用此循环来暂停一切,以便在按下按钮时显示屏停止更新。在这种情况下,这是一种非常粗糙但绝对足够的方式。至少在某些情况下,“缺点”是这使得整个应用程序无响应,因此没有机会进行后台处理(除了运行在另一个核心上的 I2C 更新器),但在这种情况下,这对我们来说很好。请注意,我们在循环中不断调用按钮的 update()
,以便 pressed()
能够及时更改,所以我想技术上讲,应用程序并没有 *完全* 冻结。
现在我们给调光器和按钮一个更新它们自己的机会。
在此之后,我们有几个主要调用被包裹在一个 if
/else if
组合中:refresh_i2c()
和 refresh_serial()
。其中每一个都会检查它们各自的输入是否有更改的数据,如果存在新数据,它会使用该数据更新 display_text
并返回 true。否则,它返回 false。在每个情况下,我们都会更新 is_serial
,因为对于串行通信,当你首次切换到它时,它会清空显示。probe_label
无论如何都会被更新,然后确保显示屏和调光器都处于唤醒状态。
在此之后,如果调光器已完全关闭,我们将 LCD 置于睡眠状态,否则我们将 LCD 唤醒并更新屏幕。
htcw_uix/ESP LCD 面板 API 互联
我们需要我们的用户界面渲染库 (htcw_uix) 与实际的显示硬件进行通信,我们使用 ESP LCD 面板 API 来控制它。我们通过实现几个 callback 来做到这一点,一个用于 htcw_uix,一个用于 ESP LCD 面板 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;
esp_lcd_panel_draw_bitmap(lcd_handle,
x1,
y1,
x2,
y2,
bmp.begin());
}
// 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) {
main_screen.set_flush_complete();
return true;
}
第一个函数由 htcw_uix 调用,提供一个位图和要绘制的位置。我们将该数据转换为 ESP LCD 面板 API 接受的坐标和颜色信息。这很简单。唯一的小麻烦是 ESP LCD 面板 API 期望 x2
和 y2
在任一方向上比目标多一个像素。这里会处理。
第二个函数由 ESP LCD 面板 API 调用,它告诉我们之前调用 esp_lcd_panel_draw_bitmap()
何时完成。我们只需将该信息转发给我们的 htcw_uix main_screen
,它会处理我们的渲染。
LCD 电源管理
每当我们完全清空屏幕时,我们就将 LCD 置于睡眠状态,并在再次需要时将其唤醒。为了做到这一点,我们向 ST7789 显示控制器发送一些命令来告诉它该做什么。这两个函数就起到了这个作用。
// puts the ST7789 to sleep
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;
}
}
// wakes the ST7789
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;
}
}
持久化
我们使用 save_settings()
例程来处理配置数据的持久化。
// saves the current configuration to flash
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();
}
按钮处理
当按下任一按钮时,会发生两件事:屏幕会暂停,无论它之前在做什么——这一点我们已经讲过了,并且屏幕会唤醒。对于左侧按钮,它只会做这些,所以我们不会过多地介绍这个例程。有趣的事情发生在右侧按钮被操作时。
首先,当它被快速单击时,我们希望它更改串行模式。这里发生的就是这种情况。当我们唤醒显示屏时,我们会减去一次点击。这样,如果你只是想唤醒显示屏,它只会做这件事。然后我们做一些位运算来将所有报告的点击次数减少到一个简单的奇偶校验(&1
),然后将其加到一个布尔值中,再次限制为奇偶校验,或者说是 true 或 false,在这种情况下。这会将我们的串行模式从二进制切换到文本,反之亦然。完成这些之后,我们使用相应的文本显示我们的 probe_msg_label
s,指示当前设置。我们启动消息显示消失的计时器。我们更新屏幕,这样它就不必等到下一次循环迭代才显示消息,从而使消息立即显示,然后保存设置。
// 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()) {
lcd_wake();
lcd_dimmer.wake();
--clicks;
}
// 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");
probe_msg_label1.visible(true);
probe_msg_label2.visible(true);
// start the message timeout
serial_msg_ts = millis();
// ensure the screen is up to date
main_screen.update();
// save the config
save_settings();
}
如果是一个长按,我们会更改波特率。这将在下面处理。其中很多基本上是相同的,只是不需要处理多次点击的奇偶校验场景。
// 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()) {
lcd_wake();
lcd_dimmer.wake();
return;
}
// 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);
probe_msg_label2.text(buf);
probe_msg_label1.visible(true);
probe_msg_label2.visible(true);
// start the message timeout
serial_msg_ts = millis();
// update the baud rate
SER.updateBaudRate(baud);
// update the main screen
main_screen.update();
// save the config
save_settings();
}
这里稍微不同的是,首先我们是在一个波特率数组中索引,最后,每当我们在这里更改波特率时,我们都会设置它。除此之外,这个例程在功能上与普通的单击例程非常相似。
左侧按钮很简单,但为了完整起见在此提供。记住,这个按钮的实际暂停功能是在 loop()
中处理的,而不是在点击处理程序中。
// left button on click
static void button_b_on_click(int clicks, void* state) {
// just wake the display
lcd_wake();
lcd_dimmer.wake();
}
I2C 更新器线程
I2C 总线是很敏感的,尤其是当总线上有故障设备或过多设备时。为了避免阻塞主应用程序线程,我们让原本休眠的第二个核心来处理 I2C 总线的周期性扫描。
// scan the i2c bus periodically
// (runs on alternative core)
void i2c_update_task(void* state) {
while (true) {
vTaskDelay(1);
I2C.begin(I2C_SDA, I2C_SCL);
// ensure pullups
i2c_set_pin(0, I2C_SDA, I2C_SCL, true, true, I2C_MODE_MASTER);
// catch slow devices
I2C.setTimeOut(uint16_t(-1));
// 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
I2C.beginTransmission(i);
if (I2C.endTransmission() == 0) {
// if so, set the corresponding bit
banks[i / 32] |= (1 << (i % 32));
}
}
I2C.end();
// safely update the main address list
xSemaphoreTake(i2c_update_sync, portMAX_DELAY);
memcpy(i2c_addresses, banks, sizeof(banks));
xSemaphoreGive(i2c_update_sync);
// say we ran
i2c_updater_ran = true;
delay(1000);
}
}
每秒一次,它会扫描总线,并将任何响应地址打包到我们在全局变量中声明的地址位组中。扫描完成后,我们会锁定对共享内存的访问(非常重要!),并将新数据复制进去。最后,我们更新 I2C_updater_ran
的值。我们在这里不做其他任何事情。我们在别处使用这些数据。这只是保持它最新。
I2C 刷新
这个例程将更新器线程收集的数据放入 display_text
,但仅当数据自上次检查以来发生变化时才进行。我们通过对 i2c_addresses
和 i2c_addresses_old
进行简单的 memcmp()
来实现这一点,这就是我们保留后者原因。
// 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));
xSemaphoreGive(i2c_update_sync);
// 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");
}
++count;
// 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);
MONITOR.println("<none>");
}
MONITOR.println();
// 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;
}
如果我们在两者之间看到了变化,我们基本上会遍历所有可能的位,如果某个位被设置,我们会向 display_text
添加一行文本来表示。你可能想知道,如果屏幕上要显示的地址太多会怎样。答案是监视器仍然会输出它们,而且在这种情况下,电容可能已经损坏了你的总线。I2C 上超过 5 个设备不是个好主意,当然,在某些情况下你也可以做得更多。
串行刷新
这可能是最复杂的代码部分,因为涉及到滚动传入数据和多种模式。
// 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 += SER.read(p, 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) {
break;
}
uint8_t b = *pb++;
--pbc;
// if it's printable, print it
// otherwise, print '.'
if (b == ' ' || isprint(b)) {
*sz++ = (char)b;
MONITOR.print((char)b);
} else {
// monitor follows slightly different rules
*sz = '.';
if (b == '\n' || b == '\r' || b == '\t') {
MONITOR.print((char)b);
} else {
MONITOR.print('.');
}
}
// insert newlines as necessary
if (rows < probe_rows - 1 && ++cols == probe_cols) {
cols = 0;
*sz++ = '\n';
++rows;
}
++advanced;
} 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) {
break;
}
uint8_t b = *pb++;
--pbc;
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';
++rows;
}
// dump to the monitor
MONITOR.printf("%02X ", b);
if (++mon_cols == 10) {
MONITOR.println();
mon_cols = 0;
}
++advanced;
} while (--count_bin);
*sz = '\0';
MONITOR.println();
}
// report a change
return true;
}
// no change
return false;
}
天哪,我从哪里开始呢?这个例程大致分两个阶段。第一阶段是收集传入的串行数据,可能需要丢弃旧数据腾出空间。我在这里使用 memmove()
而不是更高效的队列,因为我不想调试一个队列,而且数据量不是很大。其中的很多噪声只是簿记工作。我试图让变量名具有一定的描述性,但它仍然像个动物园。总之,之后,目标是根据数据重建 display_text
。这取决于模式是文本还是二进制。在文本模式下,我们只输出可打印字符,否则就用点。在二进制模式下,我们输出一系列十六进制值。无论哪种情况,我们都返回 true,表示数据已更改。
注意:如果串行数据传入速度太快,微小的 MCU UART 缓冲区就会溢出,这很容易发生,因为更新显示屏需要时间,而且我们不能一次显示太多字符。在这种情况下,你的串行输出会出现跳跃。这并不理想,但这是一个微小、简单的设备。不是 PuTTY。
/include/lcd_config.h
此文件包含我们在 ESP LCD 面板 API 初始化例程中使用的一些定义。它已针对 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 PIN_NUM_BCKL 4
#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
本质上,这些只是引脚常量和显示控制器的一些勘误。
/include/lcd_init.h
此文件包含一个通用的初始化例程,可以处理大多数类型的 LCD 面板。我编写它是为了在许多项目中使用,并且在这里也使用它。它使用了我们上面刚介绍的配置信息。
// Generic ESP LCD Panel API initialization code
#ifndef LCD_INIT_H
#define LCD_INIT_H
// define LCD_IMPLEMENTATION in exactly one source file
#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"
// 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);
#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; // swapped
int lcd_height = LCD_HRES;
#else
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) {
#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;
// 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
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;
// Initialize the LCD configuration
LCD_PANEL(lcd_io_handle, &panel_config, &lcd_handle);
#ifdef PIN_NUM_BCKL
// Turn off backlight to avoid unpredictable display on
// the LCD screen while initializing
// the LCD panel driver. (Different LCD screens may need different levels)
gpio_set_level((gpio_num_t)PIN_NUM_BCKL,LCD_BCKL_OFF_LEVEL);
#endif // PIN_NUM_BCKL
// Reset the display
esp_lcd_panel_reset(lcd_handle);
// Initialize LCD panel
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);
// Turn on the screen
esp_lcd_panel_disp_off(lcd_handle, false);
#ifdef PIN_NUM_BCKL
// Turn on backlight (Different LCD screens may need different levels)
gpio_set_level((gpio_num_t)PIN_NUM_BCKL,LCD_BCKL_ON_LEVEL);
#endif // PIN_NUM_BCKL
}
#endif // LCD_IMPLEMENTATION
#endif // LCD_INIT_H
为了完整起见,我在这里提供它,但 ESP LCD 面板 API 的细节超出了本文的范围。
/include/ui.hpp
此文件包含用于 htcw_uix 用户界面组件的声明。
#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();
我们只是声明我们的屏幕和控件,以便它们可以在 main.cpp 中被引用。
/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;
// 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;
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);
// 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 {
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);
}
// create the probe text label
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);
// compute the probe columns and rows
probe_rows = (main_screen.dimensions().height-
probe_label.padding().height*2)/
probe_label.text_line_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(),
spoint16::zero(),
"M",
probe_font.scale(
probe_label.text_line_height()));
probe_cols = (main_screen.dimensions().width-
probe_label.padding().width*2)/
tsz.width;
// now compute where our probe
// configuration message labels
// go
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();
}
我们在这里做的是布局和设置我们的控件。我们从一个 label
和一个 svg_box
开始作为标题屏幕。然后我们创建一个探测标签,并对背景进行 alpha 混合,以便标题屏幕能够透过。然后我们创建两个消息标签,用于在更改设置时显示信息。在此过程中,我们计算可用于布局文本的可用列和行数。
/platformio.ini
没有这个配置文件,我们会怎样?这是我们项目的“魔法酱”。
[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
你会注意到我们已将编译器标准更新为 GNU C++17。htcw_gfx 需要 C++14 或更高版本才能编译,而 htcw_uix 目前需要 C++17 或更高版本,但我将来可能会放宽这个要求。
其他重要的文件是我们的字体和 SVG 头文件,所有这些都使用了我的在线字体/图像转换器工具生成。
历史
- 2023 年 3 月 16 日 - 首次提交