
ESP32 MQTT: QoS & Last Will Testament xử lý mất kết nối an toàn
Tìm hiểu 3 mức QoS của MQTT (0/1/2) và cơ chế Last Will Testament qua một demo ESP32 thực tế: LED báo trạng thái kết nối, nút nhấn giả lập mất kết nối đột ngột để quan sát broker tự động publish "offline".
QoS (Quality of Service) và Last Will Testament (LWT) là hai cơ chế cốt lõi giúp MQTT đáng tin cậy hơn HTTP polling trong môi trường mạng không ổn định của thiết bị IoT.
Bài này giải thích rõ 3 mức QoS (0/1/2), giới hạn thực tế của thư viện PubSubClient phổ biến trên Arduino, và minh hoạ LWT bằng một demo ESP32 thật: nhấn nút để giả lập thiết bị "chết đột ngột" và quan sát broker tự động publish trạng thái "offline" thay cho thiết bị.
Hướng dẫn chi tiết
Tìm hiểu 3 mức QoS của MQTT (0/1/2) và cơ chế Last Will Testament qua một demo ESP32 thực tế: LED báo trạng thái kết nối, nút nhấn giả lập mất kết nối đột ngột để quan sát broker tự động publish "offline".
1. Giới thiệu
QoS (Quality of Service) và Last Will Testament (LWT) là hai cơ chế cốt lõi giúp MQTT đáng tin cậy hơn HTTP polling trong môi trường mạng không ổn định của thiết bị IoT. Bài này giải thích rõ 3 mức QoS theo chuẩn MQTT 3.1.1, đối chiếu với giới hạn thực tế của thư viện PubSubClient phổ biến trên Arduino (rất nhiều tutorial trên mạng nói sai điều này), và minh hoạ LWT bằng một demo ESP32 thật: nhấn nút để giả lập thiết bị "chết đột ngột", quan sát broker tự động publish trạng thái "offline" thay cho thiết bị đã ngắt kết nối.
3 mức QoS theo chuẩn MQTT:
- QoS 0 — At most once: gửi một lần, không xác nhận, không retry. Nhanh nhất, có thể mất gói. Phù hợp cho dữ liệu tần suất cao mà mất 1 gói không quan trọng (heartbeat, telemetry liên tục).
- QoS 1 — At least once: broker/client gửi lại cho tới khi nhận PUBACK. Đảm bảo đến nơi nhưng có thể trùng lặp (duplicate) — ứng dụng phải tự xử lý idempotency nếu cần. Phù hợp cho lệnh điều khiển, cảnh báo quan trọng.
- QoS 2 — Exactly once: bắt tay 4 bước (PUBLISH→PUBREC→PUBREL→PUBCOMP) đảm bảo đến đúng 1 lần, không trùng lặp. Tốn băng thông/độ trễ nhất — dùng cho giao dịch tài chính, lệnh không được phép lặp lại.
Sự thật về PubSubClient: thư viện này (bản phổ biến nhất trên Arduino) chỉ thực sự publish ở QoS 0 — không có overload nào của publish() nhận tham số QoS. Nó CÓ hỗ trợ subscribe với QoS 1 (broker gửi PUBLISH kèm packet id, client tự động PUBACK trong loop()), nhưng KHÔNG implement QoS 2 (không có state machine PUBREC/PUBREL/PUBCOMP). Nhiều bài viết trên mạng nhầm lẫn điều này. Muốn publish thật ở QoS 1/2 từ ESP32, cần dùng client đầy đủ hơn như ESP-IDF esp-mqtt hoặc Paho MQTT-C.
2. Linh kiện cần có
| Linh kiện | Số lượng | Ghi chú |
|---|---|---|
| ESP32 DevKit V4 | 1 | Board chính |
| LED Module – Đèn LED đơn | 1 | Báo trạng thái kết nối MQTT hiện tại (sáng = đang kết nối) |
| Nút nhấn (Tactile Switch) | 1 | Giả lập sự kiện "thiết bị chết đột ngột" để demo LWT |
3. Sơ đồ kết nối
| Module | ESP32 DevKit V4 |
|---|---|
| LED · VCC | 3V3 |
| LED · GND | GND |
| LED · IN | GPIO17 |
| Nút nhấn · A | GPIO16 (INPUT_PULLUP) |
| Nút nhấn · B | GND |
Nút nhấn dùng chế độ INPUT_PULLUP nội bộ của ESP32 nên không cần điện trở pull-up ngoài — chân A đọc HIGH khi nhả, LOW khi nhấn (nối xuống GND qua chân B).
4. Code ví dụ #1 — Firmware ESP32: QoS0 heartbeat, QoS1 subscribe, LWT, giả lập crash
Code đầy đủ, compile pass với arduino-cli compile --fqbn esp32:esp32:esp32 (thư viện PubSubClient). Đọc kỹ comment trong code — mỗi đoạn đều ghi rõ hành vi QoS thật, không thổi phồng khả năng của thư viện.
/*
ESP32 MQTT: QoS & Last Will Testament - xu ly mat ket noi an toan
Board: ESP32 DevKit V4 (board_esp32_devkitc)
Hardware:
LED module (mod_led) connection status indicator: VCC -> 3V3, GND -> GND, IN -> GPIO17
Push button (comp_pushbutton): pin A -> GPIO16 (INPUT_PULLUP), pin B -> GND
Demonstrates, honestly within the limits of the PubSubClient library:
- QoS 0 publish: heartbeat topic, fire-and-forget, no delivery guarantee.
- QoS 1 subscribe: command topic, broker resends until PUBACK is returned;
PubSubClient's loop() acknowledges QoS1 incoming PUBLISH automatically.
- QoS 2 (exactly-once): NOT implemented by PubSubClient (no PUBREC/PUBREL/
PUBCOMP handshake). Explained conceptually only - a real QoS2 demo needs
a fuller client (e.g. ESP-IDF's esp-mqtt, or Paho MQTT-C). Do not claim
this sketch performs QoS2; it does not.
- LWT (Last Will Testament): configured on connect() so the broker
publishes "offline" (retained) on our behalf if we vanish uncleanly
(crash, power loss, radio drop) - the button here simulates that by
forcing an abrupt TCP-level disconnect without a clean MQTT DISCONNECT.
*/
#include <WiFi.h>
#include <PubSubClient.h>
const char *WIFI_SSID = "YOUR_WIFI_SSID";
const char *WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char *MQTT_HOST = "test.mosquitto.org"; // public test broker, reference only
const uint16_t MQTT_PORT = 1883;
const char *MQTT_CLIENT_ID = "esp32_qoslwt01";
const char *TOPIC_HEARTBEAT = "demo/esp32_qoslwt01/heartbeat"; // QoS0
const char *TOPIC_CMD = "demo/esp32_qoslwt01/cmd"; // QoS1 subscribe
const char *TOPIC_STATUS = "demo/esp32_qoslwt01/status"; // LWT topic
const int LED_PIN = 17;
const int BUTTON_PIN = 16;
const unsigned long HEARTBEAT_INTERVAL_MS = 5000;
const unsigned long RECONNECT_INTERVAL_MS = 5000;
const unsigned long WIFI_TIMEOUT_MS = 15000;
const unsigned long BUTTON_DEBOUNCE_MS = 250;
WiFiClient wifiClient;
PubSubClient mqttClient(wifiClient);
unsigned long lastHeartbeat = 0;
unsigned long lastReconnectAttempt = 0;
unsigned long lastButtonEvent = 0;
int lastButtonState = HIGH;
bool connectWifi() {
if (WiFi.status() == WL_CONNECTED) return true;
Serial.printf("[wifi] connecting to %s ...\n", WIFI_SSID);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
unsigned long start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - start < WIFI_TIMEOUT_MS) {
delay(300);
Serial.print(".");
}
Serial.println();
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("[wifi] connected, IP=%s\n", WiFi.localIP().toString().c_str());
return true;
}
Serial.println("[wifi] connect timeout, will retry in main loop");
return false;
}
void onMqttMessage(char *topic, byte *payload, unsigned int length) {
String msg;
for (unsigned int i = 0; i < length; i++) msg += (char)payload[i];
// Because TOPIC_CMD was subscribed at QoS1, PubSubClient has already sent
// the PUBACK for this message by the time this callback runs.
Serial.printf("[mqtt][qos1 cmd] %s -> %s\n", topic, msg.c_str());
}
bool reconnectMqtt() {
Serial.print("[mqtt] connecting...");
bool ok = mqttClient.connect(
MQTT_CLIENT_ID,
nullptr, nullptr,
TOPIC_STATUS, 1, true, "offline" // LWT: QoS1, retained, "offline"
);
if (ok) {
Serial.println(" connected");
digitalWrite(LED_PIN, HIGH);
mqttClient.publish(TOPIC_STATUS, "online", true);
mqttClient.subscribe(TOPIC_CMD, 1); // request QoS1 for incoming commands
} else {
Serial.printf(" failed, rc=%d\n", mqttClient.state());
digitalWrite(LED_PIN, LOW);
}
return ok;
}
void publishHeartbeat() {
// QoS 0: fire-and-forget. If the packet is dropped in transit, nobody
// resends it - acceptable for a heartbeat where the NEXT beat corrects it.
char payload[24];
snprintf(payload, sizeof(payload), "%lu", millis() / 1000);
mqttClient.publish(TOPIC_HEARTBEAT, payload); // QoS0, not retained
Serial.printf("[heartbeat][qos0] uptime=%ss\n", payload);
}
void handleButtonSimulateCrash() {
int state = digitalRead(BUTTON_PIN);
unsigned long now = millis();
if (state == LOW && lastButtonState == HIGH && now - lastButtonEvent > BUTTON_DEBOUNCE_MS) {
lastButtonEvent = now;
if (mqttClient.connected()) {
Serial.println("[demo] button pressed -> simulating ungraceful drop (no clean DISCONNECT)");
// Cutting the underlying TCP socket directly (instead of calling
// mqttClient.disconnect(), which WOULD send a clean DISCONNECT and
// suppress the Will) is what makes the broker publish our LWT.
wifiClient.stop();
digitalWrite(LED_PIN, LOW);
}
}
lastButtonState = state;
}
void setup() {
Serial.begin(115200);
delay(200);
Serial.println("\n[boot] ESP32 MQTT QoS + LWT demo");
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, LOW);
pinMode(BUTTON_PIN, INPUT_PULLUP);
connectWifi();
mqttClient.setServer(MQTT_HOST, MQTT_PORT);
mqttClient.setCallback(onMqttMessage);
lastReconnectAttempt = 0;
}
void loop() {
if (WiFi.status() != WL_CONNECTED) {
connectWifi();
delay(1000);
return;
}
if (!mqttClient.connected()) {
unsigned long now = millis();
if (now - lastReconnectAttempt > RECONNECT_INTERVAL_MS) {
lastReconnectAttempt = now;
reconnectMqtt();
}
} else {
mqttClient.loop();
unsigned long now = millis();
if (now - lastHeartbeat > HEARTBEAT_INTERVAL_MS) {
lastHeartbeat = now;
publishHeartbeat();
}
handleButtonSimulateCrash();
}
delay(10);
}
5. Code ví dụ #2 — Quan sát LWT bằng mosquitto_sub song song
Mở 2 terminal trước khi bấm nút demo, để thấy rõ trình tự sự kiện:
# Terminal 1: theo doi trang thai (LWT se xuat hien o day)
mosquitto_sub -h test.mosquitto.org -t "demo/esp32_qoslwt01/status" -v
# Terminal 2: theo doi heartbeat QoS0
mosquitto_sub -h test.mosquitto.org -t "demo/esp32_qoslwt01/heartbeat" -v
# Terminal 3 (tuy chon): gui lenh QoS1 cho ESP32
mosquitto_pub -h test.mosquitto.org -t "demo/esp32_qoslwt01/cmd" -q 1 -m "PING"
Trình tự quan sát được: (1) ESP32 kết nối → status="online" xuất hiện ngay; (2) heartbeat chạy đều mỗi 5s; (3) nhấn nút trên board → ESP32 ngắt socket TCP đột ngột KHÔNG gửi DISCONNECT sạch; (4) sau keepalive timeout của broker (mặc định PubSubClient đặt 15s), broker tự phát hiện client "biến mất" và publish Will Message → Terminal 1 sẽ thấy status offline xuất hiện dù ESP32 không hề tự gửi nó — đây chính là giá trị của LWT.
6. Code ví dụ #3 — Vì sao QoS 2 cần một client khác
Nếu dự án bắt buộc cần QoS 2 thật (ví dụ: lệnh mở khoá cửa không được phép gửi trùng), cách tiếp cận đúng trên ESP32 là dùng ESP-IDF component esp-mqtt (không phải Arduino PubSubClient):
// Vi du minh hoa (ESP-IDF esp-mqtt), KHONG phai code cua sketch nay:
esp_mqtt_client_config_t mqtt_cfg = {
.broker.address.uri = "mqtt://test.mosquitto.org:1883",
};
esp_mqtt_client_handle_t client = esp_mqtt_client_init(&mqtt_cfg);
esp_mqtt_client_start(client);
// esp-mqtt HO TRO qos=2 that su (co PUBREC/PUBREL/PUBCOMP):
esp_mqtt_client_publish(client, "demo/esp32_qoslwt01/critical", "OPEN_DOOR", 0, 2, 0);
Đây chỉ là đoạn minh hoạ API (không nằm trong sketch Arduino chính, không được compile trong bài) để người đọc biết hướng đi đúng khi cần QoS 2 thật, tránh ngộ nhận rằng đổi số trong PubSubClient::subscribe(topic, 2) là đủ.
7. Lỗi thường gặp
| Lỗi | Nguyên nhân | Cách sửa |
|---|---|---|
| Tưởng đã publish QoS1/2 nhưng Wireshark cho thấy toàn QoS0 | Ngộ nhận PubSubClient hỗ trợ publish QoS >0 | Chấp nhận giới hạn của thư viện, hoặc chuyển sang ESP-IDF esp-mqtt nếu cần publish QoS thật |
| LWT không bao giờ kích hoạt dù rút nguồn ESP32 | Keepalive quá dài, hoặc code gọi mqttClient.disconnect() (DISCONNECT sạch) thay vì để mất kết nối đột ngột | Test bằng cách rút cáp mạng/nguồn thay vì gọi hàm disconnect; giảm keepalive nếu cần phát hiện nhanh hơn |
| Subscribe QoS1 nhưng vẫn thấy trùng lặp message | Đúng theo chuẩn — QoS1 là "at least once", KHÔNG phải "exactly once" | Thêm cơ chế idempotency ở tầng ứng dụng (ví dụ id tăng dần) nếu trùng lặp gây vấn đề |
| Nút nhấn không phản hồi / bấm nhiều lần mới nhận | Thiếu debounce, hoặc nhiễu tín hiệu cơ khí | Đã xử lý bằng BUTTON_DEBOUNCE_MS trong code; tăng giá trị nếu nút nhấn kém chất lượng |
8. Tổng kết
QoS và LWT là hai công cụ khác nhau giải quyết hai vấn đề khác nhau: QoS kiểm soát độ tin cậy của TỪNG gói tin (có bị mất/lặp không), còn LWT giải quyết vấn đề "làm sao biết thiết bị đã chết" khi bản thân thiết bị không còn khả năng tự báo. Bài viết cũng làm rõ giới hạn thường bị bỏ qua của thư viện PubSubClient: chỉ publish thật ở QoS 0, subscribe được ở QoS 1, không hỗ trợ QoS 2 — thông tin cần thiết trước khi thiết kế một hệ thống MQTT đòi hỏi độ tin cậy cao.
Ghi chú minh bạch: môi trường tạo bài viết này không có phần cứng vật lý/broker MQTT thật để kết nối trực tiếp, nên trình tự sự kiện mô tả (LWT kích hoạt sau ngắt kết nối đột ngột) dựa trên đặc tả MQTT 3.1.1 chính thức và hành vi tài liệu hoá của PubSubClient/Mosquitto — chưa được đo đạc thực nghiệm trong lần viết bài này.