之前一直想做一块放在桌上的小屏,不用电脑、不依赖任何客户端,就能看到当前位置的天气和空气质量。Wio Terminal 自带屏幕、自带 RTL8720 Wi-Fi、Arduino IDE 直接烧,正好适合这个需求。
最终的样子是:通电就联网,大约每 10 分钟自己跑 HTTPS 去 Open-Meteo 拉一次天气和空气质量,再画到自带的 320×240 TFT 上。A 键手动刷新(最短 60 秒一次,防止被 API 限流),B 键或者摇杆按下切换总览 / 详情。整个过程没有任何 PC 端程序,USB 只用来供电和调试。
整体架构
代码全部放在 weather_wifi.ino 这一个工程里,分成四块:
| weather_wifi.ino | 入口、setup()/loop()、Wi-Fi 与 HTTP 流程、按键扫描 |
| weather_data.h | WeatherData / AirData 结构、JSON 解析、HTTP Date 头转时间戳 |
| weather_ui.h | 颜色常量、所有绘图函数、drawUI() |
| secrets.h | Wi-Fi SSID/密码、ALLOW_PUBLIC_HTTP_FALLBACK 开关、城市坐标 |
| root_ca.h | ISRG Root X1 根证书字符串 |
主循环 loop() 同时干三件事:按键扫描 + 联网状态判断 + 决定要不要再拉一次数据。屏幕只在 dirty 标记置位或超过 1 秒没刷时才重绘,避免每帧都画。
数据流:
Wi-Fi 连上 → fetchStage=1 → fetchData(weather) → fetchStage=2 → fetchData(air) → fetchStage=0 之后每 10 分钟循环:失败时改为 60 秒重试
UI 只有两个页面,details 标志切换;不管哪一页都共用同一个 320×240 的 TFT_eSprite 离屏缓冲,最后 pushSprite(0,0) 一次性送出。背景用 4×4 Bayer 抖动画一个深蓝渐变,RGB332 也能看出层次。
硬件与依赖
板子:Seeeduino Wio Terminal(ATSAMD51 + RTL8720)
Arduino IDE + Seeed SAMD Boards 1.8.6
库:TFT_eSPI、rpcWiFi 1.1.0 + rpcUnified、ArduinoJson 6.21.5
RTL8720 固件要支持 rpcWiFi,Seeed 官方建议 ≥ 2.0.2
串口调试可选 115200
固件里只用了 Wio Terminal 的三个按键 WIO_KEY_A、WIO_KEY_B、WIO_5S_PRESS,分别对应刷新、切详情、切详情。所有按键都配 INPUT_PULLUP,按下读 LOW。
关键实现
1. HTTPS + 公共 HTTP 回退
Open-Meteo 的接口本身支持 HTTPS,也推荐用 HTTPS。但是 RTL8720 固件跑 TLS 的时候实测会在握手阶段拿到 EOF,所以代码里做了一个软降级:
client.setCACert(ROOT_CA);
http.setConnectTimeout(12000);
http.setTimeout(12000);
http.setReuse(false);
http.useHTTP10(true); // HTTP/1.0,避免 chunked 让 ArduinoJson 解码失败
if (http.begin(client, url)) {
const char* headers[] = { "Date" };
http.collectHeaders(headers, 1);
int response = http.GET();
if (response < 0 && ALLOW_PUBLIC_HTTP_FALLBACK) {
http.end();
client.stop();
url.replace("https://", "http://");
http.setConnectTimeout(8000);
http.setTimeout(8000);
http.begin(publicClient, url);
http.collectHeaders(headers, 1);
response = http.GET();
}降级走的是 http://api.open-meteo.com,不带证书校验。secrets.h 里 ALLOW_PUBLIC_HTTP_FALLBACK 可以关掉,关掉之后 TLS 失败就直接报错。需要明白的是:回退路径下没有 HTTPS 身份验证,只发了坐标和字段名,没有发送 Wi-Fi 密码或账号。
2. JSON 过滤解析
直接喂 deserializeJson(doc, *stream) 给 4 KB 的 DynamicJsonDocument 会爆 RAM,所以加了一个 384 字节的 StaticJsonDocument filter 只放白名单字段:
StaticJsonDocument<384> filter;
filter["current"] = true;
if (!airRequest) {
filter["hourly"]["time"] = true;
filter["hourly"]["precipitation_probability"] = true;
}
DynamicJsonDocument doc(4096);
WiFiClient* stream = http.getStreamPtr();
stream->Stream::setTimeout(12000);
DeserializationError error = deserializeJson(doc, *stream,
DeserializationOption::Filter(filter));http.useHTTP10(true) 是关键一步,HTTP/1.1 默认 chunked,对流式 JSON 解析不友好;切到 HTTP/1.0 之后服务端会按长度一次性返回。
3. 不依赖 RTC 的本地时钟
Wio Terminal 没有电池 RTC,要显示时间就只能从网络拿,又不能真的去查 NTP。代码里用 Open-Meteo 响应里的 Date 头:
const char* headers[] = { "Date" };
http.collectHeaders(headers, 1);
int response = http.GET();
if (response == 200) {
uint32_t serverTime = parseHttpDate(http.header("Date"));
if (serverTime) setClock(serverTime);
}parseHttpDate() 自己写了一个 RFC 1123 解析,把 "Sat, 11 Oct 2025 03:14:12 GMT" 转成 epoch。tickClock() 用 millis() 增量累加,clockRemainder 保留余数,保证长时间运行误差不会累积成几秒。屏幕右下角显示的 timeText() 就是把 UTC 加上 8 小时转成 HH:MM。
吉隆坡是 UTC+8,所以显示的是当地时间,不需要再算时区。
4. 数据新鲜度判断
Open-Meteo 的天气和空气质量更新频率不一样,所以代码里对两者分别设置过期阈值:
| 天气温度/湿度/降水 | 1 小时 |
| US AQI / PM2.5 | 3 小时 |
过期后屏幕上相应数字会用 SOFT 灰色而不是白色,底部状态条会显示 STALE DATA,告诉用户这是缓存值。
数据缺失一律显示 --,不显示 0,避免把"接口没给"误读成"零毫米降水"或"零污染"。
5. 概率降水的有效区间
Open-Meteo 的 precipitation_probability 是 hourly 的,utcClock 没拿到的时候强行匹配会取到很久以前的小时。
if (utcClock && times.size() == probabilities.size()) {
for (size_t i = 0; i < times.size(); i++) {
uint32_t end; float probability;
if (timestampIn(times[i], end) && end >= utcClock
&& end - utcClock <= 3600
&& numberIn(probabilities[i], 0, 100, probability)) {
next.probability = probability;
next.probabilityEnd = end;
break;
}
}
}只匹配 end >= utcClock && end - utcClock <= 3600 的那一小时,匹配上的就是"接下来 60 分钟内会结束的那个时段"。过期后 UI 直接不显示 xx% chance。
6. 自绘天气图标
8 KB 内存下塞不进大位图,所以 weatherIcon() 全部用 fillCircle、drawLine、fillTriangle 现画。weather_code==0 的时候画太阳或月亮,叠加云层;code>=95 加闪电三角形;(code>=71 && code<=77) || code==85 || code==86 画雪花线;code>=51 画雨丝。
0 → Clear sky / Clear night 1~2 → 多云 + 太阳 3 → 阴天 45 / 48 → 阴天 + 三条横线(雾) 51~55 → 多云 + 雨丝(毛毛雨) 56~57, 66~67→ 冻雨 61~65 → 雨 71~77, 85~86→ 雪 80~82 → 阵雨 95+ → 雷暴
7. US AQI 配色条
uint16_t pollutionColor(float aqi) {
if (aqi < 0) return SOFT;
if (aqi <= 50) return rgb(67, 220, 151); // Good
if (aqi <= 100) return rgb(241, 204, 90); // Moderate
if (aqi <= 150) return rgb(251, 155, 75); // Sensitive
if (aqi <= 200) return rgb(250, 95, 108); // Unhealthy
if (aqi <= 300) return rgb(181, 115, 234); // Very unhealthy
return rgb(209, 73, 136); // Hazardous
}底部还有一条 6 段的色带,对应 25 / 75 / 125 / 175 / 250 / 350 六个档位,让用户看一眼就知道当前值落在哪一档。
测试过程与结果
烧录之后,第一次启动会先看到下面这张图(还没有拉到数据时的"等待"状态):

城市名右上角的绿点亮代表 Wi-Fi 已连,左下角 Data age W:-- A:-- 因为 utcClock 还没设。等到第一次 fetchData() 拿到 Date 头,时间就开始走了。
串口 115200 打开能拿到这些关键日志:
WIFI_OK IP=192.168.x.x WEATHER_HTTP 200 WEATHER_OK TEMP=28.4 HUMIDITY=78 AIR_HTTP 200 AIR_OK US_AQI=42 PM25=12.6
如果 TLS 握手失败并触发回退,会先看到一行 PUBLIC_HTTP_FALLBACK,后面再跟 WEATHER_HTTP 200,时间戳和数值一样正常。
屏幕截图脚本
固件里专门留了一个 S 命令做 RGB565 帧导出,方便不带 SD 卡的情况下截屏:
void captureFrame() {
drawUI();
Serial.println("FRAME 320 240");
for (int y = 0; y < 240; y++) {
for (int x = 0; x < 320; x++) {
uint16_t p = screen.readPixel(x, y);
// 写 4 个 hex 字符进 row
}
Serial.write((const uint8_t*)row, sizeof(row));
while (!Serial.available() && millis() - start < 3000) delay(1);
if (Serial.read() != 'K') return;
}
}每发一行要等 PC 回 K 才继续,简单的手抖协议但够用。PC 端 capture-screen.ps1 默认端口 COM11,把 4 字符十六进制还原成 RGB565,再展开成 PNG 写到 screen-preview.png。
param([string]$Port='COM11',[string]$OutputPath=(Join-Path $PSScriptRoot 'screen-preview.png'))
$serial=[IO.Ports.SerialPort]::new($Port,115200)
$serial.ReadBufferSize=262144
# ...
do {$line=$serial.ReadLine().Trim()}while($line -ne 'FRAME 320 240')
for($y=0;$y -lt 240;$y++){
$rows[$y]=$serial.ReadLine().Trim()
if($rows[$y].Length -ne 1280){throw "Incomplete row $y"}
$serial.Write('K')
}跑 powershell -ExecutionPolicy Bypass -File capture-screen.ps1 -Port COM11 就能拿到当前屏幕的快照。

踩坑记录
TLS EOF:RTL8720 固件跟 Open-Meteo 的 HTTPS 握手有时会拿到 EOF,没有更多错误细节。开了 ALLOW_PUBLIC_HTTP_FALLBACK 之后体验顺滑很多,但是代价是 HTTP 那条路没有证书校验。生产环境可以关掉回退,等 RTL8720 固件修。
Chunked + ArduinoJson:HTTP/1.1 默认 chunked 传输,ArduinoJson 流式解析会失败。useHTTP10(true) 是必须的。
读屏的 readPixel 慢:每像素调一次函数会很慢,截图大概要十几秒。一次性截图够用,做视频就别想了。
SPIFFS/SD 都没用:要留调试插桩、要看现场画面,最终选了"导出 RGB565 + PC 还原"。比 SD 卡少一个 BOM,比 SPIFFS 少一次擦写。
Date 头解析时区:必须按 GMT 处理。parseHttpDate() 里专门拦了 strcmp(zone,"GMT"),否则有些 CDN 会返回 +0000 之类的写法直接判失败。
总结
目前这个项目已经基本实现了最初设想的功能:设备可以独立运行,只需通过 USB 或充电器供电,就能自动联网、定时更新天气和 AQI 数据,并通过按键进行操作。对于缺失的数据,屏幕会显示 --;如果天气或 AQI 信息过期,也会通过颜色变化和提示提醒用户。即使没有 RTC 时钟模块,设备也能根据服务器返回的时间戳显示当地时间,同时还支持通过串口导出屏幕截图,方便后续调试和分享。接下来,可以考虑增加多城市切换、过去 24 小时 AQI 折线图,以及进一步优化 TLS 连接的稳定性。如果以后需要接入 WPA2-Enterprise 等更复杂的网络,也需要进一步测试现有 Wi-Fi 方案是否支持。
题外话:要不是印尼整天烧森林,谁闲的没事干去关系AQI指数
我要赚赏金
