ESP32 y Arduino

ESP32 OLED MQTT: muestra mensajes MQTT en vivo en una pantalla SSD1306

Convierte un ESP32 y una OLED SSD1306 barata en una pantalla MQTT en vivo que muestra valores de sensores y mensajes de cualquier dispositivo, marca las lecturas obsoletas e indica su propio estado de conexión.

Pequeña pantalla SSD1306 en un ESP32 mostrando valores de temperatura en una pantalla ESP32 OLED MQTT
Pruébalo en tu propio broker MQTTConsigue un broker MQTT privado y gestionado con el plan Free y empieza con ejemplos de código listos para usar.
Pruébalo gratis Ejemplos de código Sin tarjeta

Una pantalla ESP32 OLED MQTT es la forma más sencilla de tener datos en vivo en un escritorio, un banco de trabajo o el panel de una máquina sin necesidad de móvil ni navegador. En este tutorial te suscribirás a varios topics en broker.connectmq.com, mostrarás los últimos valores en una pantalla SSD1306 de 128×64, marcarás las lecturas como obsoletas cuando un sensor deje de informar y mostrarás el estado del Wi-Fi y de MQTT de la propia pantalla. El mismo sketch funciona sin cambios cuando pases a credenciales privadas.

Qué muestra la pantalla

Un buen dashboard diminuto responde a tres preguntas de un vistazo: cuál es el valor, qué antigüedad tiene y si la propia pantalla está conectada. La distribución que se usa aquí encaja las tres en una pantalla de 0,96 pulgadas:

+--------------------------------+
| MQTT  OK          RSSI -58     |
| Temp      23.4 C               |
| Humidity  48 %                 |
| Door      CLOSED       (12s)   |
| Msg: Coffee is ready           |
+--------------------------------+

Cada fila está vinculada a un topic. La pantalla se suscribe con un único comodín y dirige los mensajes entrantes a las filas comparando el último nivel del topic.

Topic Fila Payload de ejemplo
connectmq/demo/<id>/temp Temp 23.4
connectmq/demo/<id>/humidity Humidity 48
connectmq/demo/<id>/door Door CLOSED
connectmq/demo/<id>/msg Msg Cualquier texto corto

En una instalación real, los valores proceden de otros dispositivos: un ESP32 con un sensor DHT22 que publica temperatura y humedad, un contacto de puerta o un script que envía notificaciones. A la pantalla no le importa quién publica, y esa es la principal fortaleza del modelo publicación/suscripción. Si este desacoplamiento es nuevo para ti, lee sobre qué hace un broker MQTT antes de empezar.

Hardware y conexiones

  • Placa de desarrollo ESP32.
  • Módulo OLED SSD1306 de 128×64 píxeles, versión I2C (cuatro pines: GND, VCC, SCL, SDA).
  • Cuatro cables de puente.
Pin de la OLED Pin del ESP32
GND GND
VCC 3V3
SCL GPIO 22
SDA GPIO 21

GPIO 21 y 22 son los pines I2C por defecto en las placas ESP32 DevKit clásicas. Otras variantes, como el ESP32-S3 o el C3, usan pines por defecto distintos, así que pasa tus pines explícitamente a Wire.begin(SDA, SCL), como hace el sketch. La mayoría de los módulos usan la dirección I2C 0x3C; unos pocos usan 0x3D. Si la pantalla sigue en negro, ejecuta un sketch de escáner I2C para confirmar la dirección.

Librerías

Si nunca has grabado un ESP32 desde el Arduino IDE, sigue primero la configuración de la placa del tutorial de IoT con ESP32; explica cómo instalar el paquete de placas esp32, elegir la placa y el puerto correctos y leer el monitor serie, todo lo cual necesitarás aquí. Después instala desde el Gestor de librerías de Arduino:

  • PubSubClient de Nick O'Leary.
  • Adafruit SSD1306 de Adafruit, que también instala Adafruit GFX Library y Adafruit BusIO.

El sketch ESP32 OLED MQTT

La regla clave de diseño: nunca dibujes dentro del callback de MQTT. El callback solo guarda el valor y una marca de tiempo y activa un flag. El dibujo se hace en loop() a un ritmo fijo. Así el callback sigue siendo rápido, se evitan pantallas a medio actualizar y la pantalla puede refrescar los contadores de "antigüedad" aunque no lleguen mensajes.

#include <WiFi.h>
#include <Wire.h>
#include <PubSubClient.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

const char* WIFI_SSID     = "YOUR_WIFI";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* MQTT_HOST     = "broker.connectmq.com";
const uint16_t MQTT_PORT  = 1883;
const char* CLIENT_ID     = "oled-5d20ab";             // único
const char* BASE          = "connectmq/demo/oled-5d20ab";

const unsigned long STALE_MS = 120000;                  // 2 minutos

Adafruit_SSD1306 display(128, 64, &Wire, -1);
WiFiClient net;
PubSubClient mqtt(net);

struct Row {
  const char* key;       // último nivel del topic
  const char* label;
  const char* unit;
  String value;
  unsigned long at;      // millis() al recibirlo, 0 = nunca
};

Row rows[] = {
  {"temp",     "Temp",     " C", "", 0},
  {"humidity", "Humidity", " %", "", 0},
  {"door",     "Door",     "",   "", 0},
  {"msg",      "Msg:",     "",   "", 0},
};
const int ROW_COUNT = sizeof(rows) / sizeof(rows[0]);
bool dirty = true;
unsigned long lastDraw = 0, lastAttempt = 0;

void onMessage(char* topic, byte* payload, unsigned int length) {
  const char* leaf = strrchr(topic, '/');
  if (!leaf) return;
  leaf++;
  for (int i = 0; i < ROW_COUNT; i++) {
    if (strcmp(leaf, rows[i].key) == 0) {
      String v;
      for (unsigned int j = 0; j < length && j < 40; j++) v += (char)payload[j];
      rows[i].value = v;
      rows[i].at = millis();
      dirty = true;
      return;
    }
  }
}

String ageText(unsigned long at) {
  unsigned long s = (millis() - at) / 1000;
  if (s < 60) return String(s) + "s";
  if (s < 3600) return String(s / 60) + "m";
  return String(s / 3600) + "h";
}

void draw() {
  display.clearDisplay();
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);

  // Barra de estado
  display.setCursor(0, 0);
  display.print("MQTT ");
  display.print(mqtt.connected() ? "OK" : (WiFi.status() == WL_CONNECTED ? "..." : "NO WIFI"));
  if (WiFi.status() == WL_CONNECTED) {
    display.setCursor(80, 0);
    display.print("RSSI ");
    display.print(WiFi.RSSI());
  }
  display.drawFastHLine(0, 10, 128, SSD1306_WHITE);

  int y = 14;
  for (int i = 0; i < ROW_COUNT; i++) {
    display.setCursor(0, y);
    display.print(rows[i].label);
    display.setCursor(i == 3 ? 30 : 60, y);
    if (rows[i].at == 0) {
      display.print("--");
    } else {
      bool stale = millis() - rows[i].at > STALE_MS;
      String text = rows[i].value + rows[i].unit;
      if (i == 3) text = text.substring(0, 16);         // ajusta la fila del mensaje
      display.print(text);
      if (stale && i != 3) {
        display.setCursor(104, y);
        display.print(ageText(rows[i].at));             // muestra la antigüedad solo si está obsoleto
      }
    }
    y += 12;
  }
  display.display();
}

bool connectMqtt() {
  if (!mqtt.connect(CLIENT_ID)) {
    Serial.printf("MQTT failed, state=%d\n", mqtt.state());
    return false;
  }
  String filter = String(BASE) + "/+";
  mqtt.subscribe(filter.c_str(), 1);
  Serial.println("Subscribed to " + filter);
  return true;
}

void setup() {
  Serial.begin(115200);
  Wire.begin(21, 22);
  if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
    Serial.println("SSD1306 not found");
    while (true) delay(1000);
  }
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(0, 0);
  display.print("Connecting...");
  display.display();

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  mqtt.setServer(MQTT_HOST, MQTT_PORT);
  mqtt.setCallback(onMessage);
  mqtt.setSocketTimeout(5);
}

void loop() {
  if (WiFi.status() == WL_CONNECTED) {
    if (!mqtt.connected()) {
      if (millis() - lastAttempt > 5000) { lastAttempt = millis(); connectMqtt(); }
    } else {
      mqtt.loop();
    }
  }
  // Redibuja con datos nuevos y una vez por segundo para actualizar estado y antigüedades
  if (dirty || millis() - lastDraw > 1000) {
    draw();
    dirty = false;
    lastDraw = millis();
  }
}

Por qué importan los retained messages en una pantalla

Cuando el ESP32 arranca, no sabe nada. Si los sensores publican sus lecturas como retained messages, el broker entrega a la pantalla el último valor de cada topic en cuanto se suscribe, y la pantalla se rellena en menos de un segundo. Sin retained messages, las filas muestran -- hasta la siguiente publicación de cada sensor, que en un dispositivo con batería podría tardar minutos. Si controlas a los publicadores, retén los valores que cambian despacio, como la temperatura, el estado de la puerta y la última notificación.

Pruébalo desde la línea de comandos

Publica algunos valores como si fueras los sensores:

mosquitto_pub -h broker.connectmq.com -p 1883 -i pub-5d20ab -r -t "connectmq/demo/oled-5d20ab/temp" -m "23.4"
mosquitto_pub -h broker.connectmq.com -p 1883 -i pub-5d20ab -r -t "connectmq/demo/oled-5d20ab/humidity" -m "48"
mosquitto_pub -h broker.connectmq.com -p 1883 -i pub-5d20ab -r -t "connectmq/demo/oled-5d20ab/door" -m "CLOSED"
mosquitto_pub -h broker.connectmq.com -p 1883 -i pub-5d20ab -t "connectmq/demo/oled-5d20ab/msg" -m "Coffee is ready"

Ahora pulsa el botón de reset del ESP32. Como los tres primeros se enviaron con -r, reaparecen casi de inmediato tras la reconexión; la fila del mensaje queda vacía porque no se retuvo. Espera dos minutos sin publicar y aparecerá el indicador de antigüedad junto a cada valor obsoleto.

Para borrar un valor retenido, publica un mensaje retenido vacío en ese topic:

mosquitto_pub -h broker.connectmq.com -p 1883 -i pub-5d20ab -r -n -t "connectmq/demo/oled-5d20ab/door"

Los datos retenidos en el servidor MQTT público gratuito son visibles para cualquiera que se suscriba, así que limítate allí a valores de demostración inofensivos: está pensado para pruebas y prototipos, no para datos de producción.

Elegir topics para una pantalla compartida

La demo pone todas las filas bajo el prefijo propio de la pantalla, lo que mantiene el ejemplo autocontenido. En una instalación real, los sensores suelen ser dueños de sus topics, por ejemplo home/livingroom/temp de un dispositivo y home/garage/door de otro. Tienes dos opciones. La primera es suscribirte explícitamente a cada topic y asignar las cadenas de topic completas a las filas, el enfoque más predecible y el adecuado cuando los topics proceden de distintas partes del árbol. La segunda es acordar una convención, como una rama display/ en la que los sensores o un pequeño flujo de Node-RED vuelvan a publicar. Así el firmware de la pantalla se mantiene genérico, y añadir una fila pasa a ser un cambio de configuración en lugar de un cambio de código. Elijas lo que elijas, recuerda que el área pública permite 10 suscripciones por cliente, así que un puñado de topics explícitos está bien, pero docenas no.

Consejos de legibilidad para pantallas diminutas

  1. Usa texto de tamaño 2 para el número más importante. Una pantalla de 128×64 admite unas cuatro líneas de texto de tamaño 2, suficiente para una lectura grande más una línea de estado.
  2. Alinea los números a la derecha para que los dígitos no salten al cambiar los valores. Mide el ancho del texto con display.getTextBounds().
  3. Redondea en el publicador, no en la pantalla. Enviar 23.4 en lugar de 23.41678 ahorra bytes y espacio en pantalla.
  4. Indica explícitamente cuándo un dato está obsoleto. Un valor congelado que parece actual es peor que uno en blanco. El contador de antigüedad es el indicador honesto más barato.
  5. Cuidado con el quemado. Los píxeles OLED envejecen con el uso. En paneles siempre encendidos, baja el contraste con display.dim(true) o desplaza la distribución un píxel cada pocos minutos.

Solución de problemas

  • Pantalla negra sin errores. Dirección I2C incorrecta o SDA/SCL intercambiados. Ejecuta un escáner I2C.
  • "SSD1306 not found" en una placa S3 o C3. Los pines por defecto son distintos; pasa los correctos a Wire.begin().
  • Texto ilegible con la imagen desplazada. Puede que tengas un módulo de 128×32; cambia la altura en el constructor.
  • Los valores nunca se actualizan pero el estado indica OK. Los topics no coinciden. El comodín + coincide exactamente con un nivel, así que connectmq/demo/oled-5d20ab/sensors/temp no coincidiría. Suscríbete a # mientras depuras.
  • La pantalla se congela durante segundos. Una reconexión bloqueante. Reduce el tiempo de espera del socket o usa el patrón de espera progresiva sin bloqueos de la lógica de reconexión MQTT del ESP32.

Del panel de demostración a una pantalla permanente

Una pantalla colgada en la pared de un taller o de una planta no debería depender del servidor público de pruebas. Crea un broker en el portal de ConnectMQ, un broker MQTT gestionado, conéctate con el usuario, la contraseña y el client ID generados por TLS en el puerto 8883 y apunta todos tus sensores al mismo endpoint. Un broker MQTT en la nube permite que el panel de casa muestre lecturas de un invernadero al otro lado de la ciudad sin redirigir puertos.

ConnectMQ también facilita la depuración: el explorador de topics en vivo muestra exactamente qué topics existen y qué hay retenido en cada uno, lo que suele responder en segundos a "¿por qué está vacía esta fila?". Para una salida más visual junto al texto, combina la OLED con una tira LED WS2812B controlada por MQTT a modo de luz de estado con código de colores.

Empieza con ConnectMQ

Abre app.connectmq.com, elige el plan gratuito Serverless (sin tarjeta de crédito) y crea un broker. Tu pantalla, tus sensores y tu móvil podrán compartir un endpoint seguro, y podrás ver cada mensaje en el portal mientras construyes.

Preguntas frecuentes

¿Puede la pantalla OLED suscribirse a topics de otros dispositivos?

Sí. Esa es la gracia de MQTT: cualquier dispositivo puede publicar en un topic y cualquier número de suscriptores, incluida esta pantalla, lo recibe. Solo tienen que coincidir los nombres de los topics.

¿Por qué no debo dibujar en la OLED dentro del callback de MQTT?

Dibujar por I2C lleva varios milisegundos, y pueden llegar varios mensajes en ráfaga. Guardar los valores en el callback y dibujar a un ritmo fijo en loop() mantiene el cliente ágil y la pantalla coherente.

¿Funciona con pantallas SH1106?

Los módulos de 1,3 pulgadas suelen usar el controlador SH1106. Usa una librería compatible, como U8g2 o un fork para SH1106 del driver de Adafruit; la parte MQTT del sketch no cambia.

¿Cómo muestro un valor de un payload JSON?

Analiza el payload con ArduinoJson en el callback, extrae el campo que necesites y guárdalo como valor de la fila. Mantén breve el análisis en el callback y deja el dibujo en loop().

Artículos relacionados

Ejemplo de ESP-IDF MQTT: crea un cliente esp-mqtt desde cero

Construye un proyecto ESP-IDF desde una carpeta vacía con el componente esp-mqtt: configuración de Wi-Fi en modo estación, el manejador de eventos MQTT, publicación y suscripción, propiedades de MQTT 5 y TLS con el bundle de certificados.

9 min de lectura

Todos los artículos →

Tu broker MQTT privado, a unos pocos clics

Crea una cuenta, obtén las credenciales de tu broker y conecta tu primer dispositivo, sin servidores que instalar, parchear ni escalar.