65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconemptyStarIconemptyStarIconemptyStarIconemptyStarIcon

1.00/5 (1投票)

2023 年 3 月 13 日

MIT

11分钟阅读

viewsIcon

6519

downloadIcon

72

用这个简单的设备轻松确定 I2C 总线上的活动设备。

i2cu

引言

最近,我一直在为一个工作项目忙碌,其中涉及一些严肃的硬件破解。我们有电路板要测试,电路要验证,有些还要事后重新布线。真是一团糟。

我真正需要的是一个快速而简单的探测工具,用于现场检查我们板上的哪些设备正在响应。我当时手头有一个 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>>;

为了可维护性和节省我的手指,我大量使用 typedefusing。我也倾向于为我的许多代码使用模板类,你可以在这里看到。我们正在设置调光器、用于两种不同二进制像素格式的两种颜色枚举、两个按钮(每个都在中断上)以及一个“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.hppui.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 个像素。如果 x2y2 的计算看起来“错误”,那就是原因。它们是正确的。

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_colsprobe_rows 的计算。我们实际上还没有在这个应用程序中使用它们,但它们是屏幕上可用文本字符的列数和行数的预计算值。字体宽度变得很奇怪,因为字符宽度不同,但我们使用或多或少标准的测量“M”以获得基本宽度的方法。应该注意的是,探测屏幕应该使用等宽字体。这将消除此计算中的问题。

历史

  • 2023 年 3 月 13 日 - 初次提交
© . All rights reserved.