Đồng hồ và dự báo thời tiết trên OLED: ESP32 lấy dữ liệu Open-Meteo qua HTTP API
ESP32 lấy giờ NTP và thời tiết Open-Meteo qua HTTPS mỗi 15 phút, đọc JSON bằng ArduinoJson, hiện nhiệt độ, độ ẩm, gió và dự báo 3 ngày trên OLED 0.96 inch.
Nhiều dịch vụ trên Internet cho đọc dữ liệu qua một địa chỉ web (HTTP API) và trả về JSON: thời tiết, tỷ giá, giá vàng, chất lượng không khí. Bài này dùng Open-Meteo, dịch vụ dự báo thời tiết không cần đăng ký, không cần khóa, để làm một màn hình đặt bàn: ESP32 lấy giờ qua NTP, cứ 15 phút hỏi Open-Meteo một lần, rồi hiện trên OLED 0.96 inch nhiệt độ, độ ẩm, gió, trời nắng hay mưa và dự báo 3 ngày. Cách gọi API, đọc JSON và hiển thị trong bài dùng lại được cho các dịch vụ khác.
1. ESP32 lấy dữ liệu từ đâu

- Giờ: lấy qua NTP như bài đồng hồ Internet với ESP32 và OLED; ESP32 tự đồng bộ lại định kỳ.
- Thời tiết: một yêu cầu HTTPS tới
api.open-meteo.com/v1/forecast, trong địa chỉ có tọa độ và danh sách số liệu cần lấy. Phản hồi là JSON khoảng 0,8 KB. - Điều khoản: gói miễn phí của Open-Meteo dùng cho mục đích không thương mại, dưới 10.000 lần gọi mỗi ngày; dữ liệu theo giấy phép CC BY 4.0. Hỏi 15 phút một lần là 96 lần mỗi ngày, rất xa giới hạn. Đưa dữ liệu lên nơi người khác xem (trang web, màn hình ở cửa hàng) thì ghi nguồn Open-Meteo.com; làm sản phẩm bán thì cần gói trả phí.
2. Chuẩn bị
- ESP32 DevKit V1 30 chân.
- Màn hình OLED 0.96 inch SSD1306 I2C (loại một màu hay hai màu vàng - xanh đều được).
- Breadboard, dây cắm, dây USB nạp code.
3. Sơ đồ nối

Thứ tự 4 chân trên module OLED có thể khác theo lô hàng (VCC và GND đổi chỗ): đọc chữ in trên mạch trước khi cắm.
4. Chương trình
Cần ba thư viện cài từ Library Manager: Adafruit SSD1306 (tự cài kèm Adafruit GFX và Adafruit BusIO) và ArduinoJson (Benoit Blanchon, bản 7). Trước khi nạp, sửa tên và mật khẩu Wi-Fi, và tọa độ VI_DO, KINH_DO của nơi muốn xem (mở Google Maps, nhấn giữ hoặc bấm chuột phải vào vị trí để thấy hai số này).
// PYWORLD.VN - Đồng hồ và dự báo thời tiết trên OLED 0.96 inch: ESP32 lấy giờ NTP và thời tiết Open-Meteo qua HTTP
// OLED SSD1306 I2C: VCC 3V3, GND, SDA GPIO21, SCL GPIO22, địa chỉ 0x3C; hai trang tự đổi mỗi 5 giây
// Thư viện: Adafruit SSD1306, Adafruit GFX, ArduinoJson 7; Open-Meteo không cần khóa, miễn phí cho mục đích không thương mại
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <Wire.h>
#include <time.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <ArduinoJson.h>
const char *TEN_WIFI = "TEN_WIFI";
const char *MAT_KHAU_WIFI = "MAT_KHAU_WIFI";
const float VI_DO = 10.78, KINH_DO = 106.70; // Tọa độ nơi xem thời tiết (ví dụ: trung tâm TP.HCM)
const unsigned long CHU_KY_THOI_TIET_MS = 15UL * 60 * 1000; // 15 phút một lần: 96 lần mỗi ngày
const unsigned long THU_LAI_MS = 60000; // Lỗi thì 1 phút sau thử lại
const unsigned long DOI_TRANG_MS = 5000;
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
struct ThoiTiet {
bool co = false;
float nhietDo = 0, gio = 0;
int doAm = 0, ma = 0;
char ngay[3][11]; // "2026-10-25"
float cao[3], thap[3];
int mua[3], maNgay[3]; // Khả năng mưa (%), mã thời tiết từng ngày
} tt;
const char *THU_NGAN[7] = {"CN", "T2", "T3", "T4", "T5", "T6", "T7"};
const char *THU_DAI[7] = {"Chu nhat", "Thu hai", "Thu ba", "Thu tu", "Thu nam", "Thu sau", "Thu bay"};
// Mã thời tiết WMO mà Open-Meteo trả về, đổi sang chữ không dấu (bộ chữ mặc định của OLED không có dấu tiếng Việt)
const char *moTa(int ma) {
if (ma == 0) return "Troi quang";
if (ma == 1) return "It may";
if (ma == 2) return "May rai rac";
if (ma == 3) return "Nhieu may";
if (ma == 45 || ma == 48) return "Suong mu";
if (ma >= 51 && ma <= 57) return "Mua phun";
if (ma == 61) return "Mua nho";
if (ma == 63) return "Mua vua";
if (ma == 65) return "Mua to";
if (ma == 66 || ma == 67) return "Mua bang";
if (ma >= 71 && ma <= 77) return "Tuyet";
if (ma == 80) return "Mua rao nhe";
if (ma == 81) return "Mua rao";
if (ma == 82) return "Mua rao to";
if (ma == 85 || ma == 86) return "Mua tuyet";
if (ma == 95) return "Dong";
if (ma == 96 || ma == 99) return "Dong, mua da";
return "Khong ro";
}
// Thứ trong tuần của một ngày (0 = Chủ nhật), cách tính của Sakamoto
int thuCuaNgay(int y, int m, int d) {
static const int t[12] = {0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4};
if (m < 3) y -= 1;
return (y + y / 4 - y / 100 + y / 400 + t[m - 1] + d) % 7;
}
bool layThoiTiet() {
char url[320];
snprintf(url, sizeof(url),
"https://api.open-meteo.com/v1/forecast?latitude=%.4f&longitude=%.4f"
"¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m"
"&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max"
"&timezone=Asia%%2FHo_Chi_Minh&forecast_days=3",
VI_DO, KINH_DO);
WiFiClientSecure client;
client.setInsecure(); // Mã hóa nhưng không kiểm chứng chỉ máy chủ, xem giải thích trong bài
HTTPClient http;
http.setTimeout(10000);
if (!http.begin(client, url)) return false;
int ma = http.GET();
if (ma != HTTP_CODE_OK) {
Serial.printf("Open-Meteo loi HTTP %d\n", ma);
http.end();
return false;
}
String noiDung = http.getString();
http.end();
JsonDocument doc;
DeserializationError loi = deserializeJson(doc, noiDung);
if (loi) {
Serial.printf("Doc JSON loi: %s\n", loi.c_str());
return false;
}
JsonObject c = doc["current"];
JsonObject d = doc["daily"];
if (c.isNull() || d.isNull()) {
Serial.println("Thieu muc current/daily trong JSON");
return false;
}
tt.nhietDo = c["temperature_2m"] | 0.0f;
tt.doAm = c["relative_humidity_2m"] | 0;
tt.ma = c["weather_code"] | -1;
tt.gio = c["wind_speed_10m"] | 0.0f;
for (int i = 0; i < 3; i++) {
strlcpy(tt.ngay[i], d["time"][i] | "", sizeof(tt.ngay[i]));
tt.cao[i] = d["temperature_2m_max"][i] | 0.0f;
tt.thap[i] = d["temperature_2m_min"][i] | 0.0f;
tt.mua[i] = d["precipitation_probability_max"][i] | 0;
tt.maNgay[i] = d["weather_code"][i] | -1;
}
tt.co = true;
Serial.printf("Thoi tiet luc %s: %.1f C, do am %d%%, gio %.1f km/h, %s\n", (const char *)(c["time"] | ""), tt.nhietDo,
tt.doAm, tt.gio, moTa(tt.ma));
for (int i = 0; i < 3; i++) {
Serial.printf(" %s: %.1f-%.1f C, kha nang mua %d%%, %s\n", tt.ngay[i], tt.thap[i], tt.cao[i], tt.mua[i], moTa(tt.maNgay[i]));
}
return true;
}
void inGiua(int y, const char *chuoi, int co) {
oled.setTextSize(co);
oled.setCursor((128 - 6 * co * (int)strlen(chuoi)) / 2, y);
oled.print(chuoi);
}
void veTrangHienTai(const struct tm &t) {
char s[24];
snprintf(s, sizeof(s), "%02d:%02d:%02d", t.tm_hour, t.tm_min, t.tm_sec);
inGiua(0, s, 2);
snprintf(s, sizeof(s), "%s %02d/%02d/%04d", THU_DAI[t.tm_wday], t.tm_mday, t.tm_mon + 1, t.tm_year + 1900);
inGiua(18, s, 1);
if (!tt.co) {
inGiua(40, "Dang lay thoi tiet...", 1);
return;
}
snprintf(s, sizeof(s), "%.1fC", tt.nhietDo);
oled.setTextSize(2);
oled.setCursor(0, 30);
oled.print(s);
oled.setTextSize(1);
snprintf(s, sizeof(s), "Am %d%%", tt.doAm);
oled.setCursor(68, 30);
oled.print(s);
snprintf(s, sizeof(s), "Gio %d km/h", (int)(tt.gio + 0.5f));
oled.setCursor(68, 40);
oled.print(s);
inGiua(54, moTa(tt.ma), 1);
}
void veTrangDuBao() {
inGiua(0, "Du bao 3 ngay", 1);
if (!tt.co) {
inGiua(30, "Chua co du lieu", 1);
return;
}
char s[24];
for (int i = 0; i < 3; i++) {
int y = 16 + i * 16; // Bắt đầu từ hàng 16: màn hai màu có dải vàng ở 16 hàng trên cùng
int nam = 0, thang = 0, ngay = 0;
sscanf(tt.ngay[i], "%d-%d-%d", &nam, &thang, &ngay);
snprintf(s, sizeof(s), "%s %02d/%02d %2.0f-%2.0fC %3d%%", THU_NGAN[thuCuaNgay(nam, thang, ngay)], ngay, thang, tt.thap[i],
tt.cao[i], tt.mua[i]);
oled.setTextSize(1);
oled.setCursor(0, y);
oled.print(s);
oled.setCursor(18, y + 8);
oled.print(moTa(tt.maNgay[i]));
}
}
void setup() {
Serial.begin(115200);
Wire.begin(21, 22);
if (!oled.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println("Khong tim thay OLED o dia chi 0x3C");
}
oled.setTextColor(SSD1306_WHITE);
oled.clearDisplay();
inGiua(28, "Dang ket noi Wi-Fi", 1);
oled.display();
WiFi.mode(WIFI_STA);
WiFi.begin(TEN_WIFI, MAT_KHAU_WIFI);
configTzTime("ICT-7", "pool.ntp.org", "time.google.com"); // Giờ Việt Nam UTC+7, không đổi giờ mùa hè
Serial.println("Dang ket noi Wi-Fi...");
}
void loop() {
static bool daBaoIp = false, daBaoGio = false;
static unsigned long lanLay = 0, choLay = 0;
static int giayDaVe = -1, trangDaVe = -1;
if (WiFi.status() == WL_CONNECTED) {
if (!daBaoIp) {
daBaoIp = true;
Serial.printf("Da vao Wi-Fi, IP %s\n", WiFi.localIP().toString().c_str());
}
if (lanLay == 0 || millis() - lanLay > choLay) {
lanLay = millis();
if (lanLay == 0) lanLay = 1;
bool ok = layThoiTiet();
choLay = ok ? CHU_KY_THOI_TIET_MS : THU_LAI_MS;
if (!ok) Serial.println("Thu lai sau 60 giay");
}
}
struct tm t;
if (!getLocalTime(&t, 0)) { // Chưa đồng bộ được giờ qua NTP
if (trangDaVe != 9) {
trangDaVe = 9;
oled.clearDisplay();
inGiua(28, "Cho dong bo gio...", 1);
oled.display();
}
delay(50);
return;
}
if (!daBaoGio) {
daBaoGio = true;
Serial.printf("Dong bo gio: %02d/%02d/%04d %02d:%02d:%02d\n", t.tm_mday, t.tm_mon + 1, t.tm_year + 1900, t.tm_hour,
t.tm_min, t.tm_sec);
}
int trang = (millis() / DOI_TRANG_MS) % 2;
if (t.tm_sec != giayDaVe || trang != trangDaVe) { // Chỉ vẽ lại khi đổi giây hoặc đổi trang
giayDaVe = t.tm_sec;
trangDaVe = trang;
oled.clearDisplay();
if (trang == 0) veTrangHienTai(t);
else veTrangDuBao();
oled.display();
}
delay(50);
}
5. Gọi API và đọc JSON

Địa chỉ yêu cầu
Địa chỉ dựng bằng snprintf: current= liệt kê số liệu hiện tại, daily= liệt kê số liệu theo ngày, timezone=Asia/Ho_Chi_Minh để ngày giờ trong JSON theo giờ Việt Nam (dấu / viết thành %2F, trong snprintf phải gõ %%2F). Muốn thêm số liệu (ví dụ chỉ số UV), thêm tên biến theo trang tài liệu của Open-Meteo vào sau current= hoặc daily=, rồi đọc thêm khóa đó trong JSON. Có thể dán địa chỉ vào trình duyệt máy tính để xem trước JSON.
HTTPS và setInsecure()
Open-Meteo dùng HTTPS. client.setInsecure() vẫn mã hóa đường truyền nhưng không kiểm tra chứng chỉ của máy chủ; với dữ liệu thời tiết công khai, đây là mức chấp nhận được và không phải cập nhật chứng chỉ khi máy chủ đổi. Với dữ liệu quan trọng, nạp chứng chỉ gốc của máy chủ bằng client.setCACert().
Đọc JSON bằng ArduinoJson
Phản hồi được đọc vào String bằng http.getString() rồi đưa vào deserializeJson. Cách viết c["temperature_2m"] | 0.0f lấy giá trị nếu có khóa đó, không có thì dùng 0, nên JSON thiếu một mục không làm chương trình treo. Các mục theo ngày là mảng: phần tử 0 là hôm nay, 1 là ngày mai.
Mã thời tiết
weather_code là mã thời tiết theo chuẩn của Tổ chức Khí tượng Thế giới (WMO): 0 trời quang, 1–3 ít đến nhiều mây, 45 và 48 sương mù, 51–57 mưa phùn, 61–65 mưa, 80–82 mưa rào, 95–99 dông. Hàm moTa() đổi mã sang chữ để hiện trên màn hình.
6. Kết quả trên màn hình

- Trang 1: giờ, thứ ngày, nhiệt độ, độ ẩm, tốc độ gió và mô tả thời tiết hiện tại.
- Trang 2: dự báo 3 ngày: nhiệt độ thấp nhất – cao nhất, khả năng mưa cao nhất trong ngày, mô tả.
- Bộ chữ mặc định của thư viện Adafruit GFX không có dấu tiếng Việt, nên chữ trên màn hình viết không dấu. Muốn có dấu cần dùng bộ chữ riêng có dấu.
- Trên màn hai màu, 16 hàng trên cùng màu vàng: giờ và tiêu đề trang nằm gọn trong dải này.

7. Lưu ý
- Đồng hồ đứng một lúc khi lấy dữ liệu: lệnh gọi HTTPS chờ tới khi xong (thường dưới 2 giây), trong lúc đó màn hình không đổi giây. Mất Internet thì chờ tối đa 10 giây (
setTimeout), rồi 1 phút sau thử lại. - Số "hiện tại" của Open-Meteo là số liệu mô hình theo khung 15 phút (trong JSON có
"interval": 900), không phải đo tại nhà. Muốn số đo tại chỗ, gắn thêm cảm biến nhiệt độ, độ ẩm. - Dùng lại cho API khác: giữ phần
WiFiClientSecure,HTTPClient,deserializeJson; đổi địa chỉ, tên khóa JSON và hàm vẽ. Dịch vụ cần khóa (API key) thì không để khóa trong mã nguồn chia sẻ công khai.
8. Xử lý lỗi thường gặp
| Hiện tượng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
| Màn hình tối, Serial báo "Khong tim thay OLED" | Sai dây SDA, SCL hoặc địa chỉ khác 0x3C | Kiểm tra dây; chạy chương trình quét địa chỉ I2C, nếu là 0x3D thì sửa trong oled.begin |
| Màn hình đứng ở "Cho dong bo gio..." | Chưa vào được Wi-Fi hoặc mạng chặn NTP | Kiểm tra tên, mật khẩu Wi-Fi (2,4 GHz); thử Wi-Fi khác |
| Serial báo "Open-Meteo loi HTTP -1" | Không kết nối được máy chủ: mất Internet, lỗi DNS | Kiểm tra Internet; chương trình tự thử lại sau 1 phút |
| Serial báo lỗi HTTP 400 | Địa chỉ sai: tên biến gõ sai, tọa độ ngoài phạm vi | Dán địa chỉ vào trình duyệt máy tính để xem thông báo lỗi trong JSON |
| Serial báo lỗi HTTP 429 | Gọi quá nhiều lần | Giữ chu kỳ 15 phút, không đặt quá ngắn |
| Thời tiết không giống ngoài trời | Sai tọa độ (đảo vĩ độ, kinh độ) hoặc là số liệu mô hình | Việt Nam có vĩ độ 8–23, kinh độ 102–110; kiểm tra lại hai số |
9. Xem thêm
- Đồng hồ Internet với ESP32, NTP và màn hình OLED 0.96 inch
- Dùng màn hình OLED 1.3 inch SH1106 với ESP32 (màn hình lớn hơn, đổi thư viện)
- Kết nối Wi-Fi trên ESP32: quét mạng, tự kết nối lại, IP tĩnh
- Các bài cùng loạt: gửi cảnh báo qua Telegram, ghi dữ liệu lên Google Sheets, cấu hình Wi-Fi bằng điện thoại.
Xem thêm