ESP32 với Firebase Realtime Database qua REST: đăng nhập ẩn danh, làm mới token, điều khiển relay
ESP32 dùng Firebase Realtime Database không cần thư viện Firebase: đăng nhập ẩn danh, tự làm mới token, đọc lệnh relay, ghi trạng thái và lịch sử, Rules theo uid.
Firebase Realtime Database là cơ sở dữ liệu trên máy chủ Google, lưu dữ liệu dạng cây JSON, có gói miễn phí và bảng điều khiển trên web để xem, sửa dữ liệu. ESP32 thường dùng thư viện Firebase riêng, nhưng mọi thao tác thật ra chỉ là các yêu cầu HTTPS: đăng nhập lấy token, rồi đọc, ghi bằng REST. Bài này làm theo cách đó, chỉ dùng HTTPClient có sẵn trong gói esp32 và ArduinoJson: ESP32 đăng nhập ẩn danh, tự làm mới token mỗi giờ, ghi trạng thái relay 4 kênh, nhận lệnh bật tắt và lưu lịch sử. Viết tay giúp hiểu rõ từng bước, ít phụ thuộc thư viện, và dễ đổi sang dịch vụ khác.
1. Dữ liệu được tổ chức thế nào

thiet_bi/<uid>: mỗi mạch một nhánh,uidlà mã tài khoản ẩn danh của mạch đó.lenh: nơi người dùng sửak1…k4thànhtrue/false; ESP32 đọc mỗi 5 giây.trang_thai: ESP32 báo trạng thái relay thật, cường độ Wi-Fi, thời gian chạy;cap_nhatlà thời điểm do máy chủ điền.lich_su: mỗi 10 phút thêm một dòng, khóa do máy chủ sinh theo thứ tự thời gian.
2. Tạo dự án Firebase
Tên menu ghi theo giao diện tiếng Anh của bảng điều khiển Firebase (console.firebase.google.com), có thể thay đổi theo thời gian.
- Tạo dự án: đăng nhập bằng tài khoản Google, tạo dự án mới (có thể tắt Google Analytics).
- Bật đăng nhập ẩn danh: vào Authentication › Sign-in method, bật Anonymous.
- Tạo cơ sở dữ liệu: vào Realtime Database › Create Database, chọn vị trí Singapore (asia-southeast1) cho gần Việt Nam, chọn chế độ khóa (locked mode). Chép địa chỉ cơ sở dữ liệu ở đầu trang, dạng
https://ten-du-an-default-rtdb.asia-southeast1.firebasedatabase.app; cơ sở dữ liệu đặt ở Mỹ thì địa chỉ có đuôifirebaseio.com. - Dán Rules: trong tab Rules của Realtime Database, dán nội dung như hình ở mục 1 rồi bấm Publish.
- Lấy Web API Key: trong Project settings › General. Nếu chưa thấy khóa, bật Authentication trước hoặc thêm một ứng dụng web vào dự án.
3. Chuẩn bị và sơ đồ nối
- ESP32 DevKit V1 30 chân.
- Module relay 4 kênh 5 V kích mức thấp, một nút nhấn.
- Breadboard, dây cắm, dây USB nạp code.

Relay đóng cắt điện 220 V phải lắp trong hộp kín, dây lưới điện có cầu chì và đủ tiết diện; khi chưa quen làm điện, chỉ thử với đèn LED hoặc tải điện áp thấp.
4. Chương trình
Cần thư viện ArduinoJson (Benoit Blanchon, bản 7) trong Library Manager; phần còn lại có sẵn trong gói esp32. Trước khi nạp, sửa tên và mật khẩu Wi-Fi, API_KEY và DB_URL (không có dấu / ở cuối).
// PYWORLD.VN - ESP32 ghi trạng thái và nhận lệnh relay qua Firebase Realtime Database bằng REST, không dùng thư viện Firebase
// Đăng nhập ẩn danh bằng Firebase Auth REST, tự làm mới ID token trước khi hết hạn, lưu refresh token bằng Preferences
// Relay 4 kênh ở GPIO32, 33, 26, 27 (kích mức thấp), nút nhấn GPIO4 xuống GND; thư viện ArduinoJson 7
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <Preferences.h>
#include <ArduinoJson.h>
const char *TEN_WIFI = "TEN_WIFI";
const char *MAT_KHAU_WIFI = "MAT_KHAU_WIFI";
const char *API_KEY = "KHOA_WEB_API"; // Project settings › General › Web API Key
// Địa chỉ cơ sở dữ liệu, chép ở đầu trang Realtime Database (không có dấu / ở cuối)
const char *DB_URL = "https://ten-du-an-default-rtdb.asia-southeast1.firebasedatabase.app";
const uint8_t RELAY_PINS[4] = {32, 33, 26, 27}; // IN1, IN2, IN3, IN4
const bool RELAY_ACTIVE_LOW = true;
const uint8_t NUT = 4;
const unsigned long DOC_LENH_MS = 5000; // Hỏi lệnh mỗi 5 giây
const unsigned long GHI_TRANG_THAI_MS = 30000; // Báo trạng thái mỗi 30 giây
const unsigned long GHI_LICH_SU_MS = 600000; // Thêm một dòng lịch sử mỗi 10 phút
Preferences pref;
WiFiClientSecure tlsDb; // Kết nối tới máy chủ cơ sở dữ liệu, giữ mở giữa các lần gọi
HTTPClient httpDb;
String idToken, refreshToken, uid;
unsigned long lucCoToken = 0, thoiHanToken = 0; // ms
bool relayBat[4] = {false, false, false, false};
bool canGhiTrangThai = true;
void datRelay(uint8_t kenh, bool bat) {
relayBat[kenh] = bat;
bool muc = RELAY_ACTIVE_LOW ? !bat : bat;
digitalWrite(RELAY_PINS[kenh], muc ? HIGH : LOW);
Serial.printf("Relay K%u: %s\n", (unsigned)(kenh + 1), bat ? "BAT" : "TAT");
}
// Gửi một yêu cầu tới máy chủ đăng nhập (identitytoolkit / securetoken); mỗi lần một kết nối riêng
int goiAuth(const String &url, const char *kieu, const String &noiDung, JsonDocument &kq) {
WiFiClientSecure tls;
tls.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;
if (!http.begin(tls, url)) return -1;
http.addHeader("Content-Type", kieu);
int ma = http.POST(noiDung);
String phanHoi = http.getString();
http.end();
deserializeJson(kq, phanHoi);
if (ma != HTTP_CODE_OK) {
Serial.printf("Firebase Auth tu choi (HTTP %d): %s\n", ma, (const char *)(kq["error"]["message"] | "khong ro"));
}
return ma;
}
bool dangKyAnDanh() {
Serial.println("Dang ky tai khoan an danh moi");
JsonDocument kq;
int ma = goiAuth(String("https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=") + API_KEY, "application/json",
"{\"returnSecureToken\":true}", kq);
if (ma != HTTP_CODE_OK) return false;
idToken = kq["idToken"] | "";
refreshToken = kq["refreshToken"] | "";
uid = kq["localId"] | "";
thoiHanToken = String(kq["expiresIn"] | "3600").toInt() * 1000UL;
return idToken.length() > 0;
}
// Trả về 1 nếu thành công, 0 nếu lỗi mạng (thử lại sau), -1 nếu refresh token không còn dùng được
int lamMoiToken() {
JsonDocument kq;
int ma = goiAuth(String("https://securetoken.googleapis.com/v1/token?key=") + API_KEY, "application/x-www-form-urlencoded",
"grant_type=refresh_token&refresh_token=" + refreshToken, kq);
if (ma == HTTP_CODE_OK) {
idToken = kq["id_token"] | "";
refreshToken = kq["refresh_token"] | refreshToken.c_str();
uid = kq["user_id"] | "";
thoiHanToken = String(kq["expires_in"] | "3600").toInt() * 1000UL;
return idToken.length() > 0 ? 1 : 0;
}
return (ma == 400 || ma == 403) ? -1 : 0; // TOKEN_EXPIRED, USER_NOT_FOUND, INVALID_REFRESH_TOKEN…
}
bool layToken() {
int kq = 0;
if (refreshToken.length()) kq = lamMoiToken();
if (kq == -1 || refreshToken.length() == 0) {
if (kq == -1) Serial.println("Refresh token khong con dung duoc, tao tai khoan moi (uid se doi)");
kq = dangKyAnDanh() ? 1 : 0;
}
if (kq != 1) return false;
if (thoiHanToken < 600000UL) thoiHanToken = 3600000UL; // Phòng giá trị lạ: coi như 1 giờ
lucCoToken = millis();
pref.begin("firebase", false);
if (pref.getString("rt", "") != refreshToken) pref.putString("rt", refreshToken); // Giữ uid sau khi khởi động lại
pref.end();
Serial.printf("Dang nhap Firebase OK, uid %s, token dung %lu giay\n", uid.c_str(), thoiHanToken / 1000);
return true;
}
// Gọi REST tới /thiet_bi/<uid><duongDan>.json; ghi thì thêm print=silent để máy chủ không gửi lại dữ liệu
int goiDb(const char *phuongThuc, const String &duongDan, const String &json, String *ketQua = nullptr) {
String url = String(DB_URL) + "/thiet_bi/" + uid + duongDan + ".json?auth=" + idToken;
if (ketQua == nullptr) url += "&print=silent";
if (!httpDb.begin(tlsDb, url)) return -1;
httpDb.addHeader("Content-Type", "application/json");
int ma = httpDb.sendRequest(phuongThuc, json);
if (ketQua) *ketQua = httpDb.getString();
httpDb.end();
if (ma == 401) {
// Token cũ hơn 1 phút: có thể đã hết hạn, lần sau lấy token mới. Token mới mà vẫn bị từ chối: Rules chưa cho phép
if (millis() - lucCoToken > 60000) lucCoToken = 0;
else Serial.println("Bi tu choi (401): kiem tra Rules cua Realtime Database");
}
return ma;
}
String jsonRelay() {
JsonDocument d;
for (int i = 0; i < 4; i++) d["k" + String(i + 1)] = relayBat[i];
String s;
serializeJson(d, s);
return s;
}
void docLenh() {
String s;
int ma = goiDb("GET", "/lenh", "", &s);
if (ma != HTTP_CODE_OK) {
Serial.printf("Doc lenh loi HTTP %d\n", ma);
return;
}
if (s == "null") { // Lần đầu: chưa có nhánh lenh, tạo bằng trạng thái hiện tại
Serial.println("Tao nhanh lenh ban dau");
goiDb("PUT", "/lenh", jsonRelay());
return;
}
JsonDocument d;
if (deserializeJson(d, s)) return;
for (int i = 0; i < 4; i++) {
JsonVariant v = d["k" + String(i + 1)];
if (v.is<bool>() && v.as<bool>() != relayBat[i]) {
datRelay(i, v.as<bool>());
canGhiTrangThai = true;
}
}
}
void ghiTrangThai() {
JsonDocument d;
for (int i = 0; i < 4; i++) d["k" + String(i + 1)] = relayBat[i];
d["rssi"] = WiFi.RSSI();
d["giay"] = millis() / 1000;
d["cap_nhat"][".sv"] = "timestamp"; // Máy chủ tự điền thời điểm (mili giây từ 1/1/1970)
String s;
serializeJson(d, s);
int ma = goiDb("PATCH", "/trang_thai", s);
if (ma != HTTP_CODE_OK && ma != HTTP_CODE_NO_CONTENT) Serial.printf("Ghi trang_thai loi HTTP %d\n", ma);
}
void ghiLichSu() {
JsonDocument d;
d["rssi"] = WiFi.RSSI();
d["relay"] = (relayBat[0] ? 1 : 0) | (relayBat[1] ? 2 : 0) | (relayBat[2] ? 4 : 0) | (relayBat[3] ? 8 : 0);
d["luc"][".sv"] = "timestamp";
String s;
serializeJson(d, s);
int ma = goiDb("POST", "/lich_su", s); // POST: máy chủ tự đặt khóa theo thứ tự thời gian
Serial.printf("Ghi lich_su: HTTP %d\n", ma);
}
// Nút nhấn tại chỗ đảo K1 và ghi lại vào nhánh lenh, để lần đọc lệnh sau không đảo ngược lại
void kiemTraNut() {
static bool docTruoc = HIGH, onDinh = HIGH;
static unsigned long lucDoi = 0;
bool doc = digitalRead(NUT);
if (doc != docTruoc) {
docTruoc = doc;
lucDoi = millis();
}
if (millis() - lucDoi > 30 && doc != onDinh) { // Chống dội 30 ms
onDinh = doc;
if (onDinh == LOW) {
datRelay(0, !relayBat[0]);
canGhiTrangThai = true;
if (idToken.length()) goiDb("PATCH", "/lenh", relayBat[0] ? "{\"k1\":true}" : "{\"k1\":false}");
}
}
}
void setup() {
Serial.begin(115200);
for (uint8_t i = 0; i < 4; i++) {
pinMode(RELAY_PINS[i], INPUT); // Chân chưa xuất tín hiệu
digitalWrite(RELAY_PINS[i], RELAY_ACTIVE_LOW ? HIGH : LOW); // Ghi sẵn mức tắt
pinMode(RELAY_PINS[i], OUTPUT); // Bật ngõ ra, relay vẫn tắt
}
pinMode(NUT, INPUT_PULLUP);
pref.begin("firebase", true);
refreshToken = pref.getString("rt", "");
pref.end();
tlsDb.setInsecure();
httpDb.setReuse(true);
httpDb.setTimeout(10000);
WiFi.mode(WIFI_STA);
WiFi.begin(TEN_WIFI, MAT_KHAU_WIFI);
Serial.println("Dang ket noi Wi-Fi...");
}
void loop() {
static bool daBaoIp = false;
static unsigned long lanThuToken = 0, lanDocLenh = 0, lanGhiTrangThai = 0, lanGhiLichSu = 0;
static bool daGhiLichSu = false;
kiemTraNut();
if (WiFi.status() != WL_CONNECTED) return;
if (!daBaoIp) {
daBaoIp = true;
Serial.printf("Da vao Wi-Fi, IP %s\n", WiFi.localIP().toString().c_str());
}
// Làm mới token 5 phút trước khi hết hạn (token sống 3600 giây)
bool canToken = idToken.length() == 0 || lucCoToken == 0 || millis() - lucCoToken > thoiHanToken - 300000UL;
if (canToken) {
if (lanThuToken != 0 && millis() - lanThuToken < 30000) return; // Lỗi thì 30 giây sau thử lại
lanThuToken = millis();
if (!layToken()) return;
lanThuToken = 0;
}
if (millis() - lanDocLenh > DOC_LENH_MS) {
lanDocLenh = millis();
docLenh();
}
if (canGhiTrangThai || millis() - lanGhiTrangThai > GHI_TRANG_THAI_MS) {
canGhiTrangThai = false;
lanGhiTrangThai = millis();
ghiTrangThai();
}
if (!daGhiLichSu || millis() - lanGhiLichSu > GHI_LICH_SU_MS) {
daGhiLichSu = true;
lanGhiLichSu = millis();
ghiLichSu();
}
}
5. Đăng nhập ẩn danh và làm mới token

- Lần đầu: ESP32 gửi
POST accounts:signUpvới{"returnSecureToken":true}. Firebase tạo một tài khoản ẩn danh và trả vềidToken(dùng để đọc ghi, sống 3600 giây),refreshToken(dùng lâu dài) vàlocalId(uid). - Lưu refresh token vào Preferences. Khởi động lại, ESP32 đổi refresh token lấy ID token mới qua
securetoken.googleapis.com/v1/token, nên giữ nguyên uid và nhánh dữ liệu cũ. Nếu mỗi lần khởi động lại đều đăng ký mới, mỗi lần sẽ là một uid khác và một nhánh dữ liệu mới. - Làm mới trước hạn: chương trình đổi token khi còn 5 phút. Bị từ chối (HTTP 401) với token cũ hơn 1 phút thì cũng đổi token.
- Refresh token hỏng (tài khoản bị xóa, bị vô hiệu): chương trình đăng ký tài khoản mới và in uid mới ra Serial. Nếu dự án nâng cấp lên Identity Platform, đừng bật chức năng tự xóa tài khoản ẩn danh quá 30 ngày cho dự án này, vì mạch chạy lâu sẽ bị đổi uid.
6. Đọc, ghi bằng REST
Mọi đường dẫn có đuôi .json và tham số auth= là ID token:
GET …/lenh.json: đọc lệnh. Chưa có dữ liệu thì máy chủ trả vềnull, chương trình tạo nhánhlenhbằngPUT.PATCH …/trang_thai.json: chỉ ghi đè các khóa gửi lên, giữ nguyên khóa khác.PUTthì thay cả nhánh.POST …/lich_su.json: thêm một mục với khóa mới do máy chủ sinh, sắp theo thời gian.{".sv": "timestamp"}: máy chủ thay bằng thời điểm hiện tại (mili giây tính từ 1/1/1970), nên ESP32 không cần biết giờ.print=silentkhi ghi: máy chủ trả mã 204 và không gửi lại dữ liệu, bớt lưu lượng.
Các lệnh tới cơ sở dữ liệu dùng chung một WiFiClientSecure và HTTPClient khai báo toàn cục, để giữ kết nối mở giữa các lần gọi thay vì bắt tay TLS lại mỗi 5 giây. Yêu cầu đăng nhập đi tới máy chủ khác nên dùng đối tượng riêng. setInsecure() mã hóa đường truyền nhưng không kiểm tra chứng chỉ máy chủ; muốn chặt hơn thì nạp chứng chỉ gốc bằng setCACert().
7. Rules: ai được đọc, ghi
Khóa $uid trong Rules khớp với mọi nhánh con của thiet_bi; điều kiện auth.uid === $uid nghĩa là chỉ tài khoản có đúng uid đó mới đọc, ghi được nhánh của nó. Mạch khác, hay người lấy được Web API Key rồi tự đăng ký ẩn danh, cũng chỉ thấy nhánh của chính mình. Bảng điều khiển Firebase dùng quyền quản trị nên vẫn xem, sửa được mọi nhánh. Không để cơ sở dữ liệu ở chế độ thử (ai cũng đọc, ghi được) lâu hơn vài phút.
8. Điều khiển và kết quả
Mở Realtime Database › Data, đi tới thiet_bi › <uid> › lenh › k2, sửa thành true: trong vòng 5 giây relay K2 đóng, trang_thai/k2 đổi theo. Bấm nút nhấn tại chỗ thì K1 đảo và lenh/k1 cũng được ghi lại, để lần đọc lệnh sau không đảo ngược. Muốn điều khiển bằng ứng dụng riêng, cần một tài khoản đăng nhập cho người dùng (ví dụ email) và sửa Rules cho phép tài khoản đó vào nhánh của mạch.


Gói miễn phí Spark của Realtime Database có 1 GB lưu trữ, 10 GB dữ liệu tải về mỗi tháng và tối đa 100 kết nối đồng thời. Một mạch như bài này dùng một phần nhỏ; nhiều mạch thì nhân lên, và đọc lệnh 5 giây một lần là phần tốn nhất.
9. Xử lý lỗi thường gặp
| Hiện tượng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
| "Firebase Auth tu choi (HTTP 400): OPERATION_NOT_ALLOWED" (có khi là ADMIN_ONLY_OPERATION) | Chưa bật đăng nhập ẩn danh | Authentication › Sign-in method › bật Anonymous |
| Báo "API key not valid" | Chép sai Web API Key | Chép lại trong Project settings › General |
| "Doc lenh loi HTTP 401" ngay sau khi đăng nhập | Rules chưa đúng hoặc chưa bấm Publish | Dán lại Rules, Publish; kiểm tra đường dẫn thiet_bi |
| "Doc lenh loi HTTP 404" hoặc -1 | Sai DB_URL (tên, đuôi vùng), thừa dấu /, mất Internet | Chép lại địa chỉ ở đầu trang Realtime Database |
| Mỗi lần khởi động lại uid đổi | Refresh token không lưu được, nạp lại có xóa toàn bộ flash, tài khoản bị xóa | Tắt Erase All Flash khi nạp; không bật tự xóa tài khoản ẩn danh |
| Bấm nút tại chỗ, vài giây sau relay đảo lại | Lúc bấm mất mạng, chưa ghi được lenh/k1 | Kiểm tra Wi-Fi; sửa lenh/k1 trên bảng điều khiển cho khớp |
10. Xem thêm
- Hướng dẫn dùng MQTT với ESP32: gửi dữ liệu và điều khiển relay bằng PubSubClient
- Lưu dữ liệu khi mất điện bằng Preferences
- Nạp chương trình cho ESP32 qua Wi-Fi (OTA)
- Hộp mica cho ESP32 DevKit 30 chân
- Các bài cùng loạt: ghi dữ liệu lên Google Sheets, gửi dữ liệu lên ThingSpeak, điều khiển relay bằng Blynk IoT, gửi cảnh báo qua Telegram.
Xem thêm