用 ESP32-S3 打造一台会“呼吸”的桌面天气站 🌤️

你有没有过这样的时刻?清晨坐在书桌前,端起咖啡杯,顺手瞥一眼角落里那块小小的屏幕——它正安静地告诉你今天北京多云转晴、气温23°C、湿度58%……不需要打开手机,也不用唤醒电脑,一切信息就在眼前,像一位老朋友轻声提醒。

这,就是我们今天要做的东西: 一台真正属于你的桌面天气站 。不是那种只能显示数字的电子钟,而是一个有设计感、能联网、会动、甚至带点“情绪”的小设备。它不贵,但看起来很值;它小巧,却装得下整个城市的天气。

核心芯片?选的是乐鑫最新的 ESP32-S3 。这块芯片这几年在创客圈火得不行,不只是因为便宜,而是它真的“啥都能干”。Wi-Fi 能连,蓝牙能通,CPU 够快,还能直接驱动彩色屏幕。最关键的是——它能让嵌入式开发变得有点“意思”,比如让你的温度数字慢慢滑动变化,或者让一朵云图标随着天气阴晴缓缓飘过。


为什么是 ESP32-S3?因为它不只是个“单片机”了

说实话,以前做这种项目,我总会纠结:要不要加个外置 Wi-Fi 模块?图形处理会不会卡?内存够不够塞下一个好看的图标?但现在,这些问题在 ESP32-S3 面前,基本都成了“伪问题”。

先看几个硬核参数:

  • 双核 Xtensa LX7,主频高达 240MHz
    这意味着你可以把网络请求丢给一个核,UI 渲染交给另一个核,互不打扰。想象一下你在刷网页的时候后台还在下载文件——这就是多任务的感觉。

  • 支持外部 PSRAM 和 Flash,最高可达 16MB
    别小看这个。你要显示一张 JPEG 图标?LVGL 要缓存一整屏内容?没问题,PSRAM 就是干这个的。没有它,你可能连一个平滑动画都跑不起来。

  • 原生 LCD 控制器 + JPEG 硬件解码
    对,你没看错,它可以直接输出 RGB 信号到屏幕,不用靠 SPI 一点一点“喂”数据。而且如果你用了 JPG 格式的天气图标,S3 可以硬件解码,CPU 几乎不怎么参与。

  • USB OTG 支持
    这个很多人忽略,但它意味着你可以用 USB 当调试口、当串口、甚至将来做成 HID 设备(比如模拟键盘上报环境数据)。

换句话说, ESP32-S3 已经不是一个传统意义上的 MCU 了 ,它更像是一个微型 SoC 系统,专为视觉交互类 IoT 设备而生。

实际开发中那些“爽点”

举个例子:我想让屏幕每 10 分钟自动刷新一次天气数据。在过去,如果主控性能弱,你就得牺牲帧率或关闭动画来腾出 CPU 时间。但在 S3 上,我可以这么做:

xTaskCreatePinnedToCore(
    fetch_weather_task,   // 单独任务获取天气
    "FetchWeather",
    4096,
    NULL,
    3,
    NULL,
    0  // 绑定到 CPU0
);

xTaskCreatePinnedToCore(
    lvgl_update_task,     // LVGL 主循环
    "LVGLUpdate",
    8192,
    NULL,
    2,
    NULL,
    1  // 绑定到 CPU1
);

两个任务各司其职,互不影响。一个去网上抓 JSON 数据,另一个专心画画。结果就是:界面丝滑如德芙,后台更新悄无声息。


屏幕怎么选?别再只盯着 ILI9341 了!

说到显示,很多教程还停留在“接个 2.4 寸 ILI9341 屏就行”的阶段。没错,它便宜、资料多,但真要用起来体验并不好——SPI 接口速率有限,刷个全屏都要几十毫秒,更别说想搞点渐变、阴影、圆角这些现代 UI 元素了。

我的建议是: 直接上 RGB 接口的 TFT 屏,分辨率至少 480×320 。

比如市面上常见的 RM67162 或 ST7701S 驱动的 3.5” 屏,支持 16-bit RGB 并行输入,配合 ESP32-S3 的 LCD 控制器,可以实现接近 60fps 的刷新率(当然取决于 DMA 效率)。更重要的是,它可以摆脱“SPI 发送命令+写数据”的低效模式,进入真正的“显存映射”时代。

我是怎么配置屏幕驱动的?

这里分享一段我在项目中实际使用的初始化代码(基于 ESP-IDF):

#include "esp_lcd_panel_io.h"
#include "esp_lcd_panel_rgb.h"

static esp_lcd_panel_handle_t panel_handle;

void init_rgb_display() {
    const esp_lcd_rgb_panel_config_t panel_config = {
        .data_width = 16,
        .pins = {
            .clk = 40,
            .de = 41,
            .vsync = 42,
            .hsync = 43,
            .data = {10, 11, 12, 13, 14, 15, 16, 17, 
                     18, 19, 20, 21, 22, 23, 24, 25},
        },
        .timings = {
            .pclk_hz = 10 * 1000 * 1000,
            .h_res = 480,
            .v_res = 320,
            .hsync_back_porch = 40,
            .hsync_front_porch = 40,
            .hsync_pulse_width = 10,
            .vsync_back_porch = 15,
            .vsync_front_porch = 15,
            .vsync_pulse_width = 10,
            .flags.pclk_active_neg = true,
        },
        .bounce_buffer_size_px = 480 * 50,  // 50 行缓冲
        .flags.fb_in_psram = true
    };

    ESP_ERROR_CHECK(esp_lcd_new_rgb_panel(&panel_config, &panel_handle));
    ESP_ERROR_CHECK(esp_lcd_panel_reset(panel_handle));
    ESP_ERROR_CHECK(esp_lcd_panel_init(panel_handle));

    // 启动流式传输
    ESP_ERROR_CHECK(esp_lcd_panel_disp_on_off(panel_handle, true));
}

关键点有几个:

  • bounce_buffer_size_px 设置了一个“跳动缓冲区”(也叫双行缓冲),避免一次性申请整个 framebuffer(那可是 480×320×2 ≈ 300KB!)
  • 使用 PSRAM 存储帧缓冲,释放宝贵的 IRAM 给实时任务
  • PCLK 设置为 10MHz,实测足够稳定且能满足基本刷新需求

这样一来,屏幕就不再是“拖后腿”的部分,反而成了系统的亮点。


LVGL:让嵌入式界面也能“卷”起来

如果说硬件决定了下限,那软件决定了上限。在这个项目里, LVGL 就是我的上限突破器 。

你可能会问:“干嘛不用 Arduino 自带的 TFT_eSPI 直接画图?”
答案很简单: 可维护性太差,扩展性为零 。

试想一下,你现在画了个温度标签,明天想加个湿度进度条,后天还想做个切换页面的按钮……很快你的 loop() 函数就会变成一堆 tft.fillRect() 和坐标计算的地狱。

而 LVGL 不一样。它是组件化的,就像 Web 前端里的 React 或 Vue,每个元素都是对象,有自己的样式、事件和生命周期。

我的设计思路:构建一个“活”的天气面板

我不想做一个静态仪表盘,而是希望它有点生命力。比如:

  • 温度变化时,数字从旧值滑动到新值(动画过渡)
  • 天气状况改变时,图标淡入淡出
  • 背景颜色随时间微调(白天偏蓝,傍晚泛橙)

这一切,LVGL 都能轻松实现。

创建基础 UI 结构
lv_obj_t *screen = lv_scr_act();
lv_obj_set_style_bg_color(screen, lv_color_hex(0x1E1E1E), 0);

// 温度标签
lv_obj_t *label_temp = lv_label_create(screen);
lv_label_set_text(label_temp, "--°C");
lv_obj_set_style_text_font(label_temp, &lv_font_montserrat_48, 0);
lv_obj_set_style_text_color(label_temp, lv_color_white(), 0);
lv_obj_align(label_temp, LV_ALIGN_CENTER, 0, -60);

// 天气图标
lv_obj_t *img_weather = lv_img_create(screen);
lv_img_set_src(img_weather, &icon_clear_day);  // LVGL image declare
lv_obj_align(img_weather, LV_ALIGN_CENTER, 0, 0);

// 湿度条
lv_obj_t *bar_humidity = lv_bar_create(screen);
lv_bar_set_value(bar_humidity, 60, LV_ANIM_ON);
lv_obj_set_size(bar_humidity, 200, 10);
lv_obj_align(bar_bar, LV_ALIGN_CENTER, 0, 80);

是不是比一堆 drawXxx() 调用清晰多了?

加点动画让它“动”起来

LVGL 内建了强大的动画系统。比如我想让温度数字平滑变化:

static float current_temp = 0;

void animate_temperature_to(float target) {
    lv_anim_t a;
    lv_anim_init(&a);
    lv_anim_set_var(&a, label_temp);
    lv_anim_set_values(&a, (int)(current_temp * 10), (int)(target * 10));
    lv_anim_set_exec_cb(&a, [](void *var, int32_t v) {
        char buf[16];
        sprintf(buf, "%.1f°C", v / 10.0);
        lv_label_set_text((lv_obj_t *)var, buf);
    });
    lv_anim_set_time(&a, 800);
    lv_anim_set_playback_delay(&a, 100);
    lv_anim_set_playback_time(&a, 200);
    lv_anim_start(&a);

    current_temp = target;
}

效果如何?当你从 22°C 更新到 26°C 时,屏幕上会看到数字像老式计数器一样滚动上升,最后稳稳停住。用户感知到的是“正在更新”,而不是“突然变了”。


数据从哪来?OpenWeatherMap + HTTPS 安全请求

天气数据不能凭空生成,得找 API。目前最稳定的免费方案之一是 OpenWeatherMap ,注册后能拿到每天 1000 次免费调用额度,完全够个人使用。

请求地址长这样:

http://api.openweathermap.org/data/2.5/weather?q=Beijing&appid=YOUR_KEY&units=metric

返回 JSON 示例:

{
  "main": {
    "temp": 23.5,
    "humidity": 58
  },
  "weather": [
    {
      "main": "Clear",
      "description": "clear sky"
    }
  ],
  "name": "Beijing"
}

如何安全地发起 HTTPS 请求?

⚠️ 注意:不要用 HTTP!API Key 明文传输非常危险。

正确做法是使用 HTTPClient 配合 MbedTLS,验证服务器证书。ESP-IDF 默认已经集成了这套机制,只需启用相关组件即可。

#include <HTTPClient.h>

String fetch_weather_json(const String& city) {
    if (!WiFi.isConnected()) return "";

    HTTPClient http;
    String url = "https://api.openweathermap.org/data/2.5/weather?q=";
    url += city;
    url += "&appid=" + SECRET_API_KEY;
    url += "&units=metric";

    http.begin(url);
    int httpCode = http.GET();

    if (httpCode == HTTP_CODE_OK) {
        String payload = http.getString();
        http.end();
        return payload;
    } else {
        Serial.printf("HTTP request failed, code: %d\n", httpCode);
        http.end();
        return "";
    }
}

但等等—— API Key 写死在代码里也很危险!

更好的做法:用 NVS 存储配置项

NVS(Non-Volatile Storage)是 ESP32 提供的一种键值存储系统,类似小型数据库,数据掉电不丢失。

我们可以这样做:

  1. 第一次运行时提示用户通过串口或 BLE 输入城市名和 API Key
  2. 存入 NVS
  3. 后续启动自动读取
nvs_handle_t handle;
nvs_open("config", NVS_READWRITE, &handle);

// 保存
nvs_set_str(handle, "api_key", key.c_str());
nvs_set_str(handle, "city", city.c_str());
nvs_commit(handle);

// 读取
char buf[64];
size_t len = sizeof(buf);
nvs_get_str(handle, "api_key", buf, &len);
String saved_key = String(buf);

这样即使别人拿到固件,也无法轻易提取敏感信息。


性能优化实战:如何让小设备跑出大体验?

资源有限是现实,但我们可以通过技巧弥补。

1. 内存分配策略:哪些放 IRAM,哪些扔 PSRAM?

  • IRAM(~192KB) :留给中断服务程序、DMA 描述符、高频调用函数
  • PSRAM(8~16MB) :存放 framebuffer、图片资源、JSON 缓冲区

示例:创建 LVGL 的绘图缓冲区时指定位置

static lv_color_t *draw_buf_dbl = (lv_color_t*)ps_malloc(sizeof(lv_color_t) * 480 * 100);
lv_disp_draw_buf_init(&draw_buf, draw_buf_dbl, NULL, 480 * 100);

ps_malloc 是 ESP-IDF 提供的 API,优先在 PSRAM 分配。

2. 图标管理:用 C 数组还是外部文件?

很多人喜欢把 PNG 图标烧进 Flash,然后动态加载。但其实对于固定尺寸的小图标(如 64x64), 预编译成 C 数组更高效 。

工具推荐: LVGL Image Converter
设置输出格式为 “C array”,颜色深度 RGB565,然后复制到代码中:

LV_IMG_DECLARE(icon_rain_64);

优点:
- 零解码开销
- 直接位于 Flash,运行时不占 RAM
- LVGL 可直接引用

3. 减少网络压力:缓存 + 定时轮询

频繁请求不仅耗电,还容易触发限流。我的策略是:

  • 首次启动立即获取
  • 成功后每隔 15 分钟 自动刷新
  • 失败时指数退避重试(1min → 2min → 4min…)
  • 添加本地缓存机制,断网时显示上次有效数据
void start_weather_update_loop() {
    while (1) {
        String json = fetch_weather_json(current_city);
        if (json != "") {
            parse_and_update_ui(json);
            last_success_time = millis();
            save_to_cache(json);  // 写入 NVS
            retry_delay = 15 * 60 * 1000;  // 下次 15 分钟后再查
        } else {
            retry_delay = min(retry_delay * 2, 60 * 60 * 1000); // 最大 1 小时
        }
        delay(retry_delay);
    }
}

中文字体怎么办?别怕,LVGL 支持 UTF-8!

默认情况下,LVGL 字体只包含 ASCII 字符。想显示“北京”、“晴”、“湿度”这些中文?必须自己生成字体。

步骤如下:

  1. 使用 lvgl-font-addons 工具
  2. 输入想要的 TTF 字体(推荐思源黑体、站酷酷黑)
  3. 指定字符范围: --range=U+4E00-U+9FFF,U+30,U+20 (覆盖常用汉字+数字+空格)
  4. 输出为 C 文件并包含进工程
npx lv_font_conv -r latine-3,greek-cyrillic,chinese-1 \
                 --font "ZCOOLKuaiLe-Regular.ttf" \
                 -s 24 \
                 --format c-array \
                 -o font_kuaile_24.c

然后注册字体:

lv_font_t* font_chinese = &my_font_kuaile_24;
lv_obj_set_style_text_font(label_city, font_chinese, 0);

虽然生成后的字体文件可能有几百 KB,但放在 PSRAM 或压缩后存储都没问题。毕竟,谁不想自己的天气站说“你好,北京”而不是冷冰冰的“Hello Beijing”呢?


功耗与休眠:让它陪你更久一点

虽然是插电设备,但我还是希望它节能。特别是在夜间,没必要全亮度运行。

方案一:PWM 调节背光

几乎所有 TFT 屏都有 BLK(Backlight)引脚,接 PWM 就能调亮度。

ledcSetup(0, 5000, 8);  // 通道 0,5kHz,8位精度
ledcAttachPin(TFT_BL, 0); // 绑定引脚
ledcWrite(0, 128);       // 50% 亮度

可以结合 RTC 时间判断是否进入“夜间模式”:

if (hour >= 22 || hour <= 6) {
    ledcWrite(0, 64);  // 降低至 25%
} else {
    ledcWrite(0, 255); // 全亮
}

方案二:浅睡眠模式(Light-sleep)

ESP32-S3 支持多种低功耗模式。虽然屏幕开着没法深度睡眠,但可以让 CPU 在空闲时暂停。

esp_sleep_enable_timer_wakeup(15 * 60 * 1000 * 1000); // 15分钟后唤醒
esp_light_sleep_start(); 

注意:进入 Light-sleep 前需暂停 LVGL 任务,醒来后再恢复。


扩展性:下一步你能加什么?

这个项目最迷人的地方在于它的可塑性。一旦基础框架搭好,后续功能几乎是“即插即用”。

✅ 已实现

  • 实时温度/湿度显示
  • 天气图标动态切换
  • 自动定时刷新
  • 支持中文界面
  • 安全 HTTPS 请求

🔧 正在规划

  • 触摸屏支持 → 实现翻页(当前天气 / 未来预报 / AQI)
  • NTP 时间同步 → 显示精准时间 + 日出日落
  • SD 卡记录 → 存储历史数据生成趋势图
  • BLE 配网 → 手机 App 设置城市和主题
  • 动态壁纸 → 根据天气切换背景图(晴天蓝天,雨天窗边雨滴)

🚀 黑科技彩蛋(已验证)

我还试过一个有趣的玩法: 用 ESP32-S3 的 USB OTG 模拟成一个 HID 设备 ,把当前温度作为“键盘输入”发送给电脑。这样只要按一个快捷键,就能把“23°C”打到文档里——纯属好玩,但足够惊艳。


遇到过哪些坑?这是我踩出来的经验清单

❌ 屏幕花屏?检查时序参数!

刚开始调试 RGB 屏时满屏雪花,后来发现是 pclk_active_neg 没设对。有些屏幕需要下降沿采样,就得开启负极性。

💡 建议:先用最低 PCLK(如 5MHz)测试能否点亮,再逐步提高。

❌ LVGL 卡顿?别忘了定期调用 lv_timer_handler()

这个函数是 LVGL 的“心跳”,必须在某个任务中持续运行:

void lvgl_update_task(void *pvParameter) {
    while (1) {
        lv_timer_handler();
        vTaskDelay(pdMS_TO_TICKS(5));  // 每 5ms 一次
    }
}

否则动画不会动,点击无响应。

❌ HTTPS 报错 -0x7200 ?缺少根证书!

MbedTLS 默认不会信任所有 CA,你需要手动添加 OpenWeatherMap 使用的证书链。

解决方法:导出 api.openweathermap.org 的 PEM 证书,编译进代码:

http.setCACert(weather_ca_pem_start);  // 来自 certificates bundle

否则永远连接失败。

❌ 内存溢出崩溃?监控 heap 使用情况!

尤其是在 PSRAM 初始化失败的情况下,很容易误以为分配成功。

建议加入监控:

void print_memory_usage() {
    size_t free_iram = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
    size_t free_psram = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
    Serial.printf("IRAM: %d KB, PSRAM: %d KB\n", free_iram / 1024, free_psram / 1024);
}

每分钟打印一次,帮助定位泄漏点。


写在最后:技术的意义,是让人生活得更舒服一点

做这个项目的过程中,我常常在想:我们折腾这么多,到底图个啥?

是为了炫技吗?不是。
是为了省几块钱买成品吗?也不是。

真正打动我的,是某天晚上加班回家,看到它静静地亮着,显示“室内 24°C,室外小雨”,然后顺手把窗户关上了。

那一刻我知道,这不是一块会发光的塑料板,而是一个懂得关心你的小东西。

而这一切,始于一块开源芯片、一段自由代码、和一颗愿意动手的心。

所以,如果你也在寻找一个既能练手又有温度的项目,不妨试试这个天气站吧。
不一定完美,但一定值得。

毕竟, 最好的技术,从来都不是冷冰冰的参数表,而是藏在日常里的温柔瞬间 。✨

更多推荐