ESP32 MQTT: Đồng bộ nhiều thiết bị qua public broker
Trung cấp1/8/2026- Tác giả: IoTlabs Maker

ESP32 MQTT: Đồng bộ nhiều thiết bị qua public broker

Hai board ESP32 chạy cùng một firmware, đồng bộ trạng thái (ON/OFF) với nhau qua một topic MQTT retained trên broker công cộng, nhấn nút ở board nào, LED trên cả hai board đều đổi theo.

ESP32MQTTMulti-deviceĐồng bộ trạng tháiPublic BrokerIoT

Một trong những use-case kinh điển của MQTT là đồng bộ trạng thái giữa nhiều thiết bị mà không cần chúng biết địa chỉ IP của nhau, mọi giao tiếp đi qua một broker trung tâm.

Bài này dựng 2 board ESP32 chạy CHUNG một firmware (chỉ khác một hằng số DEVICE_ID), publish/subscribe cùng một topic trạng thái retained trên broker công cộng. Nhấn nút vật lý trên bất kỳ board nào cũng làm LED trên cả 2 board đồng bộ theo, kể cả khi một board mới bật nguồn sau nhờ cơ chế retained message của MQTT.

Hướng dẫn chi tiết

Hai board ESP32 chạy cùng một firmware, đồng bộ trạng thái (ON/OFF) với nhau qua một topic MQTT retained trên broker công cộng — nhấn nút ở board nào, LED trên cả hai board đều đổi theo.

1. Giới thiệu

Một trong những use-case kinh điển của MQTT là đồng bộ trạng thái giữa nhiều thiết bị mà không cần chúng biết địa chỉ IP của nhau, mọi giao tiếp đi qua một broker trung tâm (mô hình publish/subscribe, khác hẳn kết nối điểm-điểm).

Bài này dựng 2 board ESP32 chạy CHUNG một firmware (chỉ khác một hằng số DEVICE_ID), publish/subscribe cùng một topic trạng thái retained trên broker công cộng.

Nhấn nút vật lý trên bất kỳ board nào cũng làm LED trên cả 2 board đồng bộ theo, kể cả khi một board mới bật nguồn sau — nhờ cơ chế retained message của MQTT.

Vì sao dùng "retained message" thay vì chỉ publish thường? Message thường chỉ đến được các subscriber đang online tại thời điểm publish. Message retained được broker lưu lại và gửi ngay cho bất kỳ client nào subscribe SAU đó — đây chính là cơ chế giúp board mới bật nguồn "bắt kịp" trạng thái hiện tại thay vì mặc định về OFF.

2. Linh kiện cần có (× 2 bộ, mỗi board 1 bộ)

Linh kiện

Số lượng

Ghi chú

ESP32 DevKit V4

2

Board A + Board B, chạy CHUNG 1 file firmware (chỉ đổi DEVICE_ID)

LED Module – Đèn LED đơn

2

1 cái/board, hiển thị trạng thái đồng bộ hiện tại

Nút nhấn (Tactile Switch)

2

1 cái/board, để toggle trạng thái từ board đó

3. Sơ đồ kết nối (áp dụng giống nhau cho cả Board A và Board B)

Module

ESP32 DevKit V4 (mỗi board)

LED · VCC

3V3

LED · GND

GND

LED · IN

GPIO17

Nút nhấn · A

GPIO16 (INPUT_PULLUP)

Nút nhấn · B

GND

Diagram trên trang project vẽ đầy đủ 2 board độc lập (Board A và Board B) — mỗi board có LED và nút nhấn riêng, KHÔNG có dây nối trực tiếp giữa 2 board (toàn bộ đồng bộ đi qua WiFi + broker MQTT, không qua dây vật lý).

4. Code ví dụ #1 — Firmware dùng chung cho cả 2 board (chỉ đổi DEVICE_ID)

Code đầy đủ, flash y hệt file này lên Board A; trước khi flash Board B, đổi giá trị DEVICE_ID thành "esp32_sync_B".

/*
  ESP32 MQTT: Dong bo nhieu thiet bi qua public broker
  Board: 2x ESP32 DevKit V4 (board_esp32_devkitc) - SAME firmware flashed to
  both boards; only DEVICE_ID differs (change before flashing the 2nd board).

  Hardware (per board):
    LED module (mod_led): VCC -> 3V3, GND -> GND, IN -> GPIO17
    Push button (comp_pushbutton): pin A -> GPIO16 (INPUT_PULLUP), pin B -> GND

  Pattern: shared-state sync via a single retained topic. Both devices
  subscribe AND publish to the same state topic. Pressing the button on
  EITHER board toggles the shared state; both LEDs converge to the same
  value because the retained message is delivered to every subscriber
  (including a device that reconnects later - it immediately gets the
  last known state, not a stale default).

  Topics:
    sync/groupA/state           retained, QoS1 - "ON" or "OFF", last-write-wins
    sync/groupA/<DEVICE_ID>/status  LWT, retained, QoS1 - "online"/"offline"
*/

#include <WiFi.h>
#include <PubSubClient.h>

const char *WIFI_SSID = "YOUR_WIFI_SSID";
const char *WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char *MQTT_HOST = "mqtt-ce2.iotlabs.vn"; // public test broker, reference only
const uint16_t MQTT_PORT = 1883;

// CHANGE this to a different value on the 2nd board (e.g. "esp32_sync_B").
const char *DEVICE_ID = "esp32_sync_A";

const char *TOPIC_STATE = "sync/groupA/state";
char topicStatus[48]; // built at runtime from DEVICE_ID: sync/groupA/<id>/status

const int LED_PIN = 17;
const int BUTTON_PIN = 16;

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);

bool sharedState = false; // false = OFF
unsigned long lastReconnectAttempt = 0;
unsigned long lastButtonEvent = 0;
int lastButtonState = HIGH;

void applyLocalState(bool on) {
  sharedState = on;
  digitalWrite(LED_PIN, on ? HIGH : LOW);
}

void onMqttMessage(char *topic, byte *payload, unsigned int length) {
  String msg;
  for (unsigned int i = 0; i < length; i++) msg += (char)payload[i];
  Serial.printf("[mqtt][%s] %s -> %s\n", DEVICE_ID, topic, msg.c_str());

  if (String(topic) == TOPIC_STATE) {
    bool incoming = (msg == "ON");
    if (incoming != sharedState) {
      applyLocalState(incoming);
      Serial.printf("[sync] state converged to %s (from broker)\n", incoming ? "ON" : "OFF");
    }
  }
}

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;
}

bool reconnectMqtt() {
  Serial.printf("[mqtt][%s] connecting...", DEVICE_ID);
  bool ok = mqttClient.connect(
      DEVICE_ID,
      nullptr, nullptr,
      topicStatus, 1, true, "offline" // LWT
  );
  if (ok) {
    Serial.println(" connected");
    mqttClient.publish(topicStatus, "online", true);
    // Retained TOPIC_STATE (if any) arrives right after this subscribe,
    // so a device that (re)joins late still converges to the current
    // shared state instead of defaulting to OFF.
    mqttClient.subscribe(TOPIC_STATE, 1);
  } else {
    Serial.printf(" failed, rc=%d\n", mqttClient.state());
  }
  return ok;
}

void handleButtonToggle() {
  int state = digitalRead(BUTTON_PIN);
  unsigned long now = millis();
  if (state == LOW && lastButtonState == HIGH && now - lastButtonEvent > BUTTON_DEBOUNCE_MS) {
    lastButtonEvent = now;
    bool next = !sharedState;
    Serial.printf("[button][%s] toggling shared state -> %s\n", DEVICE_ID, next ? "ON" : "OFF");
    // Publish retained so late joiners (or the peer board reconnecting)
    // immediately read the latest state instead of a stale one.
    mqttClient.publish(TOPIC_STATE, next ? "ON" : "OFF", true);
    applyLocalState(next); // optimistic local update; broker echo will confirm
  }
  lastButtonState = state;
}

void setup() {
  Serial.begin(115200);
  delay(200);
  Serial.printf("\n[boot] ESP32 MQTT multi-device sync demo (device=%s)\n", DEVICE_ID);

  snprintf(topicStatus, sizeof(topicStatus), "sync/groupA/%s/status", DEVICE_ID);

  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();
    handleButtonToggle();
  }

  delay(10);
}

5. Code ví dụ #2 — Theo dõi cả 2 board qua mosquitto_sub

Mở terminal quan sát để thấy trình tự đồng bộ giữa 2 board (cả 2 topic status theo device):

# Theo doi trang thai dong bo chung
mosquitto_sub -h mqtt-ce2.iotlabs.vn -t "sync/groupA/state" -v

# Theo doi tung device rieng (wildcard +)
mosquitto_sub -h mqtt-ce2.iotlabs.vn -t "sync/groupA/+/status" -v

# Toggle thu cong tu terminal (gia lap "board thu 3")
mosquitto_pub -h mqtt-ce2.iotlabs.vn -t "sync/groupA/state" -r -q 1 -m "ON"

Vì broker công cộng như mqtt-ce2.iotlabs.vn là KHÔNG GIAN CHIA SẺ toàn cầu (mọi người dùng broker này đều thấy topic của nhau nếu trùng tên), hãy đổi groupA thành một chuỗi ngẫu nhiên/duy nhất khi thử nghiệm thật để tránh xung đột với người khác đang test cùng topic mẫu.

6. Code ví dụ #3 — Mở rộng: đồng bộ N thiết bị và tránh vòng lặp phản hồi

Với thiết kế retained + "chỉ áp dụng khi giá trị thực sự đổi" (if (incoming != sharedState) trong onMqttMessage), mẫu này mở rộng tự nhiên lên N thiết bị mà không cần sửa logic — mọi thiết bị mới chỉ cần subscribe đúng topic là tự động đồng bộ. Điểm quan trọng cần giữ khi mở rộng:

// Quy tac chong vong lap "phan hoi" khi mo rong sang N thiet bi:
// KHONG publish lai TOPIC_STATE trong onMqttMessage() -- chi publish
// khi CHINH THIET BI NAY thay doi trang thai (vd: nguoi dung bam nut).
// Neu publish lai moi khi nhan duoc message, N thiet bi se tao vong lap
// publish-nhan-publish vo han, lam nghen broker.
if (incoming != sharedState) {
  applyLocalState(incoming); // CHI cap nhat local, KHONG publish lai
}

7. Lỗi thường gặp

Lỗi

Nguyên nhân

Cách sửa

2 board không đồng bộ, mỗi board hiển thị trạng thái khác nhau

Quên đổi DEVICE_ID giữa 2 board → 2 board dùng chung 1 MQTT client ID → broker liên tục ngắt kết nối board cũ khi board mới connect (trùng client ID không được phép tồn tại 2 lần)

Đảm bảo DEVICE_ID khác nhau tuyệt đối giữa các board

Board mới bật nguồn hiển thị sai trạng thái ban đầu (mặc định OFF thay vì trạng thái thật)

Publish trạng thái KHÔNG retained, hoặc subscribe sau khi trạng thái cũ đã "trôi"

Luôn publish TOPIC_STATE với retained=true; broker sẽ tự gửi lại giá trị cuối cùng ngay khi subscribe

Vòng lặp LED nhấp nháy liên tục không dừng

Logic publish lại mỗi khi nhận message (thay vì chỉ khi state thực sự thay đổi)

Giữ điều kiện if (incoming != sharedState) — chỉ cập nhật/publish khi giá trị THỰC SỰ khác

Bị người khác "phá" trạng thái khi test trên broker công cộng

Topic sync/groupA/state là tên chung, dễ trùng với người khác đang test

Đổi thành topic có tiền tố duy nhất (ví dụ UUID ngắn) khi test trên broker công cộng chia sẻ

8. Tổng kết

Bài viết trình bày pattern đồng bộ trạng thái nhiều thiết bị qua MQTT retained message — một firmware duy nhất chạy trên nhiều board, không cần biết địa chỉ IP lẫn nhau, tự động hội tụ về cùng một trạng thái kể cả khi thiết bị mới tham gia sau.

Đây là nền tảng cho các hệ thống lớn hơn: đồng bộ đèn trong 1 phòng, đồng bộ trạng thái cửa cuốn ở nhiều điểm điều khiển, hay dashboard tổng hợp trạng thái N thiết bị cùng lúc.