ESP32 and Arduino

ESP32 OLED MQTT: Show Live MQTT Messages on an SSD1306 Display

Turn an ESP32 and a cheap SSD1306 OLED into a live MQTT display that shows sensor values and messages from any device, flags stale readings and shows its own connection status.

Small SSD1306 screen on an ESP32 showing temperature values in an esp32 oled mqtt display
Run this on your own MQTT brokerGet a private, managed MQTT broker on the Free plan and start from ready-made code samples.
Try it free Code samples No card required

An ESP32 OLED MQTT display is the simplest way to put live data on a desk, a workbench or a machine panel without a phone or a browser. In this tutorial you will subscribe to several topics on broker.connectmq.com, render the latest values on a 128×64 SSD1306 screen, mark readings as stale when a sensor stops reporting, and show the display's own Wi-Fi and MQTT status. The same sketch works unchanged when you move to private credentials.

What the Display Shows

A good tiny dashboard answers three questions at a glance: what is the value, how old is it, and is the display itself connected. The layout used here fits all three on a 0.96-inch screen:

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

Each row is bound to one topic. The display subscribes with a single wildcard and routes incoming messages to rows by matching the last topic level.

Topic Row Example payload
connectmq/demo/<id>/temp Temp 23.4
connectmq/demo/<id>/humidity Humidity 48
connectmq/demo/<id>/door Door CLOSED
connectmq/demo/<id>/msg Msg Any short text

In a real setup, the values come from other devices: an ESP32 with a DHT22 sensor publishing temperature and humidity, a door contact, or a script sending notifications. The display does not care who publishes, which is the main strength of the publish/subscribe model. If that decoupling is new to you, read about what an MQTT broker does before you start.

Hardware and Wiring

  • ESP32 development board.
  • SSD1306 OLED module, 128×64 pixels, I2C version (four pins: GND, VCC, SCL, SDA).
  • Four jumper wires.
OLED pin ESP32 pin
GND GND
VCC 3V3
SCL GPIO 22
SDA GPIO 21

GPIO 21 and 22 are the default I2C pins on classic ESP32 DevKit boards. Other variants such as the ESP32-S3 or C3 use different defaults, so pass your pins to Wire.begin(SDA, SCL) explicitly, as the sketch does. Most modules use I2C address 0x3C; a few use 0x3D. If the screen stays black, run an I2C scanner sketch to confirm the address.

Libraries

If you have never flashed an ESP32 from the Arduino IDE, follow the board setup in the ESP32 IoT tutorial first; it covers installing the esp32 board package, choosing the right board and port, and reading the serial monitor, all of which you will need here. Then install through the Arduino Library Manager:

  • PubSubClient by Nick O'Leary.
  • Adafruit SSD1306 by Adafruit, which also pulls in Adafruit GFX Library and Adafruit BusIO.

The ESP32 OLED MQTT Sketch

The key design rule: never draw inside the MQTT callback. The callback only stores the value and a timestamp and sets a flag. Drawing happens in loop() at a fixed rate. That keeps the callback fast, avoids half-updated screens, and lets the display refresh "age" counters even when no messages arrive.

#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";             // unique
const char* BASE          = "connectmq/demo/oled-5d20ab";

const unsigned long STALE_MS = 120000;                  // 2 minutes

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

struct Row {
  const char* key;       // last topic level
  const char* label;
  const char* unit;
  String value;
  unsigned long at;      // millis() when received, 0 = never
};

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

  // Status bar
  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);         // fit the message row
      display.print(text);
      if (stale && i != 3) {
        display.setCursor(104, y);
        display.print(ageText(rows[i].at));             // show age only when stale
      }
    }
    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();
    }
  }
  // Redraw on new data, and once a second to update status and ages
  if (dirty || millis() - lastDraw > 1000) {
    draw();
    dirty = false;
    lastDraw = millis();
  }
}

Why retained messages matter for a display

When the ESP32 boots, it knows nothing. If the sensors publish their readings as retained messages, the broker hands the display the latest value of every topic the moment it subscribes, and the screen is populated within a second. Without retained messages the rows show -- until each sensor's next publish, which could be minutes away for a battery device. If you control the publishers, retain slow-changing values such as temperature, door state and the last notification.

Test It from the Command Line

Publish some values as if you were the sensors:

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"

Now press the ESP32's reset button. Because the first three were sent with -r, they reappear almost immediately after reconnect; the message row stays empty because it was not retained. Wait two minutes without publishing and the age indicator appears next to each stale value.

To clear a retained value, publish an empty retained message to that topic:

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

Retained data on the public MQTT test broker is visible to anyone who subscribes, so stick to harmless demo values there.

Choosing Topics for a Shared Display

The demo puts every row under the display's own prefix, which keeps the example self-contained. In a real installation the sensors usually own their topics, for example home/livingroom/temp from one device and home/garage/door from another. You have two options. The first is to subscribe to each topic explicitly and map full topic strings to rows, which is the most predictable approach and the one to use when topics come from different parts of the tree. The second is to agree on a convention, such as a display/ branch that sensors or a small Node-RED flow republish into. That keeps the display firmware generic, and adding a row becomes a configuration change rather than a code change. Whichever you choose, remember the public area allows 10 subscriptions per client, so a handful of explicit topics is fine while dozens are not.

Readability Tips for Tiny Screens

  1. Use size 2 text for the single most important number. A 128×64 screen fits about four lines of size 2 text, enough for one large reading plus a status line.
  2. Right-align numbers so digits do not jump around as values change. Measure text width with display.getTextBounds().
  3. Round in the publisher, not the display. Sending 23.4 instead of 23.41678 saves bytes and screen space.
  4. Show staleness explicitly. A frozen value that looks current is worse than a blank one. The age counter is the cheapest honest indicator.
  5. Mind burn-in. OLED pixels age with use. For always-on panels, lower contrast with display.dim(true) or shift the layout by a pixel every few minutes.

Troubleshooting

  • Black screen, no errors. Wrong I2C address or swapped SDA/SCL. Run an I2C scanner.
  • "SSD1306 not found" on an S3 or C3 board. Default pins differ; pass the right ones to Wire.begin().
  • Garbled text with a shifted image. You may have a 128×32 module; change the constructor height.
  • Values never update but the status shows OK. Topic mismatch. The wildcard + matches exactly one level, so connectmq/demo/oled-5d20ab/sensors/temp would not match. Subscribe to # while debugging.
  • Display freezes for seconds at a time. A blocking reconnect. Lower the socket timeout or use the non-blocking backoff pattern from ESP32 MQTT reconnect logic.

From Demo Panel to Permanent Display

A display that hangs on a workshop wall should not depend on a public test broker. Create a broker in the ConnectMQ portal, connect with the generated username, password and client ID over TLS on port 8883, and point all your sensors at the same endpoint. A cloud MQTT broker lets the panel at home show readings from a greenhouse across town without any port forwarding.

ConnectMQ also makes debugging easier: the live topic explorer shows exactly which topics exist and what is retained on each, which usually answers "why is this row empty?" in seconds. For a more visual output beside the text, combine the OLED with a WS2812B LED strip controlled over MQTT as a color-coded status light.

Get Started with ConnectMQ

Open app.connectmq.com, pick the free Serverless plan (no credit card), and create a broker. Your display, sensors and phone can then share one secure endpoint, and you can watch every message in the portal while you build.

Frequently Asked Questions

Can the OLED display subscribe to topics from other devices?

Yes. That is the point of MQTT: any device can publish to a topic and any number of subscribers, including this display, receive it. Only the topic names need to match.

Why should I not draw on the OLED inside the MQTT callback?

Drawing takes several milliseconds over I2C, and several messages can arrive in a burst. Storing values in the callback and drawing at a fixed rate in loop() keeps the client responsive and the screen consistent.

Does this work with SH1106 displays?

The 1.3-inch modules often use the SH1106 controller instead. Use a library that supports it, such as U8g2 or an SH1106 fork of the Adafruit driver; the MQTT part of the sketch stays the same.

How do I show a value from a JSON payload?

Parse the payload with ArduinoJson in the callback, extract the field you need, and store it as the row value. Keep the parsing in the callback short and the drawing in loop().

Related articles

ESP-IDF MQTT Example: Build an esp-mqtt Client from Scratch

Build an ESP-IDF project from an empty folder with the esp-mqtt component: Wi-Fi station setup, the MQTT event handler, publish and subscribe, MQTT 5 properties and TLS with the certificate bundle.

8 min read

Using esp-mqtt MQTT 5 Features on ESP32 with ESP-IDF

A hands-on ESP-IDF project that enables MQTT 5.0 in esp-mqtt and uses user properties, message expiry, topic aliases, session expiry and response topics against a real MQTT 5 broker.

7 min read

All articles →

Your private MQTT broker is a few clicks away

Create an account, get your broker credentials and connect your first device — no servers to install, patch or scale.