← 返回实战项目

联网天气时钟:OLED 显示时间 + 自动对时 + 实时天气

最后更新 2026-07-01
⏱ 约 14 分钟 🟢 软件/低风险
你将学到
  • 做出一个上电连 WiFi 就自动对时、OLED 上显示当前时间和城市天气的桌面时钟
  • 把 OLED 显示、NTP 对时、HTTP 拉天气三个单练过的知识点接成一条完整链路
  • 学会在设备上把"取到的数据"排版进一块小屏,并想清刷新节奏怎么定
  • 给时钟收个尾(天气定时刷新、断网不崩),体会一个 demo 到一件成品差在哪
🛒 器材清单
器材数量参考
ESP32-S3 开发板1约 25-45 元(以商城实际为准)
0.96 寸 OLED(SSD1306, I2C 接口)1约 6-12 元(以商城实际为准)
面包板1约 5-10 元(以商城实际为准)
杜邦线(母对母,接屏 4 根够)1 小扎约 3-6 元(以商城实际为准)

价格随渠道波动,以购买页实时为准。

想象桌角立着一块巴掌大的小屏。你不用给它对时、不用装 App,插上电、连上家里的 WiFi,它自己就从网上把时间校准到秒,屏上"滴答"跳着当前的年月日时分秒;下面一行还写着你所在城市此刻的天气——多云、24 度。断电再上电,几秒后一切照旧。这就是这个项目要带你做出来的东西:一台会自己对时、还会看天气的桌面时钟。

它不难,一块开发板加一块几块钱的 OLED,一个下午能点亮。但它对你的意义比"又点了块屏"大得多:这是你第一次把三个单练过的招式——OLED 显示、NTP 对时、HTTP 拉数据——真正接成一件能摆桌上天天看的成品。 之前你在 OLED 那节让屏学会显示文字、在 NTP 那节让芯片从网上校时间、在 HTTP 那节让它去公开接口拉数据,但它们各是各的。做完这台时钟,你手里第一次有了一个"联网取数据、再排版上屏"的完整小系统。

这一篇不重复讲 i2c_master 怎么配、esp_netif_sntp 怎么对时、cJSON 怎么解析——那些原理三节讲透了,默认你跑通过。只干一件事:把它们拼成时钟,讲清拼的过程里那些单看一个知识点看不到的坑。


第一步:想清楚要做成什么样,再定选型

动手前先把"成品长什么样"钉死,选型才有依据。时钟就三个行为:

  • 对时:上电连 WiFi 后,自动向 NTP 服务器校准到准确本地时间,不用手动设。
  • 显时:OLED 第一行日期,第二行大字时分秒,每秒跳一下。
  • 看天气:屏下方显示当前城市天气和温度,隔一段时间自动刷一次(进阶)。

行为定了,选型每一步就都有了理由。

屏:为什么是 0.96 寸 I2C 的 SSD1306?

做时钟就选 0.96 寸 I2C 接口的 SSD1306,别的先别碰。 理由有三:只用两根信号线(SDA/SCL),接线极简、新手不易插错;它是这尺寸里最主流的驱动芯片,乐鑫官方有现成组件、社区例子多,出问题好搜;几块钱,烧了不心疼。想要彩色、大屏再换 SPI 的 LCD——那是另一套接线和驱动,这台用不着。买时认准模块印着 SSD1306、接口是 I2C(4 脚:VCC/GND/SDA/SCL),别买成 SPI 的 7 脚版。

显示方案:u8g2 还是 esp_lcd?

这是接线之外你要做的唯一选择题。OLED 那节把两条路讲透了,这里只给结论:做时钟选 u8g2 移植组件。 时钟核心就是"在屏上写几行会变的文字",u8g2 一行 u8g2_DrawStr 就画上去、还自带字库能画中文;esp_lcd 是官方栈、适合上 LVGL 做复杂界面,单纯写几行字偏重。所以下面走 u8g2 主线——它的 ESP-IDF 移植组件有好几个分支,具体组件名、HAL 回调签名以你装的那个组件的 README、example 为准,别把下面的当一字不差的标准答案。

不用传感器?

对,这台时钟的温度不是自己测的,是从天气 API 拉的当前城市气温——不用接任何传感器,一块屏就够。想显示"桌面这里"的真实温湿度,那是进阶加一颗传感器的事,最后一节点。


第二步:接线——OLED 的 I2C 两根线,避开 S3 选脚雷区

OLED 用 I2C 接口,一共四根线,真正传数据的只有两根:一根 SDA(数据)、一根 SCL(时钟),另两根是 VCC/GND 供电。ESP32-S3 的 I2C 引脚可以任意选普通 GPIO,但有一批脚是碰不得的雷区,选脚前先记牢:

🚧 避坑

ESP32-S3 选脚雷区,接线前对照排除:

  • GPIO0 / 3 / 45 / 46:strapping 脚,上电时的电平决定芯片启动模式,接了外设可能让板子刷不进、起不来;
  • GPIO26-37:绝大多数模组内部连着 SPI flash / PSRAM,动了直接死机;
  • GPIO19 / 20:默认是 USB D-/D+,用它俩会断掉 USB 串口,你连日志都看不到;
  • GPIO22 / 23 / 24 / 25:ESP32-S3 上根本不存在这几个号(GPIO 号从 21 直接跳到 26),写了编译不报错、运行时行为诡异。

避开这些,安全区里随手挑两根就行。本项目和 l2-oled 保持一致,选 GPIO8 做 SDA、GPIO9 做 SCL:

OLED(SSD1306, I2C)        ESP32-S3
  VCC ───────────────────── 3V3
  GND ───────────────────── GND
  SDA ───────────────────── GPIO8
  SCL ───────────────────── GPIO9
  • VCC 接 3V3,不是 5V——接开发板的 3V3 脚最稳,和信号电平一致。
  • SDA→GPIO8、SCL→GPIO9,两根挨着好接线,都在安全区。
  • 接之前先看模块上印的丝印! SSD1306 模块管脚顺序不统一,有的 GND VCC SCL SDA、有的 VCC GND SDA SCL——别照图片背顺序,VCC 和 GND 接反有烧屏风险。丝印上写 SDA 的脚才接 GPIO8。

I2C 是"总线",所有设备共用这两根线、靠地址区分。SSD1306 地址通常是 0x3C(少数 0x3D)。这意味着以后想再挂个 I2C 传感器(做真实温湿度),可以和屏共用这对 SDA/SCL,不用另占引脚——这是进阶那步的伏笔。


第三步:分步把代码写出来

我们不一次甩一大段代码,而是分三步长出来,每步都能单独烧进去看到效果——出问题时你才知道是哪一步坏的。这三步刚好对应三块知识:先点亮屏(OLED)、再对上时(NTP)、最后接天气(HTTP)。

步 1:先让 OLED 显示一行字

第一步只验证一件事:I2C 接对了、u8g2 组件配对了、屏能显示。这段就是把 l2-oled 的初始化搬过来,在屏上写一行固定的字。u8g2 移植组件先用包管理器拉:

idf.py add-dependency "u8g2"
# 具体组件名/版本以 components.espressif.com 上你选的那个为准
#include "u8g2.h"
#include "esp_log.h"

static const char *TAG = "clock";
static u8g2_t u8g2;   // 全局屏对象,后面每一步都靠它画

// oled_init() 里做两件事:配 I2C 总线(SDA=GPIO8/SCL=GPIO9)+ 初始化 u8g2。
// 具体的 HAL 回调怎么接 i2c_master、用哪个 u8g2_Setup 宏,
// 以你装的移植组件 README/example 为准——这里给出调用骨架。
static void oled_init(void)
{
    // 1. 用组件提供的 setup 宏把 u8g2 和 SSD1306(0x3C)+I2C 引脚绑上
    //    形如 u8g2_Setup_ssd1306_i2c_128x64_noname_f(&u8g2, U8G2_R0, cb, gpio_and_delay_cb);
    // 2. 设好 SDA=8 / SCL=9(组件通常有个设引脚的接口,按 README 填)
    u8g2_InitDisplay(&u8g2);        // 上电初始化序列
    u8g2_SetPowerSave(&u8g2, 0);    // 0=唤醒屏,1=息屏
    ESP_LOGI(TAG, "OLED 初始化完成");
}

// 封装一个"清屏→写几行→送显"的小函数,后面全靠它,别到处手写这套流程
static void oled_show(const char *line1, const char *line2, const char *line3)
{
    u8g2_ClearBuffer(&u8g2);                       // 清缓冲区
    u8g2_SetFont(&u8g2, u8g2_font_ncenB08_tr);     // 一个清晰的英数字体
    if (line1) u8g2_DrawStr(&u8g2, 0, 12, line1);  // 参数:x, y(基线), 文字
    if (line2) u8g2_DrawStr(&u8g2, 0, 34, line2);
    if (line3) u8g2_DrawStr(&u8g2, 0, 56, line3);
    u8g2_SendBuffer(&u8g2);                        // 一次性把缓冲区推给屏(关键!)
}

void app_main(void)
{
    oled_init();
    oled_show("Weather Clock", "OLED OK", "waiting wifi...");
    ESP_LOGI(TAG, "屏已点亮");
}

烧进去 idf.py build flash monitor你应该看到:OLED 上分三行显示 Weather Clock / OLED OK / waiting wifi...。看到这个,说明 I2C 通路和显示驱动全对——这一步的意义就是把"接线 + u8g2 配置"这两个最容易错的地方先隔离验证掉,后面对时、拉天气全在这块能显示的屏上做,不会再回来怀疑屏。

这里把"清屏→设字体→写几行→送显"封进了 oled_show()。为什么?u8g2先在缓冲区里画、再 SendBuffer 一次推给屏的机制——每处都手写这几行、还容易漏掉最后的 SendBuffer(画了不推,屏上不变),是新手最常见的"屏不更新"故障。封一次,后面只调 oled_show(a, b, c)这就是从"跑通例子"到"写成品"的第一个思维转变:把易错、要反复用的成套操作收进函数。

步 2:连 WiFi + NTP 对时,让屏上跳出真时间

现在把固定的字换成"每秒跳的真实时间"。这一步把 l3-wifi 的联网和 l3-ntp 的对时接进来:连上 WiFi → 向 NTP 对时 → 设好东八区 → 每秒取本地时间画到屏上。把上一篇的 wifi_init_sta()(连同你家 WiFi 名字密码)从 l3-wifi 拿过来放进同一个 main.c

#include <time.h>
#include "esp_netif_sntp.h"   // ESP-IDF 5.x 的 SNTP 封装
#include "esp_sntp.h"

// 联网 + 对时,成功返回 true
static bool time_sync(void)
{
    esp_sntp_config_t cfg = ESP_NETIF_SNTP_DEFAULT_CONFIG("ntp.aliyun.com");
    esp_netif_sntp_init(&cfg);
    if (esp_netif_sntp_sync_wait(pdMS_TO_TICKS(10000)) != ESP_OK) {
        ESP_LOGW(TAG, "NTP 对时超时");
        return false;
    }
    setenv("TZ", "CST-8", 1);   // 中国东八区,用 TZ 字符串而非手填秒数
    tzset();
    ESP_LOGI(TAG, "对时成功");
    return true;
}

void app_main(void)
{
    oled_init();
    oled_show("Weather Clock", "connecting", "wifi...");

    wifi_init_sta();            // 从 l3-wifi 拿过来,连上你家 WiFi
    if (!time_sync()) {
        oled_show("NTP FAIL", "check network", "");
    }

    char date_buf[24], time_buf[16];
    while (1) {
        time_t now = time(NULL);
        struct tm tm;
        localtime_r(&now, &tm);                                 // 按 TZ 换成本地时间
        strftime(date_buf, sizeof(date_buf), "%Y-%m-%d %a", &tm);
        strftime(time_buf, sizeof(time_buf), "%H:%M:%S", &tm);  // 大字显时分秒

        u8g2_ClearBuffer(&u8g2);
        u8g2_SetFont(&u8g2, u8g2_font_6x12_tr);
        u8g2_DrawStr(&u8g2, 0, 12, date_buf);        // 第一行小字:日期
        u8g2_SetFont(&u8g2, u8g2_font_logisoso20_tn); // 第二行大字:时间
        u8g2_DrawStr(&u8g2, 6, 44, time_buf);
        u8g2_SendBuffer(&u8g2);

        vTaskDelay(pdMS_TO_TICKS(1000));             // 一秒刷一次
    }
}

你应该看到:屏先显示 connecting,几秒后跳出真实的日期和一个大字号的时钟,秒位每秒跳一下,走得和你手机上的时间分秒不差。

这里没复用步 1 的 oled_show(),是因为时间那行要用更大字体、和日期不同排版,"三行同字体"套不进来——很正常,封装是为了复用高频的、不是硬把所有情况塞进一个函数。别为了"复用"把一个函数塞成十个 if。

还有个关键点:对时是"一次性"的、显时是"持续"的。 time_sync() 只在开机跑一次,对上后芯片内部时钟就自己走了(time() 每次返回当前秒数),不需要每秒去问 NTP 服务器——那样既费流量又给公共服务器添乱。新手常把"对时"和"读时间"当成一件事、每秒联网校时。记住:校时是校准手表,之后手表自己走。

步 3:接上天气 API,把天气拉下来显示

最后一步,把 l3-http 的本事接进来:向一个天气 API 发 GET 请求,用 cJSON 解出天气和温度,画到屏的第三行。

先说清一件事:天气 API 的具体 URL、参数、返回的 JSON 字段各家不一样,你得按你选的那家 API 文档来。 下面用一个"返回 JSON 里有 weather(天气文字)和 temp(温度)"的假想结构演示解析——换真实 API 时,改的只是请求 URL 和 cJSON_GetObjectItem 取的字段名,流程一字不动。收响应 body 走 l3-http 教的事件回调攒进缓冲区:

#include "esp_http_client.h"
#include "cJSON.h"
#include <string.h>

static char s_resp_buf[1024];   // 攒天气 API 的响应
static int  s_resp_len = 0;

static esp_err_t _http_event_handler(esp_http_client_event_t *evt)
{
    if (evt->event_id == HTTP_EVENT_ON_DATA) {
        // 把一段段数据攒进缓冲区,注意别越界
        if (s_resp_len + evt->data_len < sizeof(s_resp_buf)) {
            memcpy(s_resp_buf + s_resp_len, evt->data, evt->data_len);
            s_resp_len += evt->data_len;
        }
    }
    return ESP_OK;
}

// 拉一次天气,成功时把结果写进 out(如 "Cloudy 24C")
static bool fetch_weather(char *out, size_t out_sz)
{
    s_resp_len = 0;
    memset(s_resp_buf, 0, sizeof(s_resp_buf));

    esp_http_client_config_t config = {
        .url = "http://你的天气API地址?city=beijing",  // 换成你选的 API,含你的 key/城市
        .event_handler = _http_event_handler,
        .timeout_ms = 8000,
    };
    esp_http_client_handle_t client = esp_http_client_init(&config);

    bool ok = false;
    esp_err_t err = esp_http_client_perform(client);   // 同步发出并收完响应
    if (err == ESP_OK && esp_http_client_get_status_code(client) == 200) {
        s_resp_buf[s_resp_len] = '\0';                 // 收尾补零,当字符串用
        cJSON *root = cJSON_Parse(s_resp_buf);
        if (root) {
            cJSON *w = cJSON_GetObjectItem(root, "weather");  // 字段名按你的 API 改
            cJSON *t = cJSON_GetObjectItem(root, "temp");
            if (cJSON_IsString(w) && cJSON_IsNumber(t)) {
                snprintf(out, out_sz, "%s %dC", w->valuestring, t->valueint);
                ok = true;
            }
            cJSON_Delete(root);                        // 解析完一定要 Delete,否则泄漏
        }
    } else {
        ESP_LOGW(TAG, "天气请求失败 err=%d code=%d",
                 err, esp_http_client_get_status_code(client));
    }
    esp_http_client_cleanup(client);                   // client 用完一定要 cleanup
    return ok;
}

app_main 里,对时成功后先拉一次天气,再进显示循环,用一个计数器控制"天气每 10 分钟刷一次、时间每秒刷":

    char weather_buf[24] = "-- ";
    fetch_weather(weather_buf, sizeof(weather_buf));   // 开机先拉一次

    int tick = 0;
    while (1) {
        // ...(步 2 里那段画日期+时间的代码原样保留)...
        u8g2_SetFont(&u8g2, u8g2_font_6x12_tr);
        u8g2_DrawStr(&u8g2, 0, 62, weather_buf);       // 第三行:天气
        u8g2_SendBuffer(&u8g2);

        if (++tick >= 600) {                           // 600 秒 = 10 分钟刷一次天气
            tick = 0;
            fetch_weather(weather_buf, sizeof(weather_buf));
        }
        vTaskDelay(pdMS_TO_TICKS(1000));
    }

你应该看到:第一行日期、中间大字时间每秒跳、最下面一行天气和温度(如 Cloudy 24C);十分钟后天气那行悄悄更新一次。一台会自己对时、还会看天气的时钟就整个跑起来了。

为什么天气 10 分钟才刷一次、不像时间每秒刷?因为天气变化是分钟级甚至小时级的,秒级去拉纯属浪费——耗流量、给 API 添请求量(很多免费 API 有每天调用上限,秒刷几天就封),屏上数字也不会更准。刷新节奏要匹配数据本身的变化速度:时间秒刷、天气分钟刷、配置类数据可能几小时才拉一次。

回头看,你没写多少新东西:显示是 l2-oled 的、对时是 l3-ntp 的、拉数据是 l3-http 的,你干的是把它们按"开机对一次时 → 循环里秒刷时间、分钟刷天气"的节奏组织起来、再排版进一块小屏——这个"组织 + 排版",就是 project 比 guide 多出来的那层功夫。系统看进阶通信见 L2 阶梯


第四步:调试——对不上就查这张表

分步烧的好处是,哪一步出问题你已经缩小了范围:屏不亮是步 1 的事、时间不对是步 2、天气不显是步 3。真出了岔子,照这张表查:

现象 最可能的原因 怎么办
OLED 完全不亮 VCC/GND 接反 / SDA-SCL 接反或接错脚 / 地址不是 0x3C 对照模块丝印重接,别背图片顺序;用 i2c_master_probe 探地址,是 0x3D 就改宏
屏亮但花屏、显示错乱 u8g2 的 setup 宏选错型号 / 分辨率不符(128x64 vs 128x32) 确认买的是 128x64 还是 128x32,选对应的 u8g2_Setup_*
画了字屏上不变 DrawStrSendBuffer 确认每次画完都调了 u8g2_SendBuffer;用封好的 oled_show() 就不会漏
板子刷不进 / 一直重启 信号脚踩了 strapping(GPIO0/3/45/46)或 flash 区(26-37) 把 SDA/SCL 换到 GPIO8/9 这类安全脚,对照第二步雷区表
串口没日志 / 一连就断 用了 GPIO19/20(USB 脚)当 I2C 脚 换脚,这两根是 USB D-/D+
时间一直是 1970 / 对不上 WiFi 没连上 / NTP 服务器不通 / 等待太短 先确认 wifi_init_sta 真连上(打日志看 IP);换 ntp.aliyun.comsync_wait 给到 10 秒
时间对但差了 8 小时 没设 TZ 或没 tzset() 确认 setenv("TZ","CST-8",1) 后调了 tzset(),且在读时间之前设
天气那行一直是 -- API 地址/key 错 / 字段名对不上 / 返回不是 200 看串口打的 errcode;把 s_resp_buf 打出来,核对 JSON 里字段名和代码里 GetObjectItem 的名字一致
中文天气显示成方块/乱码 用的英数字体不含中文,或没按 UTF-8 画中文 要显示中文得用 u8g2 的中文字库并用 DrawUTF8;简单起见先让 API 返回英文,或天气用图标
跑一阵子屏卡住不动 天气请求阻塞了主循环、或响应缓冲区越界 fetch_weather 是同步阻塞的,网络慢时会卡住秒刷——进阶里把它挪到独立任务;确认缓冲区写入有 < sizeof 判断
💡 提示

一次只改一处再烧。屏花了同时又改字体、又改型号宏、又改引脚,还是不对,你根本分不清是哪个改动的锅。分步烧、单点改,是硬件调试省时间的铁律。


第五步:从"能跑的 demo"做成"像样的产品"

到这它已经是台能用的时钟了。但"能用"和"像个产品"之间,还差几步——这几步正好通向后面的阶梯,先给你指条路。

断网也别崩,天气拉不到也照走时间

现在如果天气 API 超时,fetch_weather 会卡住那 8 秒、时间停一下才继续——因为它是同步阻塞的,卡在主循环里。产品化的做法是把天气请求挪进一个独立的 FreeRTOS 任务:时间在主循环里稳稳每秒刷,天气任务在后台单独去拉、拉到再更新那行。这样网卡了顶多天气那行不更新,时间照走不误。这就是 触摸台灯项目 里学的"多行为拆成独立任务",在这里又用上一次。

加一颗传感器,显示"桌面这里"的真实温湿度

天气 API 给的是"城市"温度,不是你桌上的。想显示身边真实温湿度,加一颗 I2C 传感器(如 SHT30/AHT20),和 OLED 共用那对 SDA/SCL——第二步埋的伏笔就为这步:I2C 是总线,屏和传感器挂同一对线、靠不同地址区分,代码里多 add_device 一个从设备即可。屏上就能同时有"城市天气"和"桌面实测"两行。

做成真正的成品

面包板上一团线只能算原型。想变成能放桌上天天用、甚至送人的东西,要走产品化那条路:稳定供电(别再靠 USB 线吊着)、电路从面包板挪到洞洞板或画一块小 PCB、配个 3D 打印或亚克力外壳把屏嵌进去、固件量产烧录。这些是 L5 阶梯 的活儿,多做几个项目、手感稳了再来啃——这台时钟和台灯一样,都很适合做成第一件实体产品。


小结 · 你做出了什么、下一步去哪

  • 你做出了一台上电自动对时、OLED 显示日期时间、还联网看天气的桌面时钟,从选型、避雷区 I2C 接线、分三步写码到调试,走完了一件成品的全流程。
  • 你第一次把 OLED 显示l2-oled)、NTP 对时l3-ntp)、HTTP 拉数据l3-http)三个单练的知识点,按"开机对一次时、循环里秒刷时间分钟刷天气"的节奏接成完整链路,还把数据排版进了小屏——这"组织 + 排版"的功夫,就是 project 比 guide 多出来的那层。
  • 你学到几个从 demo 到成品的关键思维:把易错的成套操作(清屏/画/送显)封进函数、校时是一次性的而读时间是持续的、刷新节奏要匹配数据变化速度、阻塞的网络请求要挪出主循环、I2C 从设备可共用一对总线。

下一步:想让时钟"说话"报时、能语音问天气,那就是把这套联网取数据的本事接到语音上,通向 小智那类 AI 硬件项目;想系统补进阶通信的基础,顺着 L3 阶梯 往下走。回看全部实战项目见项目总览。这台时钟和台灯一样,是你所有硬件项目里最该留着的入门作——它够简单,又把"联网设备"该有的骨架五脏俱全地演示了一遍。

📄 来源 / 自校链接

本文为公开资料整理,非亲测。关键参数与代码请结合实物与下列官方来源验证。

内容有错、看不懂、或想看下一期?告诉我们 →

本文为公开资料的学习整理,非亲测。涉接线/花钱/合规的步骤请结合实物与官方最新资料验证,风险自负。见免责声明