Combinar ESP32, ArduinoJson y MQTT es la forma más habitual de enviar datos de sensores estructurados desde un microcontrolador. En este tutorial construirás telemetría JSON con ArduinoJson 7, la publicarás de forma eficiente con PubSubClient, analizarás comandos JSON en el callback de mensajes y conocerás los límites de búfer que hacen que los mensajes desaparezcan sin ningún error.
Por qué payloads JSON para MQTT
A MQTT no le importa lo que hay dentro de un payload; mueve bytes. Esa libertad implica que tienes que elegir un formato. JSON gana en la mayoría de los proyectos con ESP32 porque cualquier dashboard, flujo de Node-RED, script de Python o importador de bases de datos puede leerlo sin un decodificador a medida. Un mensaje como {"temp":22.4,"hum":51,"rssi":-63} se describe a sí mismo, es fácil de ampliar con campos nuevos y se lee en cualquier cliente MQTT mientras depuras.
Los costes son el tamaño y el trabajo de análisis, ambos despreciables a las frecuencias típicas de los sensores en un ESP32. Si envías cientos de mensajes por segundo o trabajas con un presupuesto de batería ajustado, las claves compactas ayudan más que cambiar de formato.
ArduinoJson es la librería de referencia para esto. La versión 7 eliminó la necesidad de elegir una capacidad de documento: JsonDocument crece según haga falta en el heap. Todo el código de abajo es para ArduinoJson 7; si estás en la versión 6, sustituye JsonDocument por StaticJsonDocument<N> o DynamicJsonDocument. La documentación oficial de ArduinoJson cubre los detalles de la migración.
Qué necesitas
- Una placa ESP32 y el Arduino IDE o PlatformIO.
- Librerías: PubSubClient de Nick O'Leary y ArduinoJson de Benoit Blanchon (7.x).
mosquitto_pubymosquitto_suben tu equipo para enviar comandos y ver la salida.
Los ejemplos usan el área pública de broker.connectmq.com en el puerto 1883 con el prefijo de topic connectmq/demo/a8e2d1. Cambia ese prefijo por tu propia cadena aleatoria y recuerda que cualquiera puede leer los topics públicos.
| Topic | Dirección | Payload |
|---|---|---|
connectmq/demo/a8e2d1/telemetry |
El ESP32 publica | {"device":"...","temp":22.4,"hum":51,"uptime":120} |
connectmq/demo/a8e2d1/cmd |
El ESP32 se suscribe | {"action":"set_interval","value":10} |
connectmq/demo/a8e2d1/ack |
El ESP32 publica | {"ok":true,"action":"set_interval"} |
Sketch completo de ESP32 ArduinoJson MQTT
El sketch publica telemetría en un intervalo configurable y acepta comandos JSON para cambiar ese intervalo o encender y apagar un LED.
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";
const char* TOPIC_TELEMETRY = "connectmq/demo/a8e2d1/telemetry";
const char* TOPIC_CMD = "connectmq/demo/a8e2d1/cmd";
const char* TOPIC_ACK = "connectmq/demo/a8e2d1/ack";
const int LED_PIN = 2;
WiFiClient net;
PubSubClient mqtt(net);
String deviceId;
unsigned long intervalMs = 5000;
unsigned long lastPublish = 0;
void publishAck(const char* action, bool ok, const char* error = nullptr) {
JsonDocument doc;
doc["ok"] = ok;
doc["action"] = action;
if (error) doc["error"] = error;
char buf[128];
size_t n = serializeJson(doc, buf, sizeof(buf));
mqtt.publish(TOPIC_ACK, (const uint8_t*)buf, n, false);
}
void onMessage(char* topic, byte* payload, unsigned int length) {
JsonDocument doc;
DeserializationError err = deserializeJson(doc, payload, length);
if (err) {
publishAck("unknown", false, err.c_str());
return;
}
const char* action = doc["action"] | "";
if (strcmp(action, "set_interval") == 0) {
int seconds = doc["value"] | 0;
if (seconds < 1 || seconds > 3600) {
publishAck(action, false, "value out of range");
return;
}
intervalMs = seconds * 1000UL;
publishAck(action, true);
} else if (strcmp(action, "led") == 0) {
bool on = doc["value"] | false;
digitalWrite(LED_PIN, on ? HIGH : LOW);
publishAck(action, true);
} else {
publishAck(action, false, "unknown action");
}
}
void publishTelemetry() {
JsonDocument doc;
doc["device"] = deviceId;
doc["temp"] = serialized(String(20.0 + random(0, 50) / 10.0, 1));
doc["hum"] = 45 + random(0, 15);
doc["uptime"] = millis() / 1000;
JsonObject network = doc["net"].to<JsonObject>();
network["rssi"] = WiFi.RSSI();
network["ip"] = WiFi.localIP().toString();
size_t len = measureJson(doc);
mqtt.beginPublish(TOPIC_TELEMETRY, len, false);
serializeJson(doc, mqtt); // volcar directamente en el paquete MQTT
mqtt.endPublish();
}
void connectMqtt() {
while (!mqtt.connected()) {
if (mqtt.connect(deviceId.c_str())) {
mqtt.subscribe(TOPIC_CMD);
} else {
Serial.printf("MQTT failed, state=%d\n", mqtt.state());
delay(3000);
}
}
}
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) delay(250);
deviceId = "esp32-json-" + String((uint32_t)ESP.getEfuseMac(), HEX);
mqtt.setServer("broker.connectmq.com", 1883);
mqtt.setBufferSize(1024); // por defecto son 256 bytes, demasiado poco para la mayoría de JSON
mqtt.setCallback(onMessage);
}
void loop() {
if (!mqtt.connected()) connectMqtt();
mqtt.loop();
if (millis() - lastPublish >= intervalMs) {
lastPublish = millis();
publishTelemetry();
}
}
Observa la salida y envía comandos desde tu equipo:
mosquitto_sub -h broker.connectmq.com -p 1883 -i pc-a8e2d1 -t 'connectmq/demo/a8e2d1/#' -v
mosquitto_pub -h broker.connectmq.com -p 1883 -i pc-a8e2d1-pub \
-t connectmq/demo/a8e2d1/cmd -m '{"action":"set_interval","value":10}'
mosquitto_pub -h broker.connectmq.com -p 1883 -i pc-a8e2d1-pub \
-t connectmq/demo/a8e2d1/cmd -m '{"action":"led","value":true}'
Serializar: tres formas de meter JSON en una publicación
En un búfer fijo
serializeJson(doc, buf, sizeof(buf)) escribe en un array de char y devuelve el número de bytes escritos. Es lo más sencillo y funciona bien para mensajes pequeños y previsibles como el de confirmación de arriba. Pasa la longitud devuelta a publish(), usando la sobrecarga que recibe un puntero de bytes y una longitud, en lugar de depender de strlen().
Volcado directo en el cliente MQTT
PubSubClient hereda de la clase Print de Arduino, así que ArduinoJson puede escribir directamente en él. measureJson(doc) da primero el tamaño exacto, beginPublish() escribe la cabecera MQTT con ese tamaño, serializeJson(doc, mqtt) vuelca el cuerpo y endPublish() cierra el paquete. No hace falta un búfer intermedio, lo que la convierte en la opción más eficiente en memoria para documentos grandes. Sigues necesitando setBufferSize() para las llamadas normales a publish() y para los mensajes entrantes, que siempre se leen en el búfer.
En un String
serializeJson(doc, someString) es cómodo pero reserva memoria en el heap con cada mensaje. En un dispositivo que funciona durante semanas, las asignaciones repetidas de String pueden fragmentar la memoria. En firmware de larga duración, prefiere las dos primeras opciones.
Controlar la precisión de los float
ArduinoJson imprime los float con dígitos suficientes para poder reconstruirlos, lo que puede dar valores como 22.399999. Envolver una cadena formateada en serialized() la inserta tal cual, como en la función de telemetría de arriba. Como alternativa, redondea el número antes de asignarlo: doc["temp"] = round(t * 10) / 10.0;.
Analizar comandos JSON de forma segura
El callback MQTT recibe un puntero de bytes y una longitud, y el payload no termina en nulo. Pasa siempre la longitud: deserializeJson(doc, payload, length). Llamarlo solo con (char*)payload lee más allá del final del mensaje.
Tres hábitos más hacen que el análisis sea robusto:
- Comprueba el error.
DeserializationErrorte dice si la entrada estaba vacía, incompleta, era inválida o demasiado profunda. Notifícalo en un topic de confirmación, como hace el sketch, para que el emisor sepa que el comando falló. - Usa valores por defecto con el operador
|.doc["value"] | 0devuelve 0 si el campo falta o tiene un tipo incorrecto, así que un comando mal formado no puede bloquear el dispositivo ni dejar una variable sin inicializar. - Valida los rangos. Que el análisis JSON tenga éxito no significa que los valores tengan sentido. Rechaza explícitamente intervalos de cero segundos o valores de brillo de 5000.
Si solo necesitas unos pocos campos de un documento entrante grande, usa un filtro para que ArduinoJson ignore el resto:
JsonDocument filter;
filter["action"] = true;
filter["value"] = true;
JsonDocument doc;
deserializeJson(doc, payload, length, DeserializationOption::Filter(filter));
Trampas de búfer y memoria
| Síntoma | Causa | Solución |
|---|---|---|
publish() devuelve false y no llega nada |
Paquete mayor que el búfer por defecto de 256 bytes de PubSubClient | mqtt.setBufferSize(1024) antes de connect() |
| El JSON entrante nunca llega al callback | Mensaje entrante mayor que el búfer | Lo mismo: aumenta el búfer para que quepa el comando más grande esperado |
Errores IncompleteInput |
Análisis con strlen() sobre un payload sin terminador |
Pasa length a deserializeJson() |
| Reinicios aleatorios al cabo de días | Fragmentación del heap por serializar con String | Serializa en búferes fijos o vuelca directamente en el cliente |
| Valores impresos con muchos decimales | Formato por defecto de los float | serialized() o redondear antes de asignar |
Ten en cuenta también los límites del broker. El área pública de broker.connectmq.com acepta hasta 64 KB por mensaje, y el plan gratuito Serverless de ConnectMQ hasta 256 KB, muy por encima de lo que un sketch de ESP32 debería enviar en un solo mensaje. Si tu JSON está creciendo mucho, divídelo en topics como .../telemetry y .../diagnostics. La guía del tamaño del búfer de PubSubClient explica en detalle la aritmética del búfer.
Diseñar el esquema JSON
Algunas convenciones ahorran problemas en cuanto intervienen más de un dispositivo y más de un consumidor:
- Incluye un ID de dispositivo o ponlo en el topic, pero no ambas cosas de forma incoherente.
- Usa las unidades en los nombres de las claves (
temp_c,power_w) o documéntalas una vez; no mezcles unidades entre dispositivos. - Añade una marca de tiempo solo si el dispositivo ha sincronizado la hora. Si no, deja que el consumidor registre la hora de llegada; consulta añadir marcas de tiempo NTP a los mensajes MQTT del ESP32.
- Mantén los comandos y la telemetría en topics separados para que un dispositivo nunca analice sus propios mensajes.
- Versiona el payload con un campo como
"v":1si el formato puede cambiar.
Usarlo con sensores reales
Sustituye los valores aleatorios por lecturas reales y el resto del sketch queda igual. El tutorial de ESP32 DHT22 MQTT muestra la parte del sensor, y el tutorial de IoT con ESP32 monta un proyecto de dispositivo completo en torno al mismo patrón JSON.
Envía tu telemetría JSON a ConnectMQ
Probar contra el servidor MQTT público gratuito es la forma más rápida de iterar sobre un formato de payload, siempre con datos de prueba. Cuando los datos importan, pasa a un broker privado: ConnectMQ es un broker MQTT gestionado cuyo portal muestra tus mensajes JSON en vivo en el explorador de topics y te permite crear reglas de alerta por umbral sobre campos numéricos. Regístrate en el plan gratuito Serverless en app.connectmq.com, sin tarjeta de crédito, y después pasa el sketch a WiFiClientSecure en el puerto 8883 con YOUR_CLIENT_ID, YOUR_USERNAME y YOUR_PASSWORD.
Preguntas frecuentes
¿Por qué PubSubClient no consigue publicar mi JSON?
El búfer por defecto es de 256 bytes, incluidos el topic y la cabecera. Llama a mqtt.setBufferSize() con un valor mayor antes de conectar y comprueba el valor de retorno de publish().
¿Debo usar ArduinoJson 6 o 7?
Usa la versión 7 en proyectos nuevos. Sustituye los documentos de capacidad fija por un único JsonDocument que se dimensiona solo, lo que elimina una fuente habitual de salidas truncadas.
¿Es JSON demasiado pesado para un ESP32 con MQTT?
No. A las frecuencias típicas de los sensores, el coste de análisis y serialización es mínimo. Mantén las claves cortas y los payloads concisos si funcionas con batería o envías muchos mensajes por segundo.
¿Cómo analizo el payload si no termina en nulo?
Pasa la longitud al analizador: deserializeJson(doc, payload, length). Así ArduinoJson lee exactamente los bytes del mensaje.



