起因是在公众号看到有人用 Waveshare ESP32-S3-Touch-LCD-3.49 做了一个桌面小摆件,觉得挺酷的,于是自己也搞了一个。没想到这一搞就是好几天,期间踩了无数的坑——LVGL 版本迁移、显示旋转、中文字体、WiFi 认证、音频频谱……每一个都够写一篇文章的。这篇博客完整记录了整个过程。

目录


一、硬件选型与项目规划

硬件:Waveshare ESP32-S3-Touch-LCD-3.49

👉🏻 官网链接

参数 规格
主控 ESP32-S3(双核 Xtensa LX7,最高 240MHz)
内存 512KB SRAM + 8MB PSRAM(Octal)
Flash 16MB
屏幕 3.49 英寸 IPS,172×640,电容触摸
屏幕驱动 AXS15231B,QSPI 接口
触摸 IC I2C(地址 0x3B)
背光 GPIO 8,PWM 可调
供电 USB-C

选这块板子的原因很简单——3.49 英寸的长条屏幕很适合做桌面摆件,172×640 的分辨率虽然窄但足够显示信息,加上触摸屏可以做翻页交互。

想做什么?

一个桌面智能仪表盘,可以显示:

  1. 翻页时钟 — 大字体显示当前时间,带日期、星期(中文)、农历
  2. 天气信息 — 当前天气 + 5 日预报,使用 QWeather API
  3. 音频频谱 — 实时显示 Mac 正在播放的音乐频谱
  4. AI 用量 — Cursor 和 DeepSeek 的 API 用量监控
  5. Web 管理面板 — 在浏览器上查看设备状态和配置

整体思路:ESP32 负责显示和触控交互,Mac 上跑一个本地服务端(FastAPI),负责获取天气、AI 用量、音频频谱等数据,通过 HTTP 和 WebSocket 推送给 ESP32。

image


二、项目架构设计

┌─────────────────────┐         ┌──────────────────────┐
│   ESP32-S3 (固件)    │◄──WiFi──►│   Mac-Server (Python) │
│                     │         │                      │
│  ┌───────────────┐  │  HTTP   │  ┌────────────────┐  │
│  │ LVGL UI Pages │  │◄────────│  │ Weather Client │  │
│  │ - Clock       │  │         │  │ (QWeather API) │  │
│  │ - Weather     │  │         │  └────────────────┘  │
│  │ - Forecast    │  │         │  ┌────────────────┐  │
│  │ - Spectrum    │  │  WS     │  │ Audio Capture  │  │
│  │ - Dashboard   │  │◄────────│  │ (catap + FFT)  │  │
│  └───────────────┘  │         │  └────────────────┘  │
│  ┌───────────────┐  │         │  ┌────────────────┐  │
│  │ WiFi Manager  │  │         │  │ Cursor Client  │  │
│  │ (WPA2-ENT)    │  │         │  │ DeepSeek Client│  │
│  └───────────────┘  │         │  └────────────────┘  │
│  ┌───────────────┐  │         │  ┌────────────────┐  │
│  │ Data Client   │  │         │  │ Web Dashboard  │  │
│  │ WS Client     │  │         │  │ (HTML/JS)      │  │
│  └───────────────┘  │         │  └────────────────┘  │
└─────────────────────┘         └──────────────────────┘

关键技术栈:

  • 固件:ESP-IDF v5.5.3 + LVGL v9.5.0 + FreeRTOS
  • 服务端:Python 3.13 + FastAPI + catap(macOS 音频捕获)
  • 通信:HTTP REST + WebSocket(二进制协议)
  • 字体:Noto Sans SC(中文)+ QWeather Icons(天气图标)+ Montserrat(英文/数字)

三、LVGL UI 开发:从模拟器到真机

3.1 模拟器先行

在真机烧录之前,因为机器还没到货,我先在桌面模拟器(lvgl-simulator/)上把 5 个页面的 UI 全部调好:

  • Clock 页 — 翻页时钟风格,时/分/秒分别在黑色卡片上用大号白字显示
  • Weather 页 — 左侧天气图标 + 温度,右侧日期/时间
  • Forecast 页 — 5 日天气预报,横向排列
  • Spectrum 页 — 32 条彩虹色柱状频谱
  • Dashboard 页 — Cursor 和 DeepSeek 用量环形图

模拟器的好处是可以快速迭代 UI 布局,不用每次都烧录到设备上。

3.2 LVGL v8 → v9 迁移

模拟器用的是 LVGL v8,但 ESP-IDF 管理的组件里拉下来的是 LVGL v9.5.0。两个大版本之间有大量 API 变更:

v8 API v9 API
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE) 相同,但参数类型变了
lv_style_set_shadow_width(style, w) 仍支持但性能代价大
lv_disp_drv_t lv_display_t *lv_display_create()
lv_disp_draw_buf_init lv_display_set_buffers()
lv_canvas_set_buffer(...) 参数 格式参数从枚举改为 LV_COLOR_FORMAT_*
lv_timer_handler() 返回值含义变了

这部分的迁移工作虽然繁琐,但有规律可循。最大的坑不是 API 变更本身,而是某些 v8 写法在 v9 下会触发隐藏的性能问题。

3.3 烧录与首次点亮

使用 ESP-IDF 工具链编译烧录:

cd firmware
idf.py set-target esp32s3
idf.py build
idf.py -p /dev/cu.usbmodem1101 flash

首次烧录后 —— 白屏

开始了漫长的调试之旅。

image

四、显示旋转:最费劲的一关

这块 3.49 英寸的屏幕物理分辨率是 172×640(竖屏),但我的 UI 设计是 640×172(横屏)。这意味着需要把画面旋转 90°。

听起来简单?这个问题折腾了我整整一天多。

4.1 方案一:LVGL 软件旋转(可行但慢)

LVGL v9 提供了 lv_display_set_rotation() API:

lv_display_set_rotation(disp, LV_DISPLAY_ROTATION_270);

这个方案能工作,但有代价——LVGL 需要在内部额外分配一个全屏缓冲区(172×640×2 = 220KB)做像素转置,每一帧都要做一次全屏 memcpy + 坐标变换。在 ESP32-S3 上,这直接把帧率拉到了 20fps 左右,页面切换也明显卡顿。

但至少能显示了。先用这个方案跑通其他功能。

4.2 方案二:硬件旋转(尝试失败)

为了提升帧率,我尝试了硬件旋转方案:

esp_lcd_panel_swap_xy(panel, true);
esp_lcd_panel_mirror(panel, true, false);

理论上,LCD 控制器可以通过 MADCTL 寄存器的 MV(行/列交换)位来实现硬件旋转,零 CPU 开销。

但现实很残酷:AXS15231B 在 QSPI 模式下不支持 swap_xy

尝试的结果是各种花屏:

  • 4 块重复的画面拼在一起
  • 左白右花
  • 全白屏
  • 竖屏 + 花屏

花屏的根本原因是 QSPI 模式下,draw_bitmap 发送的行列地址和实际像素数据的对应关系在 swap_xy 后完全错位。驱动层的 panel_axs15231b_draw_bitmap 没有针对 swap_xy 做坐标变换。

4.3 方案三:自定义转置(最终方案)

既然硬件旋转不行,LVGL 内置旋转又慢,最终采用了折中方案:在 flush 回调中手动转置像素

核心思路:

  1. LVGL 以 640×172(横屏)的逻辑分辨率工作,不设置旋转
  2. flush_cb 中,把 LVGL 输出的 640×172 像素数据手动转置为 172×640
  3. 转置后的数据分块通过 DMA 发送到 LCD
static void example_lvgl_flush_cb(lv_display_t *disp, const lv_area_t *area,
                                   uint8_t *px_map)
{
    const int src_w = area->x2 - area->x1 + 1;  // LVGL 横屏宽度方向
    const int src_h = area->y2 - area->y1 + 1;   // LVGL 横屏高度方向
    const uint16_t *src = (const uint16_t *)px_map;

    // 按 DMA 块大小分批处理(每块 64 行 × 172 像素)
    for (int chunk_start = 0; chunk_start < src_w; chunk_start += LINES_PER_DMA_CHUNK) {
        int chunk_lines = /* 计算本块行数 */;
        
        // 转置:src[r * src_w + c] → dst[c * chunk_lines + (chunk_lines - 1 - r_offset)]
        for (int r = 0; r < src_h; r++) {
            for (int c = chunk_start; c < chunk_start + chunk_lines; c++) {
                trans_buf_1[/* 转置后坐标 */] = src[r * src_w + c];
            }
        }
        
        // DMA 发送
        xSemaphoreTake(flush_done_semaphore, portMAX_DELAY);
        esp_lcd_panel_draw_bitmap(panel, /* 坐标 */);
    }
    lv_display_flush_ready(disp);
}

最后还需要用 esp_lcd_panel_mirror(panel, false, true) 修正左右镜像。

这个方案的帧率大约在 30fps,虽然不如硬件旋转的理论 60fps,但在 ESP32-S3 上已经足够流畅了。

4.4 DMA 同步:一个隐蔽的死锁

在调试转置方案时遇到了一个极其隐蔽的 bug:全白屏

原因是 flush_cb 中的信号量(semaphore)使用不当。DMA 传输完成后会触发中断释放信号量,但我在循环末尾多做了一次 xSemaphoreTake,导致下一帧开始时信号量已经被消耗,flush_cb 永远阻塞。

修复很简单——删掉那行多余的 xSemaphoreTake。但定位这个 bug 花了不少时间。


五、连接公司 WiFi(WPA2-Enterprise)

公司的 WiFi 使用 WPA2-Enterprise 认证(EAP-PEAP),不是普通的密码认证,需要用户名和密码。

5.1 WiFi Manager 模块

ESP-IDF 提供了完整的 WPA2-Enterprise 支持:

typedef struct {
    const char *ssid;
    const char *username;
    const char *password;
    const char *identity;
} wifi_enterprise_config_t;

esp_err_t wifi_manager_init(const wifi_enterprise_config_t *config) {
    // 初始化 NVS、网络协议栈
    nvs_flash_init();
    esp_netif_init();
    esp_event_loop_create_default();
    esp_netif_create_default_wifi_sta();
    
    // 配置 WPA2-Enterprise
    esp_eap_client_set_identity(config->identity);
    esp_eap_client_set_username(config->username);
    esp_eap_client_set_password(config->password);
    esp_eap_client_set_disable_time_check(true);  // 跳过证书时间校验
    esp_wifi_sta_enterprise_enable();
    
    esp_wifi_start();
    // ...
}

5.2 内存危机:WiFi + TLS 的代价

WPA2-Enterprise 需要 mbedTLS 来处理 EAP-TLS 握手,这会消耗大量的内部 RAM(IRAM 和 DRAM)。加上 LVGL 的渲染缓冲区和各种 FreeRTOS 任务栈,设备直接因为内存不足在启动时不断重启。

解决方案:

# sdkconfig 中减小 WiFi 和 TLS 缓冲区
CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=4      # 默认 10
CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=8      # 默认 32
CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN=4096       # 默认 16384
CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN=4096      # 默认 16384
CONFIG_MBEDTLS_DYNAMIC_BUFFER=y              # 动态分配
CONFIG_MBEDTLS_DYNAMIC_FREE_PEER_CERT=y

同时把网络初始化放到独立的 FreeRTOS 任务中,给足 16KB 栈空间:

xTaskCreatePinnedToCore(network_init_task, "net_init", 16384, NULL, 3, NULL, 1);

5.3 NTP 时间同步

连上 WiFi 后,立刻配置 NTP 同步时间。之前时钟页面显示的是开机后的计时器时间(从 1970-01-01 开始算),连上 WiFi 并同步 NTP 后才显示正确的当前时间。

setenv("TZ", "CST-8", 1);   // 中国时区 UTC+8
tzset();
esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL);
esp_sntp_setservername(0, "ntp.aliyun.com");
esp_sntp_setservername(1, "pool.ntp.org");
esp_sntp_init();

image

六、Mac-Server:本地数据中心

ESP32 的算力和网络能力有限,不适合直接调用各种外部 API。于是用 FastAPI 在 Mac 上搭了一个本地服务端,作为数据中转。

6.1 服务端架构

# server.py — FastAPI 入口
app = FastAPI()

# REST API(ESP32 HTTP 轮询)
@app.get("/api/weather/current")      # 当前天气
@app.get("/api/weather/forecast")     # 5日预报
@app.get("/api/cursor/usage")         # Cursor API 用量
@app.get("/api/deepseek/usage")       # DeepSeek 余额

# WebSocket(实时推送)
@app.websocket("/ws/spectrum")        # 音频频谱数据

6.2 Bearer Token 认证

所有 API 请求需要在 Header 中携带 Bearer Token:

Authorization: Bearer esp32-******-change-me

WebSocket 连接通过 query parameter 传递 token:

ws://host:port/ws/spectrum?token=esp32-******-change-me

6.3 数据轮询策略

ESP32 端每 30 秒轮询一次天气和 AI 用量数据(data_client.c)。为了避免和 WebSocket 频谱数据争抢网络资源,WebSocket 在 HTTP 首次拉取完成后延迟 5 秒才启动。


七、中文显示与农历

7.1 字体问题

LVGL 内置的 CJK 字体(lv_font_source_han_sans_sc_16_cjk)只包含常用汉字,很多天气描述(如”雷阵雨”的”阵”、”霾”)和日期用字(如”农”、”腊”)都缺失,显示为方块。

解决方案:用 lv_font_conv 工具从 Noto Sans SC TTF 字体生成自定义 LVGL 字体,精确包含项目需要的所有汉字:

npx lv_font_conv \
  --bpp 4 --size 16 \
  --font NotoSansSC-Regular.ttf \
  --range 0x20-0x7F,0xB0 \
  --symbols "所有需要的中文字符..." \
  --format lvgl \
  -o font_noto_sc_16.c

生成的字体文件大约 100KB,包含了所有 UI、天气、日历需要的简体中文字符。

7.2 字体回退链

为了兼容性,设置了字体回退链:

自定义 Noto Sans SC → 内置 CJK 字体 → Montserrat(数字/英文)

实现上有一个坑:LVGL 的字体描述符(lv_font_t)通常存储在 Flash 中,是 const 的,无法直接修改 fallback 指针。需要先 memcpy 到 RAM 中的副本,再设置回退指针:

static lv_font_t s_noto_16;  // RAM 中的可写副本
memcpy(&s_noto_16, &font_noto_sc_16, sizeof(lv_font_t));
s_noto_16.fallback = &lv_font_source_han_sans_sc_16_cjk;

7.3 农历转换

实现了完整的公历 → 农历转换算法(lunar_cal.c),覆盖 1900-2100 年:

typedef struct {
    int year, month, day;
    bool is_leap;
} lunar_date_t;

void solar_to_lunar(int sy, int sm, int sd, lunar_date_t *ld);
const char *lunar_month_str(const lunar_date_t *ld);  // "正月"、"二月"...
const char *lunar_day_str(int day);                     // "初一"、"十五"...

时钟页面的日期显示效果:2026年7月21日 星期二 六月初七


八、天气图标:QWeather Icon Font

8.1 从文字到图标

最初天气图标是用文字显示的(”晴”、”云”、”雨”),但这不够直观。QWeather 提供了一套 天气图标字体,支持 150+ 种天气状况。

8.2 图标字体生成

从 QWeather 的 TTF 字体文件生成 LVGL 字体:

npx lv_font_conv \
  --bpp 4 --size 24 \
  --font qweather-icons.ttf \
  --range 0xF101-0xF146 \
  --format lvgl \
  -o font_qweather_24.c

8.3 图标代码映射

QWeather API 返回数字图标代码(如 “100” 表示晴天),需要映射到字体中的 Unicode 码点:

const char *qw_icon_str(int code) {
    uint16_t cp;
    switch(code) {
        case 100: cp = 0xF101; break;  // 晴
        case 101: cp = 0xF102; break;  // 多云
        case 104: cp = 0xF104; break;  // 阴
        case 300: cp = 0xF10D; break;  // 阵雨
        case 305: cp = 0xF112; break;  // 小雨
        // ... 更多映射
    }
    // 将 Unicode 码点编码为 UTF-8
    static char buf[4];
    buf[0] = 0xEF;                    // 3字节 UTF-8
    buf[1] = 0x80 | ((cp >> 6) & 0x3F);
    buf[2] = 0x80 | (cp & 0x3F);
    buf[3] = '\0';
    return buf;
}

九、音频频谱可视化

这是整个项目最有趣也最有挑战的部分——让 ESP32 实时显示 Mac 正在播放的音乐频谱。

9.1 macOS 音频捕获

使用 catap 库(基于 macOS 14.2+ 的 Core Audio Tap API)捕获系统音频:

import catap

session = catap.record_system_audio(
    output_path=None,      # 不录制到文件
    on_buffer=on_buffer    # 每收到一帧音频回调
)
session.start()

注意:catap 需要 macOS 的 “屏幕与系统音频录制” 权限。不同的终端应用(Terminal.app vs iTerm2)需要分别授权。

9.2 FFT 频谱分析

收到原始音频数据后,做 FFT 变换提取频谱:

def _compute_spectrum(self, audio_data):
    windowed = audio_data * np.hanning(self.fft_size)
    fft_data = np.abs(np.fft.rfft(windowed))
    
    # 对数分布分频段(低频更密)
    for i in range(self.bands):
        start = int((freq_bins - 1) * (2 ** (i / self.bands) - 1))
        end = int((freq_bins - 1) * (2 ** ((i + 1) / self.bands) - 1))
        band_energy = np.mean(fft_data[start:end])
        # ...
    
    # 分贝映射 + 归一化到 0~1
    for v in band_values:
        db = 20 * np.log10(max(v / max_val, 1e-6))
        norm = max(0.0, min(1.0, (db + 60) / 60))

9.3 WebSocket 二进制协议

最初用 JSON 传输频谱数据,但 ESP32 上的 cJSON 解析开销太大,导致频谱动画卡顿。改用自定义二进制协议后性能大幅提升:

┌──────────┬───────┬──────────────────┐
│  "SPEC"  │ Flags │  float32 × 32    │
│ (4 bytes)│(1 byte)│  (128 bytes)     │
└──────────┴───────┴──────────────────┘
Total: 133 bytes/frame
  • Flags 的 bit0:is_playing,标识是否有音频在播放
  • float32 × 32:32 个频段的能量值(0.0~1.0)

Python 端打包:

message = b'SPEC' + struct.pack('B', flags) + struct.pack('32f', *bands)
await ws.send_bytes(message)

ESP32 端解包:

if (data->op_code == 2 && data->payload_len == 133) {
    const uint8_t *p = data->data_ptr;
    if (memcmp(p, "SPEC", 4) != 0) break;
    bool is_playing = (p[4] & 0x01) != 0;
    float bands[32];
    memcpy(bands, p + 5, 128);  // 直接 memcpy,零解析开销
}

JSON → 二进制的效果:

  • 数据量:~800 bytes → 133 bytes(减少 83%)
  • ESP32 解析耗时:~2ms → ~0ms
  • 整体延迟降低约 50%

9.4 频谱 UI 渲染

32 条频谱柱状图使用 LVGL lv_obj 实现,每条柱子独立上色(HSL 彩虹色),从底部向上生长:

for (int i = 0; i < 32; i++) {
    float hue = (float)i / 32 * 280.0f;
    lv_color_t color = hsl_color(hue, 0.85f, 0.55f);
    
    int h = (int)(data[i] * MAX_HEIGHT);
    lv_obj_set_height(bar, h);
    lv_obj_set_y(bar, 172 - 8 - h);  // 从底部向上增长
}

频谱的状态指示:

  • 绿色 “Live” — WebSocket 已连接且音频正在播放
  • 黄色 “Connected” — WebSocket 已连接但无音频
  • 灰色 “Offline” — WebSocket 未连接

十、性能调优之路

ESP32-S3 + LVGL + 软件旋转的组合对性能要求很高。整个过程中遇到了多次性能危机。

10.1 Task Watchdog 超时

症状:设备随机重启,日志显示 Task watchdog got triggered - LVGL (CPU 0)

原因:LVGL 渲染任务长时间阻塞,无法喂狗。

治理措施

  • 增大 WDT 超时时间:CONFIG_ESP_TASK_WDT_TIMEOUT_S=30
  • 禁用 IDLE0 任务的 WDT 检查
  • 增大 LVGL 任务栈:16KB

10.2 AI 用量页面的 Canvas 地雷

症状:切换到 AI 用量页面必定卡死。

原因:使用 lv_canvas 画渐变弧形时,LVGL 需要分配层缓冲区(layer buffer),在内存紧张时进入无限重试循环。

解决:用 LVGL 原生的 lv_arc 控件替代 lv_canvas,避免了额外的层缓冲区分配。

10.3 阴影渲染的代价

症状blur_walk_cb 触发 WDT 超时。

原因:LVGL 的阴影渲染使用了高斯模糊,在 ESP32 上非常耗时。

解决:去掉所有 UI 元素的阴影样式。

10.4 内存分配策略

症状:WiFi + TLS 连接后,LVGL 渲染时因内存不足再次触发 WDT。

原因:LVGL 默认使用自己的内置堆(64KB DRAM),WiFi + TLS 占用了大量内部 RAM 后,LVGL 堆不够用。

解决:让 LVGL 使用系统 malloc,这样 LVGL 的大块分配会自动走 PSRAM(8MB):

CONFIG_LV_USE_CLIB_MALLOC=y           # LVGL 使用系统 malloc
CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=4096  # 4KB 以上分配走 PSRAM

10.5 帧率优化

最终的性能参数:

CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ=240     # CPU 频率拉满
CONFIG_LV_DEF_REFR_PERIOD=33            # LVGL 刷新周期 33ms (30fps)
#define LVGL_TASK_MIN_DELAY_MS 20       // LVGL 任务最小延迟

频谱动画在 30fps 下表现流畅,柱状图的上升响应及时,下降衰减自然。


十一、Web 管理面板

在 Mac 浏览器上可以打开 http://localhost:8849/dashboard 查看:

  • 服务器状态 — 运行时间、API 调用次数
  • ESP32 设备状态 — 最后心跳时间、IP 地址、WiFi 信号强度
  • AI 用量 — Cursor/DeepSeek 的配额和使用量
  • 天气信息 — 当前天气和预报
  • WiFi 配置 — 可以远程配置 ESP32 的 WiFi 连接

管理面板使用暗色主题,每 5 分钟自动刷新数据。


十二、最终效果与项目结构

项目文件结构

esp32-agent/
├── firmware/                    # ESP32 固件
│   ├── main/
│   │   ├── main.c              # 主入口:LCD/LVGL/Touch 初始化、旋转、DMA
│   │   ├── wifi_manager.c/h    # WPA2-Enterprise WiFi 管理
│   │   ├── data_client.c/h     # HTTP 数据轮询客户端
│   │   ├── ws_client.c/h       # WebSocket 频谱客户端
│   │   ├── ui_manager.c/h      # 5 页 UI 管理、翻页手势
│   │   ├── ui_clock.c/h        # 翻页时钟 + 农历
│   │   ├── ui_weather.c/h      # 天气页 + QWeather 图标
│   │   ├── ui_spectrum.c/h     # 32 频段频谱可视化
│   │   ├── ui_dashboard.c/h    # AI 用量环形图
│   │   ├── ui_fonts.c/h        # 字体管理(回退链)
│   │   ├── ui_icons.c/h        # QWeather 图标映射
│   │   ├── lunar_cal.c/h       # 公历→农历转换
│   │   ├── font_noto_sc_16.c   # 中文字体 16px
│   │   ├── font_noto_sc_14.c   # 中文字体 14px
│   │   ├── font_qweather_24.c  # 天气图标字体 24px
│   │   └── user_config.h       # 硬件引脚和分辨率配置
│   ├── sdkconfig               # ESP-IDF 配置
│   └── CMakeLists.txt
├── mac-server/                  # Mac 服务端
│   ├── server.py               # FastAPI 入口
│   ├── config.yaml             # 服务配置
│   ├── weather_client.py       # QWeather API
│   ├── audio_capture.py        # macOS 音频捕获 + FFT
│   ├── ws_spectrum.py          # WebSocket 频谱广播
│   ├── cursor_client.py        # Cursor 用量查询
│   ├── deepseek_client.py      # DeepSeek 余额查询
│   ├── static/dashboard.html   # Web 管理面板
│   └── requirements.txt
└── lvgl-simulator/              # 桌面 LVGL 模拟器(开发用)

五个页面

页面 内容 数据来源
Clock 翻页时钟 + 日期 + 星期 + 农历 本地 RTC(NTP 同步)
Weather 天气图标 + 温度 + 湿度 + 风力 QWeather API via Mac-Server
Forecast 5 日天气预报 QWeather API via Mac-Server
Spectrum 32 频段实时音频频谱 macOS 音频 via WebSocket
Dashboard Cursor/DeepSeek API 用量 Cursor/DeepSeek API via Mac-Server

十三、踩坑总结与经验

硬件相关

  1. AXS15231B + QSPI 不支持硬件旋转:这是最大的坑,浪费了一整天。如果你的应用对帧率要求高,选板子时一定要确认屏幕控制器在你使用的接口模式下是否支持 swap_xy

  2. PSRAM 是救命稻草:8MB PSRAM 让很多内存紧张的问题迎刃而解,但要注意 DMA 不能直接从 PSRAM 读数据(需要先 memcpy 到内部 RAM)。

  3. 串口和 USB 是两回事:ESP32-S3 的 UART(GPIO 44/43)和 USB Serial/JTAG 是两个不同的控制台通道,配置不对会看不到应用日志。

LVGL 相关

  1. v8 → v9 迁移要仔细:API 变化很大,特别是 display 和 draw buffer 的初始化方式。

  2. 避免使用 Canvas 和阴影:在资源受限的嵌入式设备上,这两个特性是性能杀手。

  3. 字体 fallback 需要可写副本const 字体描述符存在 Flash 中,不能直接修改 fallback 指针。

  4. 让 LVGL 使用系统 mallocCONFIG_LV_USE_CLIB_MALLOC=y 配合 PSRAM,比 LVGL 内置堆灵活得多。

网络相关

  1. WPA2-Enterprise 很吃内存:mbedTLS 的 TLS 握手需要大量临时内存,要调小缓冲区并启用动态分配。

  2. WebSocket 用二进制协议:JSON 解析在 ESP32 上开销不小,对于高频实时数据(如频谱),二进制协议是更好的选择。

  3. macOS 音频录制权限catap 需要在”系统设置→隐私与安全→屏幕与系统音频录制”中授权运行它的终端应用。

调试技巧

  1. 先在模拟器上开发 UI:可以节省大量烧录时间。
  2. idf.py monitor 看日志:但注意要配对正确的控制台通道。
  3. 善用 FreeRTOS Task Watchdog:它能帮你发现阻塞问题,但超时时间别设太短。

写在最后

从公众号看到别人的作品,到自己动手做出一个功能完整的桌面智能仪表盘,整个过程比预想的复杂得多。最终的成品虽然谈不上完美(帧率还可以更高、UI 还可以更精致),但每次看到桌上的小屏幕实时跳动着音乐频谱、显示着天气和时间,都觉得这几天的折腾是值得的。

这个项目的所有代码都在 esp32-agent 目录下,包括固件、服务端和模拟器。如果你也有一块类似的 ESP32 开发板,希望这篇文章能帮你少踩一些坑。


技术栈:ESP-IDF v5.5.3 / LVGL v9.5.0 / FastAPI / catap / QWeather API
硬件:Waveshare ESP32-S3-Touch-LCD-3.49