Satu pembacaan DHT22 menghasilkan suhu dan kelembapan relatif dari sampling yang sama. Mengirim keduanya melalui topic MQTT terpisah memang bekerja, tetapi cara itu juga membuat dua batas pesan independen. Subscriber dapat menerima suhu baru sebelum nilai kelembapan pasangannya tiba.

Memasukkan kedua pengukuran ke satu payload membuat hubungan tersebut eksplisit:

{"temperature":25.34,"humidity":60.21}

MQTT tidak mewajibkan JSON. Bagian yang berguna adalah kedua pengukuran dikirim dalam satu publication dan dapat diperlakukan sebagai satu snapshot state sensor.

Satu topic untuk satu state sensor

Dengan topic terpisah, broker melihat dua publication:

device/temp -> 25.34
device/hum  -> 60.21

Satu topic state mengubah batas itu:

device/sensor -> {"temperature":25.34,"humidity":60.21}

Ini tidak membuat seluruh aplikasi menjadi transaksional. Artinya lebih sempit: kedua field berada di payload MQTT yang sama. Untuk nilai yang diambil bersama, kontrak seperti ini lebih jelas bagi dashboard, consumer penyimpanan, dan automation rule.

Buffer tetap sudah cukup untuk dua field numerik:

char payload[64];

snprintf(
    payload,
    sizeof(payload),
    "{\"temperature\":%.2f,\"humidity\":%.2f}",
    temperature,
    humidity
);

Nilainya tetap berupa JSON number, bukan string berisi angka. Consumer downstream tidak perlu melakukan konversi string ke number lagi.

Retained telemetry berbeda dari command

Mengirim state sensor dengan retain flag membuat subscriber yang baru terhubung dapat menerima nilai terakhir yang disimpan broker tanpa menunggu sampling berikutnya:

client.publish(topic_sensor, payload, true);

Perilaku ini cocok untuk topic state:

cccb20c92d2b32/sensor

Command LED mempunyai semantik berbeda:

cccb20c92d2b32/lamp

ESP32 subscribe ke topic tersebut dan bereaksi terhadap ON atau OFF. Retain pada command harus dipilih dengan sengaja karena command retained dapat diterima lagi setelah perangkat reconnect. Itu masuk akal untuk model desired state, tetapi biasanya tidak cocok untuk aksi sekali jalan.

Payload callback membutuhkan batas string yang eksplisit

PubSubClient memberikan payload masuk sebagai byte beserta panjangnya. Kode tidak boleh menganggap payload sudah diakhiri null byte.

char message[length + 1];
memcpy(message, payload, length);
message[length] = '\0';

if (strcmp(topic, topic_led) == 0) {
  if (strcmp(message, "ON") == 0) {
    digitalWrite(LED_PIN, HIGH);
  } else if (strcmp(message, "OFF") == 0) {
    digitalWrite(LED_PIN, LOW);
  }
}

Penyalinan tersebut membuat batas data eksplisit sebelum strcmp() memperlakukannya sebagai C string.

Client ID manual adalah identitas koneksi

Client ID tetap valid ketika satu instance firmware mewakili satu perangkat:

const char* mqtt_client_id = "cccb20c92d2b32";

ID tersebut tidak boleh dipakai oleh client lain yang terhubung bersamaan ke broker yang sama. Client ID juga bukan credential. Membuatnya panjang atau sulit ditebak tidak memberi otorisasi terhadap topic.

Saat menguji dengan MQTTX atau ESP32 lain, gunakan client ID berbeda meskipun client tersebut subscribe ke topic yang sama.

Pemeliharaan koneksi berada di main loop

PubSubClient membutuhkan client.loop() untuk berjalan secara rutin selama koneksi aktif. Blocking delay yang panjang dapat mengganggu pekerjaan itu. Default keepalive PubSubClient adalah 15 detik dan nilainya dapat diubah melalui setKeepAlive().

Timing sensor dapat memakai millis() sebagai pengganti delay(5000), sehingga prosesor tetap tersedia untuk MQTT di antara pembacaan DHT22.

Percobaan reconnect juga tidak perlu dijalankan pada setiap putaran CPU. Interval retry lima detik cukup sederhana dan mencegah broker yang sedang tidak tersedia menghasilkan connection loop yang rapat:

if (!client.connected() &&
    millis() - lastMqttAttempt >= mqttRetryInterval) {
  lastMqttAttempt = millis();

  if (client.connect(mqtt_client_id)) {
    client.subscribe(topic_led);
  } else {
    Serial.println(client.state());
  }
}

State code membantu diagnosis. Reconnect loop dapat berasal dari Wi-Fi atau TCP, kondisi broker, maupun proses koneksi MQTT. Client ID yang unik menyingkirkan satu penyebab umum, bukan semua penyebab.

Sketch ESP32 lengkap

Sketch berikut mengirim satu retained JSON state setiap lima detik dan menerima command LED melalui topic terpisah.

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

const char* ssid = "YOUR_WIFI";
const char* password = "YOUR_PASSWORD";
const char* mqtt_server = "broker.emqx.io";
const char* mqtt_client_id = "cccb20c92d2b32";

const char* topic_sensor = "cccb20c92d2b32/sensor";
const char* topic_led = "cccb20c92d2b32/lamp";

#define DHTPIN 4
#define DHTTYPE DHT22
#define LED_PIN 5

WiFiClient espClient;
PubSubClient client(espClient);
DHT dht(DHTPIN, DHTTYPE);

unsigned long lastPublish = 0;
unsigned long lastMqttAttempt = 0;
const unsigned long publishInterval = 5000;
const unsigned long mqttRetryInterval = 5000;

void callback(char* topic, byte* payload, unsigned int length) {
  char message[length + 1];
  memcpy(message, payload, length);
  message[length] = '\0';

  if (strcmp(topic, topic_led) != 0) return;

  if (strcmp(message, "ON") == 0) {
    digitalWrite(LED_PIN, HIGH);
  } else if (strcmp(message, "OFF") == 0) {
    digitalWrite(LED_PIN, LOW);
  }
}

void setup_wifi() {
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);

  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
  }
}

void connect_mqtt() {
  if (client.connected()) return;

  unsigned long now = millis();
  if (now - lastMqttAttempt < mqttRetryInterval) return;
  lastMqttAttempt = now;

  if (client.connect(mqtt_client_id)) {
    Serial.println("MQTT connected");
    client.subscribe(topic_led);
  } else {
    Serial.print("MQTT failed, state=");
    Serial.println(client.state());
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW);

  dht.begin();
  setup_wifi();

  client.setServer(mqtt_server, 1883);
  client.setCallback(callback);
  client.setKeepAlive(60);
}

void loop() {
  if (WiFi.status() != WL_CONNECTED) {
    setup_wifi();
  }

  connect_mqtt();

  if (client.connected()) {
    client.loop();
  }

  unsigned long now = millis();
  if (now - lastPublish < publishInterval) return;
  lastPublish = now;

  float temperature = dht.readTemperature();
  float humidity = dht.readHumidity();

  if (isnan(temperature) || isnan(humidity)) {
    Serial.println("DHT read failed");
    return;
  }

  char payload[64];
  snprintf(
      payload,
      sizeof(payload),
      "{\"temperature\":%.2f,\"humidity\":%.2f}",
      temperature,
      humidity
  );

  if (!client.connected()) {
    Serial.println("MQTT offline");
    return;
  }

  bool published = client.publish(topic_sensor, payload, true);

  Serial.print(published ? "Publish OK -> " : "Publish FAILED -> ");
  Serial.println(payload);
}

EMQX menyediakan broker.emqx.io pada TCP port 1883 sebagai endpoint MQTT publik untuk testing. Port 1883 tidak terenkripsi dan broker tersebut dipakai bersama. Karena itu, sketch memakai kredensial Wi-Fi placeholder dan tidak seharusnya membawa telemetry privat atau command actuator yang harus dipercaya dalam bentuk ini.

Pada perangkat yang benar-benar digunakan, authentication, access control, dan TLS merupakan kebutuhan terpisah dari format pesan JSON.

Batas yang penting adalah publication

Menggabungkan suhu dan kelembapan bukan sekadar mengurangi jumlah topic. Struktur tersebut menentukan nilai mana yang termasuk dalam satu state sensor. Retain kemudian berlaku pada state lengkap itu, dan subscriber memproses satu payload tanpa harus menyelaraskan dua pesan yang tiba secara independen.

Bagian firmware lain sebaiknya mempertahankan batas yang sama jelasnya: command berada pada topic sendiri, byte dari callback diberi batas string eksplisit, client.loop() tetap berjalan, dan percobaan reconnect diberi interval. JSON hanyalah representasi; batas pesan MQTT yang memberi makna pada state tersebut.