按日归档: 2023/09/16

基于Adafruit ESP32-S3 TFT Feather的网络应用开发实践

image

视频链接:http://training.eeworld.com.cn/video/37657

源码下载:http://download.eeworld.com.cn/detail/%E5%9B%A7%E5%A4%A7%E5%A4%A7%E7%8E%8B/629208

项目介绍

该项目是基于Adafruit ESP32-S3 TFT Feather开发板的一个简单的示例,通过该示例可以学习到如何使用Adafruit ESP32-S3 TFT Feather开发板的WiFi、GPIO控制及以Neopixel LED控制等功能。

该项目完成了"Follow me活动”第2期的任务1、任务2、任务3以及任务4的分任务1。

功能介绍

  • 基于LVGL的多页面图形界面
  • 内置 OPPOSans 字体,支持大部分常见汉字及符号的显示
  • WiFi热点网页配网
  • 日期时间显示,支持网络时钟同步
  • 通过网络获取天气信息
  • 板载NeoPixel LED控制,呼吸灯闪烁,支持通过按键更换闪烁颜色

在尚未进行配网时启动板卡会自动启动AP热点,通过手机连接该热点会启动CaptivePortal网页引导进行配网,配网成功后会自动关闭AP热点并连接到指定的WiFi SSID。

配网完成后首页会显示已连接的WiFi信息以及IP地址,屏幕上方状态栏会显示日期、时间以及天气信息。

点击屏幕左侧的Boot0按钮可切换显示页面,第二个页面是LED控制页面。进入LED控制页面会自动点亮Neopixel LED呼吸灯,在该页面长按Boot0可控制板载的Neopixel LED的颜色切换,颜色可在红绿蓝三种颜色之间进行切换,离开此页面会自动熄灭Neopixel LED。

内置AP默认连接信息:

  • SSID: https://hessian.cn/followme2
  • 密码: followme2

连接信息可通过menuconfig进行修改

Captive Portal 配网界面效果 image

Follow me活动介绍

"Follow me活动”是DigiKey联合EEWORLD发起的为期一年的“跟技术大咖学技术,完成任务返现”活动。2023年共有4期,每3个月技术大咖推荐可玩性与可学性较强的开发板/仪器套件,带着大家实际操作。

第2期活动任务要求

任务1:控制屏幕显示中文(必做任务)

完成屏幕的控制,并且能显示中文

任务2:网络功能使用(必做任务)

完成网络功能的使用,能够创建热点和连接到WiFi

任务3:控制WS2812B(必做任务)

使用按键控制板载Neopixel LED的显示和颜色切换

任务4:从下方5个分任务中选择1个感兴趣的完成即可(必做任务)

分任务1:日历&时钟——完成一个可通过互联网更新的万年历时钟,并显示当地的天气信息 分任务2:WS2812B效果控制——完成一个Neopixel(12灯珠或以上)控制器,通过按键和屏幕切换展示效果 分任务3:数据检测与记录——按一定时间间隔连续记录温度/亮度信息,保存到SD卡,并可以通过按键调用查看之前的信息,并在屏幕上绘图 分任务4:音乐播放功能——实现音乐播放器功能,可以在屏幕上显示列表信息和音乐信息,使用按键进行切换,使用扬声器进行播放 分任务5:AI功能应用——结合运动传感器,完成手势识别功能,至少要识别三种手势(如水平左右、前后、垂直上下、水平画圈、垂直画圈,或者更复杂手势

任务5:通过网络控制WS2812B(可选任务,非必做)

结合123,在手机上通过网络控制板载Neopixel LED的显示和颜色切换,屏幕同步显示状态

Adafruit ESP32-S3 TFT Feather简介

image Adafruit ESP32-S3 TFT Feather是由开源硬件行业知名公司Adafruit出品的一款富有特色的开源硬件,开发板使用乐鑫ESP32-S3芯片,支持WiFi和蓝牙能,自带高清TFT彩色显示屏。

程序实现

该项目使用C语言基于ESP-IDF进行开发。项目实现参考了ESP-Box-Lite的代码,自行封装了BSP组件,主要封装了板载TFT LCD(1.14" ST7789)的初始化。 目前该组件已上传至Github: BSP: Adafruit ESP32-S3 TFT-Feather

项目依赖

## IDF Component Manager Manifest File
dependencies:
  idf: ">=5.1"

  lvgl/lvgl: "8.3.8"
  espressif/esp_lvgl_port: "1.2.0"
  espressif/qrcode: ^0.1.0
  espressif/json_generator: ^1
  espressif/json_parser: =1.0.0
  espressif/led_strip: "^2.4.3"

目录结构 image

分区表

# Note: if you have increased the bootloader size, make sure to update the offsets to avoid overlap
# Name,   Type, SubType, Offset,  Size, Flags
sec_cert, data, ,        0xd000,  0x3000,
nvs,      data, nvs,     ,        0x6000,
otadata,  data, ota,     ,        0x2000,
fctry,    data, nvs,     ,        0x6000,
ota_0,    app,  ota_0,   ,        3M,
storage,  data, spiffs,  ,        500K,

联网功能处理

esp_err_t app_wifi_start(void)
{
    ESP_LOGD(TAG, "app_wifi_start() ENTER");

    wifi_init_sta();

    // 检查是否已配网成功
    esp_err_t err = wifi_prov_mgr_is_provisioned(&provisioned);
    ESP_ERROR_CHECK(err);

    ESP_LOGD(TAG, "isProvisioned %d", provisioned);

    // 未配网启动AP,否则启动STA
    if (!provisioned) {
        // 启动AP
        wifi_start_softap();

        // 启动CaptivePortal配网服务
        start_captive_portal();

        // 启动DNS服务器,并重定向所有域名查询到AP的IP地址
        dns_server_config_t config = DNS_SERVER_CONFIG_SINGLE("*" /* all A queries */, "WIFI_AP_DEF" /* softAP netif ID */);
        dns_server = start_dns_server(&config);

    } else {
        // 获取网络配置(已配网会自动从NVS加载)
        wifi_config_t config;
        esp_wifi_get_config(WIFI_IF_STA, &config);

        ESP_LOGD(TAG, "WIFI_IF_STA SSID %s / Password: %s", config.sta.ssid, config.sta.password);

        // 启动STA
        wifi_start_sta();
    };

    // 阻塞等待网络连接就绪
    xEventGroupWaitBits(wifi_event_group, WIFI_CONNECTED_EVENT, false, true, portMAX_DELAY);

    ESP_LOGD(TAG, "app_wifi_start() WAIT_WIFI_CONNECT ---> OK");

    // 启动网络时间同步客户端开始同步时间
    ESP_LOGD(TAG, "app_wifi_start() APP SNTP INIT");
    app_sntp_init();

    return ESP_OK;
}

Captive Portal

Captive Portal 中文通常译作“强制主页”或“强制登录门户”。是一个登录Web页面,通常由网络运营商或网关在用户能够正常访问互联网之前拦截用户的请求并将一个强制登录或认证主页呈现(通常是通过浏览器)给用户。该页面可能要求用户输入认证信息、支付、接受某些条款或者其他用户授权等,随后用户才能被授权访问互联网。该技术广泛用于移动和个人宽带服务,包括有线电视、商业WiFi、家庭热点等,也可用于访问企业和住宅区有线网络。

在这个项目中,CaptivePortal是以ESP官方的CaptivePortal demo为基础进行实现的,为了简化开发过程,CaptivePortal中的web静态文件存储到了SPIFF中。

因为Adafruit ESP32-S3 TFT Feathe只搭载了4MB的flash,空间比较有限,我只给SPIFFS分了500K的空间,因此为了平衡体验与空间利用率,WEB 端没有采用像Bootstrap这样的前端框架,而是采用milligram.css + zepto.js 的实现,二者均是针对轻量化进行设计。

加载SPIFFS文件逻辑

// HTTP Error (404) Handler - Redirects all requests to the root page
esp_err_t http_404_error_handler(httpd_req_t *req, httpd_err_code_t err)
{
    char filename[HTTPD_MAX_URI_LEN+10];
    sprintf(filename, CONFIG_BSP_SPIFFS_MOUNT_POINT "%s", req->uri);

    return send_file_response(req, filename);
}

httpd_handle_t start_captive_portal(void)
{
    httpd_config_t config = HTTPD_DEFAULT_CONFIG();
    config.max_open_sockets = CONFIG_LWIP_MAX_SOCKETS - 3;
    config.lru_purge_enable = true;
    config.max_resp_headers = 20;

    // Start the httpd server
    ESP_LOGI(TAG, "Starting server on port: '%d'", config.server_port);
    if (httpd_start(&server, &config) == ESP_OK) {
        // Set URI handlers
        ESP_LOGI(TAG, "Registering URI handlers");
        httpd_register_uri_handler(server, &root_action);
        httpd_register_uri_handler(server, &generate204_action);
        httpd_register_uri_handler(server, &generate_204_action);
        httpd_register_uri_handler(server, &config_action);
        httpd_register_uri_handler(server, &save_action);
        httpd_register_uri_handler(server, &wifi_scan_action);
        httpd_register_err_handler(server, HTTPD_404_NOT_FOUND, http_404_error_handler);
    }
    return server;
}
static esp_err_t send_file_response(httpd_req_t *req, char* filename)
{
    esp_err_t ret = ESP_OK;
    FILE *fp = NULL;
    char *buf = NULL;
    size_t read_len = 0;

    fp = fopen(filename, "rb");

    ESP_GOTO_ON_FALSE(fp != NULL, ESP_ERR_NOT_FOUND, err, TAG, "Failed to open file %s", filename);

    buf = calloc(HTML_BUF_SIZE, sizeof(char));

    if (NULL == buf) {
        ESP_LOGE(TAG, "Failed to allocate memory for buf");
        return ESP_ERR_NO_MEM;
    }

    if (file_ext_cmp(filename, "js")) {
        httpd_resp_set_type(req, "text/javascript");
    } else if (file_ext_cmp(filename, "css")) {
        httpd_resp_set_type(req, "text/css");
    } else {
        httpd_resp_set_type(req, "text/html");
    }

    while ((read_len = fread(buf, sizeof(char), HTML_BUF_SIZE, fp)) > 0) {
        httpd_resp_send_chunk(req, buf, read_len);
    }

    ESP_GOTO_ON_FALSE(feof(fp), ESP_FAIL, err, TAG, "Failed to read file %s error: %d", filename, ferror(fp));

    // chunks send finish
    httpd_resp_send_chunk(req, NULL, 0);

    err:
    if (fp != NULL) {
        fclose(fp);
    }
    if (buf != NULL) {
        free(buf);
    }

    if (ret == ESP_ERR_NOT_FOUND) {
        ret = httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "File does not exist");
    }

    return ret;
}

中文字体处理

LVGL内置的CJK宋体对一些符号和汉字的支持也不够,经常有吞字的情况。于是需要自行增加一个字体,通过查询字符表之后用以下命令转成c代码就可以嵌入到我们的工程里。

lv_font_conv --font ./OPPOSans_L.ttf -r 0x20-0x7F -r 0x2100-0x214F -r 0x3000-0x303F -r 0x4E00-0x9FFF -r 0xFE50-0xFE6F  --size 16 --format lvgl --bpp 4 --no-compress -o ~/workspace/esp/esp-followme2/main/gui/font/font_OPPOSans_L_16.c

这里我用的是OPPOSans字体16像素大小,指定了常用的ASCII字符、中英文符号和中文字符等范围,足以满足日常文本内容显示的需求。

  • 字符范围查询:https://jrgraphix.net/r/Unicode/
  • LVGL在线图标转换:https://lvgl.io/tools/imageconverter
  • LVGL字体转换程序:https://github.com/lvgl/lv_font_conv

使用方法

LV_FONT_DECLARE(font_OPPOSans_L_16);

lv_obj_t * lab_net_state = lv_label_create(parent);
lv_label_set_recolor(lab_net_state, true);
lv_obj_set_style_text_font(lab_net_state, font_OPPOSans_L_16, LV_PART_MAIN);

NeoPixel LED

开发板上搭载了一颗WS2812可寻址LED。在ESP-IDF环境中,我们可以使用官方组件库中的led_strip进行操作,免去自行编写驱动的过程。

led_strip组件: https://components.espressif.com/components/espressif/led_strip

这里要注意,这个LED的电源是直接由GPIO34提供的,使用前需要先拉高GPIO34进行供电。


#define NEOPIX_GPIO GPIO_NUM_33
#define NEOPIX_PWR_GPIO GPIO_NUM_34


static inline void led_breath(uint32_t *color)
{
    if (led_dir) {
        (*color) ++;
        if (*color == 0xff) {
            led_dir = false;
        }
    } else {
        (*color) --;
        if (*color == 0) {
            led_dir = true;
        }
    }
}

static void update_led_task(void *arg)
{
    switch (led_color) {
        case 0:
            led_breath(&r);
            break;
        case 1:
            led_breath(&g);
            break;
        case 2:
            led_breath(&b);
            break;
    }
    led_strip_set_pixel(led_strip, 0, r, g, b);
    led_strip_refresh(led_strip);
}

static void page_led_init(void)
{
    esp_err_t err;
    if (!gpio_initialized) {
        gpio_config_t io_conf = {
                .mode = GPIO_MODE_OUTPUT,
                .pin_bit_mask = (1ULL<<NEOPIX_PWR_GPIO),
                .intr_type = GPIO_INTR_DISABLE,
                .pull_down_en = 0,
                .pull_up_en = 0,
        };
        err = gpio_config(&io_conf);
        if (err != ESP_OK) {
            ESP_LOGE(TAG, "configure GPIO for NEOPIX_PWR failed");
        }
        gpio_initialized = true;
    }

    err = gpio_set_level(NEOPIX_PWR_GPIO, 1);

    if (err != ESP_OK) {
        ESP_LOGE(TAG, "gpio_set_level GPIO for NEOPIX_PWR failed");
    }

    ESP_LOGI(TAG, "NEOPIX_PWR ON");


/* LED strip initialization with the GPIO and pixels number*/
    led_strip_config_t strip_config = {
            .strip_gpio_num = NEOPIX_GPIO, // The GPIO that connected to the LED strip's data line
            .max_leds = 1, // The number of LEDs in the strip,
            .led_pixel_format = LED_PIXEL_FORMAT_GRB, // Pixel format of your LED strip
            .led_model = LED_MODEL_WS2812, // LED strip model
            .flags.invert_out = false, // whether to invert the output signal (useful when your hardware has a level inverter)
    };

    led_strip_rmt_config_t rmt_config = {
            .clk_src = RMT_CLK_SRC_DEFAULT, // different clock source can lead to different power consumption
            .resolution_hz = 10 * 1000 * 1000, // 10MHz
            .flags.with_dma = false, // whether to enable the DMA feature
    };

    ESP_ERROR_CHECK(led_strip_new_rmt_device(&strip_config, &rmt_config, &led_strip));


    const esp_timer_create_args_t periodic_timer_args = {
            .callback = &update_led_task,
            /* name is optional, but may help identify the timer when debugging */
            .name = "periodic"
    };

    ESP_ERROR_CHECK(esp_timer_create(&periodic_timer_args, &periodic_timer));
}

// LED 控制页面渲染逻辑
void page_led_render(lv_obj_t *parent)
{
    ESP_LOGD(TAG, "render start");

    if (parent == NULL) {
        return;
    }

    page_led_init();

    lv_obj_t *lab_text = lv_label_create(parent);
    lv_label_set_recolor(lab_text, true);
    lv_obj_set_style_text_font(lab_text, main_font, LV_PART_MAIN);
    lv_obj_set_align(lab_text, LV_ALIGN_CENTER);
    lv_label_set_text_fmt(lab_text, "#F2C161 LED控制#\n#CCCCCC 长按Boot0切换LED颜色#");

    // 开始呼吸灯 1000us = 1ms
    ESP_ERROR_CHECK(esp_timer_start_periodic(periodic_timer, 1000));

    ESP_LOGD(TAG, "render end");
}


参考资料

  • FreeRTOS官方文档:https://www.freertos.org/zh-cn-cmn-s/freertos-core/overview.html
  • ESP-IDF官方文档:https://docs.espressif.com/projects/esp-idf/zh_CN/latest/esp32/getstarted/index.html
  • Unicode字符范围表:https://jrgraphix.net/r/Unicode/

Read: 93