1. 项目缘起:从“看天气”到“懂天气”的硬件升级
几年前,我还在用一块简单的ESP8266加一个OLED屏幕,做一个能显示本地温湿度和天气状况的小玩意儿。那东西挺好,插上电,连上Wi-Fi,就能告诉你今天要不要带伞。但用久了,问题就来了:它只能告诉你“现在”怎么样,对于“等会儿”或者“明天”会怎样,完全无能为力。早上出门看着晴天,结果下午一场暴雨浇了个透心凉,这种经历多了,就让我琢磨着得做个更“聪明”的站。
这个“Advanced ESP32 Internet Weather Station with 5 day Forecast”项目,就是在这个背景下诞生的。它不再满足于做一个被动的数据接收器,而是试图成为一个主动的、具备一定预测和展示能力的家庭环境信息中枢。核心很简单:用一块性能更强的ESP32做主控,通过互联网获取权威的天气预报数据(特别是未来5天的预报),再搭配一块显示效果更好的屏幕,把这些信息清晰、美观、持续地呈现出来。
它适合谁呢?如果你是对物联网(IoT)感兴趣的硬件爱好者,想做一个既有实用价值又能深入理解网络请求、JSON解析、UI设计等概念的练手项目,那它再合适不过。如果你是个创客,想给家里添置一个兼具科技感和实用性的小摆件,它也能完美胜任。甚至,你可以把它作为智能家居的一个信息输入节点,将天气数据提供给Home Assistant等平台,触发更多的自动化场景。相比于简单的实时天气显示,5天预报的加入,让这个设备从“信息显示器”升级为了“生活决策辅助工具”,这才是它“Advanced”(进阶)的真正含义。
2. 核心架构选型:为什么是ESP32+开源API+SPI屏幕?
搭建一个网络天气站,技术栈的选择直接决定了项目的复杂度、稳定性和最终体验。经过多次迭代和对比,我最终锁定了ESP32 + 免费天气API + SPI接口显示屏这个组合。下面详细拆解一下每个环节的选型逻辑和备选方案。
2.1 主控芯片:ESP32为何是更优解
很多人会问,用更便宜的ESP8266不行吗?当然可以,但ESP32提供了几个关键优势,让“Advanced”成为可能。
首先,内存(RAM)是硬指标。ESP8266的可用内存通常在50KB左右,而ESP32(以常见的ESP32-WROOM-32为例)的可用内存高达520KB。处理5天预报数据意味着一次网络请求会返回一个庞大的JSON对象,包含温度、湿度、天气状况图标代码、风速、降水概率等数十个字段。在ESP8266上解析和存储这个JSON对象非常吃力,极易导致内存不足而崩溃(malloc failed错误)。ESP32充裕的内存空间让数据解析和临时存储变得游刃有余。
其次,双核处理能力。ESP32拥有两个240MHz的XTensa核心。我们可以将网络请求、数据解析这些可能阻塞的任务放在一个核心(如Core 0),而将屏幕刷新、UI渲染放在另一个核心(如Core 1)。这样即使网络稍有延迟,屏幕的动画和交互也不会出现卡顿,用户体验更加流畅。这是实现复杂UI动态效果(如平滑的图表绘制)的基础。
再者,更稳定的Wi-Fi连接。ESP32的Wi-Fi驱动和射频性能通常比ESP8266更稳定,在信号复杂的家庭环境中,重连速度和抗干扰能力都更强。对于一个需要长期在线、定时获取数据的设备来说,连接稳定性至关重要。
注意:如果预算极其有限且只显示实时天气,ESP8266是可行的。但一旦涉及多日预报、复杂UI或未来想扩展传感器(如I2C的PM2.5传感器),ESP32的扩展性和稳定性优势会立刻显现。
2.2 数据来源:免费天气API的横向对比与选择
数据是项目的灵魂。免费且稳定的天气API是个人项目的首选。我主要对比过以下三家:
- OpenWeatherMap:老牌服务,提供丰富的API,包括实时天气、5天3小时预报、历史数据等。免费层(One Call API 3.0)目前每天1000次调用,对于个人项目完全足够。其数据格式规范,社区支持好,Arduino库成熟。缺点是免费层不再包含每分钟级的实时调用,但对于小时级更新的天气站毫无影响。
- WeatherAPI.com:界面友好,文档清晰,免费层提供3天预报和实时天气,每天100万次请求额度惊人。JSON结构相对简洁。
- 国内服务商(如和风、心知):对于国内用户,访问速度和数据本地化(如空气质量、生活指数)是巨大优势。通常需要企业认证或个人实名才能获得稳定的免费额度,对于纯粹的个人学习项目,注册流程可能稍显繁琐。
我最终选择OpenWeatherMap,原因如下:其“One Call API 3.0”一个接口就能同时获取实时数据、分钟级降水(免费层无)、小时预报(48小时)和每日预报(8天),数据最全。社区有大量基于Arduino的示例代码,踩坑时容易找到解决方案。获取API Key的过程非常简单,注册后即可在控制台找到。
关键步骤:获取并使用API Key
- 访问OpenWeatherMap官网注册账号。
- 登录后,在“API Keys”选项卡下,系统会生成一个默认的Key,也可以自己创建新的。
- 非常重要:这个Key就像你的密码,不要硬编码在Arduino代码里然后上传到公开的代码仓库。我吃过亏,曾经有Key因为泄露被恶意刷调用导致封禁。正确的做法是:
- 在代码中定义一个
const char* apiKey = “YOUR_API_KEY”;。 - 使用前,通过Arduino IDE的“项目”->“加载库”->“管理库”安装
Preferences库。 - 在设备的首次设置中,将API Key通过串口输入或Web配置页面保存到ESP32的NVS(非易失性存储)中。后续程序从NVS读取。这样,即使分享代码,也不会泄露Key。
- 在代码中定义一个
2.3 显示单元:SPI屏幕的驱动与性能考量
显示部分决定了信息的呈现效果。常见的接口有I2C和SPI。
- I2C屏幕:引脚占用少(仅需2根数据线),但刷新率极低,适合显示静态文字或更新缓慢的数据。对于需要绘制图标、更新多日预报图表的情况,I2C的速率是瓶颈。
- SPI屏幕:需要占用更多引脚(通常4-6根),但通信速率快几个数量级。这意味着你可以实现平滑的动画、绘制复杂的位图天气图标,而不会感到明显延迟。
对于这个项目,SPI屏幕是必须的。我推荐使用ILI9341或ST7789驱动的屏幕,尺寸在2.4寸到3.5寸之间,分辨率240x320或320x480。这些驱动芯片有非常成熟的Arduino库支持(如TFT_eSPI)。
TFT_eSPI库的配置踩坑点: 这个库功能强大但配置稍复杂。你需要修改库文件夹内的用户配置文件(User_Setup.h或通过库提供的配置工具)。
- 正确选择驱动芯片:找到
#define ILI9341_DRIVER或#define ST7789_DRIVER等行,根据你的屏幕型号取消注释。 - 精确定义引脚:下面会有
TFT_MOSI,TFT_MISO,TFT_SCLK,TFT_CS,TFT_DC,TFT_RST等引脚定义。必须根据你实际连接ESP32的GPIO引脚来修改。例如,我常用的是:
如果接线错误,最典型的症状就是屏幕白屏或花屏。#define TFT_MOSI 23 #define TFT_MISO 19 #define TFT_SCLK 18 #define TFT_CS 5 #define TFT_DC 2 #define TFT_RST 4 - 启用SPI频率优化:可以取消注释
#define SPI_FREQUENCY 40000000来提高刷新速度,但如果出现花屏,可以尝试降低这个频率。
3. 软件设计:从网络请求到像素渲染的全链路解析
硬件连接好后,软件才是项目的灵魂。整个程序逻辑是一个清晰的“请求-解析-存储-渲染”循环,但每个环节都有不少细节需要注意。
3.1 网络请求与JSON解析的稳健性实现
在setup()函数中初始化Wi-Fi连接是第一步,这里要加入重试机制。我通常这样写:
void connectToWiFi() { WiFi.begin(ssid, password); Serial.print(“Connecting to WiFi”); int attempts = 0; while (WiFi.status() != WL_CONNECTED && attempts < 20) { // 最多尝试20次 delay(500); Serial.print(“.”); attempts++; } if (WiFi.status() == WL_CONNECTED) { Serial.println(“\nConnected! IP address: ” + WiFi.localIP().toString()); } else { Serial.println(“\nFailed to connect. Check credentials or router.”); // 这里可以进入深度睡眠或等待重启 } }获取数据的核心是发起HTTP GET请求。我使用ESP32 Arduino核心自带的HTTPClient和ArduinoJson库。
构建请求URL: OpenWeatherMap的One Call API 3.0请求URL格式如下:https://api.openweathermap.org/data/3.0/onecall?lat={纬度}&lon={经度}&exclude=minutely,alerts&units=metric&appid={你的API_KEY}
lat,lon: 你所在位置的经纬度,可以在谷歌地图上点击获取。exclude=minutely,alerts: 排除分钟级数据和警报(免费层不包含,但写上避免错误)。units=metric: 使用公制单位,温度返回摄氏度。appid: 你的API Key。
发起请求与解析:
#include <HTTPClient.h> #include <ArduinoJson.h> void fetchWeatherData() { if (WiFi.status() != WL_CONNECTED) { connectToWiFi(); } HTTPClient http; String url = “https://api.openweathermap.org/data/3.0/onecall?lat=YOUR_LAT&lon=YOUR_LON&exclude=minutely,alerts&units=metric&appid=” + String(apiKey); http.begin(url); int httpCode = http.GET(); if (httpCode == HTTP_CODE_OK) { String payload = http.getString(); // 开始解析 DynamicJsonDocument doc(16384); // 关键:根据返回数据大小预留足够内存 DeserializationError error = deserializeJson(doc, payload); if (error) { Serial.print(F(“JSON解析失败: “)); Serial.println(error.f_str()); return; } // 提取实时数据 float currentTemp = doc[“current”][“temp”]; // 当前温度 int currentHumidity = doc[“current”][“humidity”]; // 当前湿度 const char* currentWeatherIcon = doc[“current”][“weather”][0][“icon”]; // 天气图标代码,如 “01d” // 提取5天每日预报(注意:API返回的是8天,包含当天) JsonArray daily = doc[“daily”]; for (int i = 0; i < 5; i++) { // 只取未来5天 forecast[i].maxTemp = daily[i][“temp”][“max”]; forecast[i].minTemp = daily[i][“temp”][“min”]; forecast[i].iconCode = daily[i][“weather”][0][“icon”]; // 用于后续匹配图标 forecast[i].pop = daily[i][“pop”] * 100; // 降水概率,API返回0-1,转为百分比 } dataUpdated = true; // 设置数据更新标志 } else { Serial.printf(“HTTP请求失败,错误码: %d\n”, httpCode); } http.end(); }关键提示:
DynamicJsonDocument doc(16384);这里的容量(16384字节)需要根据实际返回的JSON大小调整。太小会导致解析失败,太大会浪费宝贵的内存。可以通过Serial.println(payload.length());打印一次payload长度来确定。对于5天预报数据,12000-16000字节是安全的。
3.2 数据结构设计与数据存储
为了在程序的各个部分(网络解析、UI渲染)方便地使用天气数据,定义清晰的结构体是必要的。
struct DailyForecast { float maxTemp; float minTemp; String iconCode; // 如 “04n” int pop; // 降水概率百分比 }; DailyForecast forecast[5]; // 存储未来5天的预报 float currentTemp; int currentHumidity; String currentIcon; bool dataUpdated = false; // 数据更新标志,用于触发UI刷新将数据存储在这样的结构体中,比每次都去解析巨大的JSON文档要高效得多。dataUpdated标志位是一个重要的编程技巧。我们让网络请求在后台(或在一个独立的核心任务中)定时执行,更新这个结构体和标志位。主UI循环只需要检查if(dataUpdated),如果为真,则刷新屏幕,然后重置标志位。这样实现了数据获取与界面渲染的解耦,避免了在渲染过程中因等待网络而卡顿。
3.3 用户界面渲染:TFT_eSPI库的高效使用技巧
UI渲染是让数据变得直观的关键。使用TFT_eSPI库,我们需要解决两个问题:绘制图形界面和显示天气图标。
1. 基础界面布局规划在开始画图之前,最好在纸上或绘图软件里简单规划一下布局。我的典型布局是:
- 顶部区域(1/3高度):显示当前温度(巨大字体)、当前天气状况(文字描述)、当前湿度。
- 中部区域:显示一个简单的未来24小时温度变化趋势图(用折线图表示)。
- 底部区域(1/2高度):横向并列显示未来5天的预报,每天包括:星期几、天气图标、最高/最低温、降水概率。
2. 绘制文本和图形
#include <TFT_eSPI.h> TFT_eSPI tft = TFT_eSPI(); void setup() { tft.init(); tft.setRotation(1); // 根据屏幕安装方向调整,0-3 tft.fillScreen(TFT_BLACK); // 清屏 } void drawCurrentWeather() { tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setTextDatum(TC_DATUM); // 文本对齐方式:顶部居中 // 绘制当前温度 tft.setFreeFont(&FreeSansBold24pt7b); // 使用大字体 tft.drawString(String(currentTemp, 1) + “°C”, tft.width()/2, 20); // 绘制当前天气图标(需要先将图标转换为位图数组) drawWeatherIcon(currentIcon, tft.width()/2 - 16, 70); // 假设图标是32x32像素 // 绘制湿度 tft.setFreeFont(&FreeSans12pt7b); tft.drawString(“Humidity: ” + String(currentHumidity) + “%”, tft.width()/2, 120); }3. 天气图标的处理——从代码到图片OpenWeatherMap返回的iconCode(如“01d”、“04n”)需要对应到具体的图片上。有几种方法:
- 方法一:在线图标字体。有些库支持,但需要持续联网,且字体文件可能较大。
- 方法二:本地存储位图。最可靠高效的方法。将需要的天气图标(如晴、多云、雨、雪等)转换成
TFT_eSPI库能识别的位图数组。可以使用在线工具(如TFT_eSPI作者提供的“Img2Code”工具)将PNG图片转换成C语言数组。- 步骤:准备一组32x32像素的透明背景PNG图标 -> 使用转换工具生成
.c和.h文件 -> 在Arduino项目中包含这些文件 -> 调用tft.drawXBitmap()函数绘制。 - 优点:显示速度快,不依赖网络。
- 缺点:需要提前准备图标,占用一定的程序存储空间(Flash)。但一套40个图标大约占用几十KB,对于ESP32的4MB Flash来说完全不是问题。
- 步骤:准备一组32x32像素的透明背景PNG图标 -> 使用转换工具生成
// 假设有一个“01d”(晴天)图标的数组 #include “icon_01d.h” void drawWeatherIcon(String iconCode, int x, int y) { if (iconCode == “01d”) { tft.drawXBitmap(x, y, icon_01d_bits, icon_01d_width, icon_01d_height, TFT_WHITE); } else if (iconCode == “04n”) { // … 绘制多云夜晚图标 } // … 其他条件判断 }为了管理方便,可以创建一个图标管理器类,用std::map或数组来映射iconCode和对应的位图数据。
4. 系统优化与深度功能拓展
一个基础版本完成后,我们可以从稳定性、功耗和功能上对其进行深度优化,让它从一个“项目原型”变成一个“可靠产品”。
4.1 低功耗设计与定时唤醒策略
如果设备采用电池供电,低功耗设计就至关重要。ESP32具有强大的深度睡眠(Deep Sleep)功能。
思路:让ESP32大部分时间处于深度睡眠状态,仅由定时器(RTC Timer)或外部信号(如按键)唤醒。唤醒后,它快速连接Wi-Fi、获取数据、更新屏幕,然后再次进入深度睡眠。
实现步骤:
- 连接硬件:将ESP32的
GPIO0(或任何支持唤醒的RTC GPIO)通过一个10kΩ上拉电阻连接到RST引脚。同时,将该GPIO0接地(通过一个按钮或直接连接)可以触发外部唤醒,但我们主要用定时唤醒。 - 修改代码:
#include “esp_sleep.h” void setup() { Serial.begin(115200); // … 初始化屏幕等(注意:深度睡眠前需妥善处理外设) // 获取一次数据并显示 connectToWiFi(); fetchWeatherData(); drawUI(); // 配置定时唤醒(例如:900秒 = 15分钟) esp_sleep_enable_timer_wakeup(900 * 1000000ULL); // 微秒单位 Serial.println(“准备进入深度睡眠…”); delay(100); // 给串口输出一点时间 esp_deep_sleep_start(); // 进入深度睡眠 } void loop() { // 深度睡眠后程序会重启,所以loop()永远不会执行 } - 关键注意事项:
- 电流消耗:在深度睡眠模式下,ESP32本身的电流可以低至10μA左右。但屏幕和其他外围电路的功耗可能更大!必须确保在睡眠前将屏幕完全关闭(
tft.sleep()或切断屏幕电源)。如果使用稳压模块,要选择低静态电流的型号。 - 数据保存:深度睡眠会丢失RAM中的所有数据(RTC慢速内存除外)。如果你希望唤醒后能快速显示上一次的数据(同时获取新数据),需要将
forecast结构体等数据保存到Preferences(NVS)中。在每次更新数据后写入,唤醒后首先读取。 - Wi-Fi连接耗时:每次唤醒都要重新连接Wi-Fi,这是最耗时的部分(约2-5秒)。可以考虑使用Wi-Fi的快速重连(
WiFi.setAutoReconnect(true)),但深度睡眠后通常仍需完整握手。
- 电流消耗:在深度睡眠模式下,ESP32本身的电流可以低至10μA左右。但屏幕和其他外围电路的功耗可能更大!必须确保在睡眠前将屏幕完全关闭(
4.2 增加本地传感器提升数据维度
网络预报是宏观的,加入本地传感器可以获得微环境数据,形成互补。
- 温湿度传感器(如DHT22, SHT30):可以验证和对比网络预报的温湿度。连接简单(GPIO),但注意DHT22读取速度慢且需要专门的库。更推荐I2C接口的SHT30,精度高,速度快。
- 气压传感器(如BMP280, BME280):BME280还能测温湿度。气压数据对于短期天气变化(如“气压下降,可能有雨”)有参考价值。你可以编写简单逻辑,如果检测到本地气压在几小时内持续快速下降,就在屏幕上给出“天气可能转坏”的提示。
- 空气质量传感器(如SGP30, PMS5003):监测室内VOC和CO2(SGP30)或PM2.5(PMS5003)。这对于健康生活很有意义。可以将AQI数据作为一个独立模块显示在屏幕上。
集成示例(以BME280为例):
#include <Wire.h> #include <Adafruit_BME280.h> Adafruit_BME280 bme; void setupSensors() { if (!bme.begin(0x76)) { // 0x76是常见I2C地址 Serial.println(“无法找到BME280传感器!”); } } void readLocalSensors() { float localTemp = bme.readTemperature(); float localHumidity = bme.readHumidity(); float localPressure = bme.readPressure() / 100.0F; // 转换为hPa // 将这些数据与网络数据一起显示或记录 }将本地传感器数据与网络数据并排显示,能立刻让你的天气站显得更专业、更有价值。
4.3 网络稳定性与错误处理增强
在实际部署中,网络不稳定、API服务临时不可用是常态。健壮的程序必须能处理这些异常。
- Wi-Fi连接维护:在
loop()中定期检查WiFi.status(),如果断开,尝试重连。但重试逻辑要有“退避”策略,比如第一次等待5秒,第二次等待10秒,避免频繁重试刷日志。 - API请求失败重试与降级:
bool fetchWeatherWithRetry(int maxRetries = 3) { for (int i = 0; i < maxRetries; i++) { if (fetchWeatherData()) { // 假设fetchWeatherData()成功返回true return true; } Serial.println(“请求失败,第 ” + String(i+1) + “ 次重试…”); delay(2000 * (i+1)); // 退避等待 } Serial.println(“所有重试均失败,使用缓存数据。”); // 触发降级逻辑:显示上一次成功获取的数据,并在屏幕上提示“数据未更新” return false; } - 数据有效性校验:解析JSON后,检查关键字段是否存在、数值是否在合理范围内(例如温度是否在-50到60摄氏度之间)。防止因API返回错误数据导致显示异常。
- 心跳与状态指示:可以定义一个LED(或利用屏幕的一角)来指示系统状态。例如:常亮=正常运行,慢闪=正在获取数据,快闪=网络错误,双闪=传感器错误。
4.4 将数据上传至物联网平台
如果你想让数据在手机端也能查看,或者进行历史记录,可以将数据上传到物联网平台。
- 简单方案:ThingSpeak:一个免费的物联网数据托管平台。你可以创建一个Channel,然后ESP32定期将
currentTemp,currentHumidity等数据通过HTTP POST发送到ThingSpeak的API。平台会自动生成图表。缺点是有上传间隔限制(免费版15秒一次)。 - 进阶方案:自建InfluxDB + Grafana:在家庭服务器(如树莓派)或云服务器上部署InfluxDB(时序数据库)和Grafana(可视化仪表板)。ESP32使用InfluxDB的Line Protocol通过HTTP或UDP发送数据。Grafana可以绘制出非常精美的历史趋势图。这需要一定的运维知识,但可控性和灵活性最强。
- 集成方案:Home Assistant:如果你在用Home Assistant(HA),可以通过ESP32的
ESPHome固件或MQTT协议,将每个数据点(如温度、湿度、预报条目)发布为HA中的一个传感器实体。这样,你就可以在HA的仪表盘上查看天气,并创建自动化(例如:如果明天降水概率大于70%,晚上通过TTS提醒我明天带伞)。
通过MQTT上报数据示例(使用PubSubClient库):
#include <PubSubClient.h> WiFiClient espClient; PubSubClient mqttClient(espClient); void setupMQTT() { mqttClient.setServer(“your_mqtt_broker_ip”, 1883); // MQTT代理地址 } void publishData() { if (!mqttClient.connected()) { reconnectMQTT(); // 重连函数 } String topic = “weather/station1/current/temp”; String payload = String(currentTemp, 2); mqttClient.publish(topic.c_str(), payload.c_str()); // 同样发布湿度、预报等数据 }走到这一步,你的ESP32天气站就已经从一个独立的显示设备,进化成了整个智能家居生态系统中的一个智能节点。