Đồ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

ESP32 nhận giờ từ máy chủ NTP pool.ntp.org, gửi yêu cầu GET tới api.open-meteo.com và nhận JSON, hiển thị lên OLED; bảng các tham số trong địa chỉ latitude longitude current daily timezone forecast_days
Giờ từ máy chủ NTP, thời tiết từ Open-Meteo; bảng là các tham số trong địa chỉ yêu cầ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ị

3. Sơ đồ nối

Sơ đồ nối OLED 0.96 inch với ESP32 DevKit V1: VCC vào 3V3, GND vào GND, SCL vào D22, SDA vào D21; bảng nối dây
Bốn dây: 3V3, GND, SDA D21, SCL D22.

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"
           "&current=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

Phản hồi JSON của Open-Meteo gồm timezone, current với temperature_2m relative_humidity_2m weather_code wind_speed_10m, daily với các mảng time weather_code temperature_2m_max min precipitation_probability_max; bảng khóa JSON và biến trong chương trình
Phản hồi thật cho tọa độ trong chương trình ngày 25/10/2026 (dữ liệu Open-Meteo.com, CC BY 4.0).

Đị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

Hai trang OLED: trang 1 giờ 09:32:15, Chu nhat 25/10/2026, 29.7C, độ ẩm 69 phần trăm, gió 5 km/h, Nhieu may; trang 2 dự báo 3 ngày với nhiệt độ thấp nhất cao nhất và khả năng mưa
Hai trang tự đổi mỗi 5 giây (mô phỏng theo đúng tọa độ và bộ chữ của chương trì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.
Serial Monitor: vào Wi-Fi, in thời tiết hiện tại 29.7 C, độ ẩm 69 phần trăm, gió 4.8 km/h, dự báo 3 ngày, đồng bộ giờ; khung thứ hai báo Open-Meteo lỗi HTTP -1 khi mất Internet và thử lại sau 60 giây
Serial Monitor ở tốc độ 115200.

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ượngNguyên nhân thường gặpCá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 0x3CKiể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 NTPKiể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 DNSKiể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 viDá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 429Gọi quá nhiều lầnGiữ chu kỳ 15 phút, không đặt quá ngắn
Thời tiết không giống ngoài trờiSai tọa độ (đảo vĩ độ, kinh độ) hoặc là số liệu mô hìnhViệt Nam có vĩ độ 8–23, kinh độ 102–110; kiểm tra lại hai số

9. Xem thêm


Tin tức liên quan

Hướng dẫn dùng ngắt GPIO trên ESP32: đọc nút nhấn, chống dội và đếm xung
Hướng dẫn dùng ngắt GPIO trên ESP32: đọc nút nhấn, chống dội và đếm xung

Hướng dẫn dùng ngắt GPIO trên ESP32 với Arduino IDE: attachInterrupt, các kiểu kích ngắt, chống dội nút nhấn, đếm xung đo tần số và quy tắc viết hàm ngắt an toàn.

ESP32 điều khiển động cơ DC với driver L298N: đảo chiều, chỉnh tốc độ bằng PWM
ESP32 điều khiển động cơ DC với driver L298N: đảo chiều, chỉnh tốc độ bằng PWM

Hướng dẫn nối ESP32 với driver L298N và động cơ DC giảm tốc: cầu H, đảo chiều, phanh, chỉnh tốc độ bằng PWM, điều khiển bằng lệnh trên Serial Monitor và thí nghiệm STEM.

Hướng dẫn dùng LoRa SX1278 (Ra-02) với ESP32: nối dây SPI, gửi và nhận gói tin đầu tiên
Hướng dẫn dùng LoRa SX1278 (Ra-02) với ESP32: nối dây SPI, gửi và nhận gói tin đầu tiên

Hướng dẫn nối module LoRa SX1278 Ra-02 433 MHz với ESP32 qua SPI, cài thư viện LoRa trên Arduino IDE, viết chương trình gửi và nhận gói tin, đọc RSSI, SNR và xử lý lỗi thường gặp.

Mạch hạ áp LM2596 và MP1584: cách chọn, chỉnh điện áp và cấp nguồn ESP32
Mạch hạ áp LM2596 và MP1584: cách chọn, chỉnh điện áp và cấp nguồn ESP32

So sánh mạch hạ áp LM2596 và MP1584, cách chỉnh điện áp ra bằng đồng hồ vạn năng trước khi nối tải, cấp nguồn 5 V cho ESP32 DevKit V1 từ nguồn 12 V và xử lý lỗi thường gặp.

Hướng dẫn làm cân điện tử với ESP32, HX711 và load cell trên Arduino IDE
Hướng dẫn làm cân điện tử với ESP32, HX711 và load cell trên Arduino IDE

Hướng dẫn nối load cell với module HX711 và ESP32 DevKit V1, gá load cell đúng cách, hiệu chuẩn bằng vật đã biết khối lượng và viết chương trình cân trên Arduino IDE.

Hướng dẫn dùng Deep Sleep trên ESP32: tiết kiệm pin, đánh thức bằng timer và nút nhấn
Hướng dẫn dùng Deep Sleep trên ESP32: tiết kiệm pin, đánh thức bằng timer và nút nhấn

Hướng dẫn đưa ESP32 vào Deep Sleep trên Arduino IDE để tiết kiệm pin: các chế độ nguồn, đánh thức bằng timer và nút nhấn, giữ biến trong bộ nhớ RTC và ước tính thời lượng pin.

Bàn phím 4x4 và còi với ESP32: quét ma trận, chống dội, khóa mật khẩu và âm báo không cần thư viện
Bàn phím 4x4 và còi với ESP32: quét ma trận, chống dội, khóa mật khẩu và âm báo không cần thư viện

Cách quét bàn phím ma trận 4x4, chống dội phím, nối còi thụ động qua transistor, chương trình khóa mật khẩu có đổi mật khẩu, khóa tạm khi nhập sai và âm báo không dùng delay.

Phát hiện chuyển động bằng cảm biến PIR HC-SR501 với ESP32: tự bật đèn qua relay
Phát hiện chuyển động bằng cảm biến PIR HC-SR501 với ESP32: tự bật đèn qua relay

Hướng dẫn dùng cảm biến chuyển động PIR HC-SR501 với ESP32: nguyên lý, cách chỉnh module, đọc tín hiệu bằng ngắt và ghép relay làm đèn tự bật khi có người, tự tắt sau 60 giây.


Bình luận
Đã thêm vào giỏ hàng