显示屏与桌面作品 / 触摸交互发布 内容更新

ESP32-S3 触摸屏与 LVGL:做一个真正能点击的设置页

固定BOX-3的屏幕、触摸和LVGL9组合,做带保存回执的开关与背光滑条;用同源模型检查保存快照,并保留坐标与断电恢复的实板清单。

读完能做什么
得到一份可构建的触摸设置页,保存成功与继续编辑都有明确状态;再按坐标和重启清单做实体验收。
开始之前
先完成所列前置,再按本例固定板型与README执行;本文不把主机检查当成实体运行。
所需器材
指定BOX-3主机和USB-C数据线、电脑与IDF5.5.5、需按实物核对TT21100/ST7789,其他物料不直接套用
版本组合
框架:ESP-IDF5.5.5 / esp32s3显示栈:BSP3.2.1 / port2.9.0 / LVGL9.4.0 / TT21100 1.2.1
适用范围

ESP32-S3-BOX-3主机,TT21100/ST7789分支;16MB Flash/16MB Octal PSRAM;USB Serial/JTAG115200;IDF5.5.5、BSP3.2.1、port2.9.0、LVGL9.4.0、TT21100驱动1.2.1;无外接GPIO。

这篇验证到了哪一步

资料核对已通过
docs/esp32-evidence/idf-systems.md#d06;固定上游与实际源码比对摘要。验证日期:
构建 / 配置已通过
docs/esp32-evidence/logs/d06-build.log;真实Exit0;变体/源/锁/日志/固件SHA见idf-systems-builds.json。对象:ESP32-S3-BOX-3 TT21100/ST7789分支版本:ESP-IDF5.5.5验证日期:
仿真不适用
未运行ESP32/触摸/文件系统器件仿真;主机只执行固件共享的纯C模型与文件故障夹具,不证明调度、电气或断电可靠性。
本站实板待验证
尚无这套固件烧录、串口、外设或断电测试;observations.csv实测字段为空。

构建通过只说明指定环境中的代码或配置通过检查;实物功能、功耗和稳定性以对应的实板记录为准。

01 / 触摸交互

先确认这块屏幕板,再开始画控件

这次做一个有开关、滑条、Save按钮和保存状态的设置页。开关只控制界面上的小圆点,滑条调背光PWM百分比,不控制继电器或外接负载。

器材固定为官方ESP32-S3-BOX-3主机,16MB Quad Flash、16MB Octal PSRAM、2.4英寸320×240 SPI屏,使用主机Type-C接口供电、下载与USB Serial/JTAG日志;不需要DOCK外接排针。不要把BOX、BOX-Lite或任意S3触摸板代入。

本例进一步限定BSP源码的TT21100(0x24)/ST7789组合。启动要求0x24应答,且GT911的0x5D/0x14不应答,防止走到另一分支。地址ACK不证明真实料号;先根据实物物料与板号核对,无法确认时停在探测阶段。本站尚未拿该组合实板验证。

做到这里,你应该看到
能给出自己的板号、屏幕和触摸控制器;仅“ESP32-S3触摸屏”不足以复现。

参考:[1] ESP-BOX固定提交:BOX-3硬件概览,16MB Flash/16MB Octal PSRAM与320×240屏[2] esp-box-3 BSP3.2.1:LCD探测、触摸探测、mirror与显示锁[3] BSP3.2.1 GPIO和LVGL接口[8] BOX-3 MB原理图文件:MCU p3、屏幕与触摸接口p6

02 / 触摸交互

固定一套LVGL9 API,实际构建通过

下载包固定IDF5.5.5、espressif/esp-box-3 3.2.1、esp_lvgl_port 2.9.0、lvgl/lvgl 9.4.0、esp_lcd_touch_tt21100 1.2.1;dependencies.lock冻结传递依赖与组件hash。BSP最低IDF>=5.3,所以继续复用5.5.5,不另换SDK。

公开defaults设置Flash16MB、Octal PSRAM80MHz、原生USB Serial/JTAG、16位颜色和Montserrat字体;没有照抄上游演示工程的4MB分区配置来冒充这块板的容量。实际app639440B,处于默认1MiB app分区内,Flash容量与app分区是两个数。

进入解压项目根目录,在已安装的5.5.5环境执行下列命令。若端口不出现,先核对数据线和主机Type-C;按官方板级BOOT/RESET下载方式恢复连接,不乱短接GPIO。刷写会替换原有演示固件,恢复原厂演示应另用该板官方固件。

代码 / 配置
idf.py --version
# 必须为 ESP-IDF v5.5.5;命令在本项目根目录执行
idf.py set-target esp32s3
idf.py build
idf.py -p REPLACE_WITH_BOX3_USB_PORT flash monitor

参考:[1] ESP-BOX固定提交:BOX-3硬件概览,16MB Flash/16MB Octal PSRAM与320×240屏[2] esp-box-3 BSP3.2.1:LCD探测、触摸探测、mirror与显示锁[3] BSP3.2.1 GPIO和LVGL接口[4] esp_lvgl_port2.9.0:LVGL9任务与锁实现

03 / 触摸交互

先让控件和反馈能辨认

设置页使用英文ASCII,避免依赖未打包的中文字库。主要控件采用至少44个LVGL物理像素的命中高度、至少8像素间隔、16像素主体字;这是本项目设计值,不等于移动端pt/dp规则,也不证明实体屏上的手指操作已经合适。

开关Show marker控制右上小点;Backlight滑条范围10–100%,下限避免操作中主动把背光调到全黑。百分比是BSP的PWM设置,不是实测亮度或功耗。Save显示Unsaved、Saving...、Saved、Save failed,背光API失败另显示Backlight error,状态不只靠颜色。

底部打印每次按下的LVGL x/y,串口也记录坐标。坐标来自固定BSP映射,没有另加swap/mirror覆盖;左上、右上、左下、右下和中心的实际命中仍待手点验收。

参考:[2] esp-box-3 BSP3.2.1:LCD探测、触摸探测、mirror与显示锁[3] BSP3.2.1 GPIO和LVGL接口[4] esp_lvgl_port2.9.0:LVGL9任务与锁实现[5] LVGL9.4.0固定提交:输入事件与坐标

04 / 触摸交互

保存的是快照,继续编辑不会被旧回执盖掉

所有lv_obj和settings_model只属于LVGL任务。普通app_main创建UI时使用bsp_display_lock(1000),成功后解锁;LVGL事件和timer回调本来就在LVGL任务内,不再嵌套抢同一把锁。

Save复制当前设置到长度1请求队列,只允许一笔保存进行中;worker在独立任务里执行NVS set_blob+commit,然后把结果送回长度1回执队列。worker不直接调用LVGL。UI每100ms取回执,这不是Flash保存时间上限。

保存期间仍可改开关或滑条;成功时把保存快照记为saved,而不是把当前新值冒充已写入。若当前值又变过,状态仍Unsaved。失败保留上次成功快照并显示Save failed,不能仅因按钮按下就显示Saved。

做到这里,你应该看到
能区分current、pending快照和saved;保存慢时修改UI不会丢掉新选择。

参考:[4] esp_lvgl_port2.9.0:LVGL9任务与锁实现[5] LVGL9.4.0固定提交:输入事件与坐标[7] ESP-IDF5.5.5 NVS:缺键、blob与commit

05 / 触摸交互

完整入口:先初始化,再由任务各自拥有状态

main.c负责NVS加载、控制器分支检查、队列与worker、BSP显示和LVGL控件。settings_model.h是共享纯C模型,host目录直接编译同一份;完整CMake、defaults、组件清单、锁与源码均在下载包。

NVS命名空间touch_ui的settings是4字节版本化blob:schema、marker、brightness、保留字节。缺键、大小/版本/范围错误使用未保存默认值true/60;不在读取时自动写默认,不自动擦除NVS。遇到NVS初始化错误停止并保留错误码,先备份和查分区,不能把擦Flash当首选修复。

代码 / 配置
/* SPDX-License-Identifier: MIT */
#include <stdio.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "driver/i2c_master.h"
#include "esp_log.h"
#include "nvs.h"
#include "nvs_flash.h"
#include "bsp/esp-bsp.h"
#include "bsp/display.h"
#include "lvgl.h"
#include "settings_model.h"

static const char *TAG = "touch_settings";
static QueueHandle_t requests, results;
/* This model and every lv_obj_t are owned only by the LVGL task. */
static settings_model_t model;
static lv_obj_t *marker, *slider, *save_button, *status_label, *value_label, *point_label;
static bool backlight_error;
static lv_indev_t *touch;

static settings_t load_settings(bool *valid) {
    settings_t s = settings_defaults(); *valid=false;
    nvs_handle_t h;
    esp_err_t e = nvs_open("touch_ui", NVS_READONLY, &h);
    if (e == ESP_ERR_NVS_NOT_FOUND) { ESP_LOGI(TAG,"Missing namespace: defaults are not saved yet"); return s; }
    if (e != ESP_OK) { ESP_LOGE(TAG,"Load open: %s; defaults, no erase",esp_err_to_name(e)); return s; }
    uint8_t blob[4]; size_t size=sizeof(blob);
    e=nvs_get_blob(h,"settings",blob,&size); nvs_close(h);
    if (e==ESP_OK && settings_decode(blob,size,&s)) *valid=true;
    else ESP_LOGW(TAG,"Missing/invalid setting: %s, size=%u; use unsaved defaults",esp_err_to_name(e),(unsigned)size);
    return s;
}
static esp_err_t store_settings(settings_t s) {
    uint8_t blob[4]; settings_encode(s,blob);
    nvs_handle_t h; esp_err_t e=nvs_open("touch_ui",NVS_READWRITE,&h);
    if (e!=ESP_OK) return e;
    e=nvs_set_blob(h,"settings",blob,sizeof(blob));
    if (e==ESP_OK) e=nvs_commit(h);
    nvs_close(h); return e;
}
static void storage_worker(void *unused) {
    (void)unused; settings_t snapshot;
    while (true) {
        if (xQueueReceive(requests,&snapshot,portMAX_DELAY)==pdTRUE) {
            esp_err_t e=store_settings(snapshot);
            ESP_LOGI(TAG,"NVS save snapshot marker=%u brightness=%u result=%s",snapshot.marker,snapshot.brightness,esp_err_to_name(e));
            /* Never call LVGL from this task. One request is in flight. */
            xQueueSend(results,&e,portMAX_DELAY);
        }
    }
}
static void refresh_status(void) {
    lv_label_set_text(status_label,backlight_error ? "Backlight error" : settings_status(&model));
    if (model.busy) lv_obj_add_state(save_button,LV_STATE_DISABLED);
    else lv_obj_remove_state(save_button,LV_STATE_DISABLED);
}
static void controls_changed(lv_event_t *e) {
    (void)e;
    settings_t next={lv_obj_has_state(marker,LV_STATE_CHECKED),(uint8_t)lv_slider_get_value(slider)};
    if (!settings_edit(&model,next)) return;
    lv_label_set_text_fmt(value_label,"Backlight %u%% PWM",next.brightness);
    backlight_error=bsp_display_brightness_set(next.brightness)!=ESP_OK;
    /* The switch marks a small UI dot, not an external load or power relay. */
    lv_obj_t *dot=lv_obj_get_user_data(marker);
    if (next.marker) lv_obj_remove_flag(dot,LV_OBJ_FLAG_HIDDEN);
    else lv_obj_add_flag(dot,LV_OBJ_FLAG_HIDDEN);
    refresh_status();
}
static void save_clicked(lv_event_t *e) {
    (void)e; settings_t snapshot;
    if (!settings_begin_save(&model,&snapshot)) return;
    if (xQueueSend(requests,&snapshot,0)!=pdTRUE) settings_finish_save(&model,false);
    refresh_status();
}
static void save_poll(lv_timer_t *timer) {
    (void)timer; esp_err_t result;
    if (xQueueReceive(results,&result,0)==pdTRUE) { settings_finish_save(&model,result==ESP_OK); refresh_status(); }
}
static void touch_pressed(lv_event_t *e) {
    (void)e; lv_point_t p; lv_indev_get_point(touch,&p);
    lv_label_set_text_fmt(point_label,"Touch x=%ld y=%ld",(long)p.x,(long)p.y);
    ESP_LOGI(TAG,"touch x=%ld y=%ld (LVGL coordinates)",(long)p.x,(long)p.y);
}
static lv_obj_t *label_at(lv_obj_t *parent, int x, int y, const char *text) {
    lv_obj_t *o=lv_label_create(parent); lv_obj_set_pos(o,x,y); lv_label_set_text(o,text); return o;
}
static void create_ui(void) {
    lv_obj_t *screen=lv_screen_active();
    lv_obj_remove_flag(screen,LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_set_style_bg_color(screen,lv_color_hex(0x101C2B),0);
    lv_obj_set_style_text_color(screen,lv_color_white(),0);
    lv_obj_t *title=label_at(screen,12,8,"Settings");
    lv_obj_set_style_text_font(title,&lv_font_montserrat_20,0);
    lv_obj_t *dot=lv_obj_create(screen); lv_obj_set_size(dot,12,12); lv_obj_set_pos(dot,292,13);
    lv_obj_set_style_radius(dot,6,0); lv_obj_set_style_bg_color(dot,lv_color_hex(0x4EE0B0),0);
    lv_obj_set_style_border_width(dot,0,0); lv_obj_remove_flag(dot,LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE);
    if (!model.current.marker) lv_obj_add_flag(dot,LV_OBJ_FLAG_HIDDEN);
    label_at(screen,12,54,"Show marker");
    marker=lv_switch_create(screen); lv_obj_set_pos(marker,232,42); lv_obj_set_size(marker,72,44);
    lv_obj_set_style_bg_color(marker,lv_color_hex(0x1E5E9B),LV_PART_INDICATOR | LV_STATE_CHECKED);
    lv_obj_set_user_data(marker,dot);
    if (model.current.marker) lv_obj_add_state(marker,LV_STATE_CHECKED);
    lv_obj_add_event_cb(marker,controls_changed,LV_EVENT_VALUE_CHANGED,NULL);
    value_label=label_at(screen,12,94,"");
    lv_label_set_text_fmt(value_label,"Backlight %u%% PWM",model.current.brightness);
    slider=lv_slider_create(screen); lv_obj_set_pos(slider,24,130); lv_obj_set_size(slider,272,12);
    lv_slider_set_range(slider,10,100); lv_slider_set_value(slider,model.current.brightness,LV_ANIM_OFF);
    lv_obj_set_ext_click_area(slider,16); /* 44 px high hit area, not a mm guarantee. */
    lv_obj_add_event_cb(slider,controls_changed,LV_EVENT_VALUE_CHANGED,NULL);
    save_button=lv_button_create(screen); lv_obj_set_pos(save_button,12,166); lv_obj_set_size(save_button,112,44);
    lv_obj_set_style_bg_color(save_button,lv_color_hex(0x1E5E9B),0);
    lv_obj_set_style_text_color(save_button,lv_color_white(),0);
    lv_obj_t *text=lv_label_create(save_button); lv_label_set_text(text,"Save"); lv_obj_center(text);
    lv_obj_add_event_cb(save_button,save_clicked,LV_EVENT_CLICKED,NULL);
    status_label=label_at(screen,140,179,"");
    point_label=label_at(screen,12,219,"Touch: not tested");
    lv_obj_set_style_text_font(point_label,&lv_font_montserrat_14,0);
    lv_timer_create(save_poll,100,NULL);
    refresh_status();
}
void app_main(void) {
    esp_err_t e=nvs_flash_init();
    if (e!=ESP_OK) { ESP_LOGE(TAG,"NVS init %s; stop without automatic erase",esp_err_to_name(e)); return; }
    bool valid; settings_t initial=load_settings(&valid);
    settings_init(&model,initial,valid);
    ESP_ERROR_CHECK(bsp_i2c_init());
    i2c_master_bus_handle_t bus=bsp_i2c_get_handle();
    /* Bind this lesson to the BSP's TT21100/ST7789 branch. Address ACK alone
       does not prove the printed controller model; inspect the board first. */
    if (i2c_master_probe(bus,0x24,100)!=ESP_OK ||
        i2c_master_probe(bus,0x5D,100)==ESP_OK || i2c_master_probe(bus,0x14,100)==ESP_OK) {
        ESP_LOGE(TAG,"Unsupported/probe-failed touch combination; require TT21100 at 0x24 and no GT911 branch"); return;
    }
    requests=xQueueCreate(1,sizeof(settings_t)); results=xQueueCreate(1,sizeof(esp_err_t));
    if (!requests || !results || xTaskCreate(storage_worker,"ui_storage",4096,NULL,3,NULL)!=pdPASS) {
        ESP_LOGE(TAG,"Storage queue/task allocation failed"); return;
    }
    lv_display_t *display=bsp_display_start();
    if (!display || !(touch=bsp_display_get_input_dev())) { ESP_LOGE(TAG,"BSP display/touch init failed"); return; }
    backlight_error=bsp_display_brightness_set(initial.brightness)!=ESP_OK;
    if (!bsp_display_lock(1000)) { ESP_LOGE(TAG,"LVGL lock timed out"); return; }
    create_ui();
    lv_indev_add_event_cb(touch,touch_pressed,LV_EVENT_PRESSED,NULL);
    bsp_display_unlock();
    ESP_LOGI(TAG,"Ready; loaded=%u, coordinate/reboot tests remain physical-board checks",valid);
}

参考:[2] esp-box-3 BSP3.2.1:LCD探测、触摸探测、mirror与显示锁[3] BSP3.2.1 GPIO和LVGL接口[4] esp_lvgl_port2.9.0:LVGL9任务与锁实现[7] ESP-IDF5.5.5 NVS:缺键、blob与commit

06 / 触摸交互

坐标与重新上电,必须做成两组实测

先记录板号、屏幕/触摸型号、固件SHA和USB端口。分别轻点四角内侧与中心,保留串口x/y和实际屏上位置;确认横向x递增、纵向y递增、点击开关/Save与滑条端点一致。边缘坐标不必刚好0或319,不为凑数随意增减偏移。

改变开关和亮度,先不按Save,断电再上电应恢复上次保存项或默认;随后明确Save且收到Saved,再分别RESET和完全断电重启,应恢复保存快照。也要测试Saving时继续编辑:旧快照成功不应把新值标为Saved。

坐标偏移先核对控制器分支、物料、BSP和屏幕方向;不要把别的LVGL8示例的mirror参数叠加。保存失败先保留NVS错误名与分区配置,勿自动erase。当前上述实体操作、PWM效果和断电耐受全部pending。

实板输入必须记录当前状态
四角和中心手点目标/实际位置、x/y、板号、固件SHApending
不保存后断电恢复旧值/默认,不能默认为新值pending
Save成功后RESET及断电快照恢复、日志与真实背光pending
Saving时继续编辑成功旧回执后仍Unsaved同源模型通过;UI实体pending

参考:[2] esp-box-3 BSP3.2.1:LCD探测、触摸探测、mirror与显示锁[5] LVGL9.4.0固定提交:输入事件与坐标[7] ESP-IDF5.5.5 NVS:缺键、blob与commit

07 / 触摸交互

主机检查覆盖逻辑,不能替代触摸与Flash

共享settings_model.h检查4字节格式、版本、布尔值、亮度两端之外、重复保存、保存时编辑、失败回执、成功快照和无进行中回执。它没有模拟LVGL触摸、NVS页、Flash断电或背光电气。

作者真实构建成功;首轮GCC因共享头中一行if后接赋值触发误导缩进-Werror,已换行修正并重建,旧失败日志保留。没有修改SDK或降低警告级别来隐藏问题。

代码 / 配置
cc -std=c11 -Wall -Wextra -Werror -I main host/host_check.c -o /tmp/d06-host
/tmp/d06-host

参考:[7] ESP-IDF5.5.5 NVS:缺键、blob与commit

08 / 触摸交互

先交付能确认的设置,再加复杂页面

先把observations.csv中的坐标、Save回执、RESET与断电结果填完,再增加新页面或更高刷新率。要更换BSP、触摸芯片或LVGL主版本,重新走版本、映射和构建检查。

原创代码、图和本文按MIT;ESP-IDF/BSP/port/触摸组件及LVGL保留各自上游许可,THIRD_PARTY.md与真实组件lock在包内。本文核对日期2026-10-18,版本固定不等于未来所有更新自动兼容。

参考:[1] ESP-BOX固定提交:BOX-3硬件概览,16MB Flash/16MB Octal PSRAM与320×240屏[2] esp-box-3 BSP3.2.1:LCD探测、触摸探测、mirror与显示锁[4] esp_lvgl_port2.9.0:LVGL9任务与锁实现[5] LVGL9.4.0固定提交:输入事件与坐标[6] TT21100触摸组件1.2.1注册页

BACK TO THE SOURCE

资料来源与版本说明

固定SDK、组件源码和具体板级已核对;真实构建与同源主机检查已留证,触摸、I2C、联网、存储断电和调度实板结果均未补造。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。

  1. ESP-BOX固定提交:BOX-3硬件概览,16MB Flash/16MB Octal PSRAM与320×240屏
  2. esp-box-3 BSP3.2.1:LCD探测、触摸探测、mirror与显示锁
  3. BSP3.2.1 GPIO和LVGL接口
  4. esp_lvgl_port2.9.0:LVGL9任务与锁实现
  5. LVGL9.4.0固定提交:输入事件与坐标
  6. TT21100触摸组件1.2.1注册页
  7. ESP-IDF5.5.5 NVS:缺键、blob与commit
  8. BOX-3 MB原理图文件:MCU p3、屏幕与触摸接口p6