LED Matrix Information Display Intermediate

Build a scrolling LED display showing time, weather, and custom messages via MQTT

🔗Goal

Build a scrolling LED dot matrix display that shows the current time, date, and custom messages received via MQTT. The ESP32 syncs time from the internet using NTP and drives a chain of four MAX7219 8x8 LED modules, creating a 32x8 pixel display. You can push any text message to the display from your phone or computer using MQTT, and configure scroll speed and brightness remotely.

Here is how the system works at a high level:

graph LR
    subgraph Internet
        A[NTP Server]
        B[MQTT Broker]
    end
    subgraph ESP32
        C[ESP32 Controller]
    end
    subgraph Display
        D[MAX7219 8x32 LED Matrix]
    end
    subgraph Remote
        E[Phone / Computer]
    end

    A -->|Time sync| C
    B -->|Custom messages| C
    C -->|SPI| D
    E -->|Publish message| B

The display cycles through three content modes: current time, current date, and any custom message sent via MQTT. When no custom message is active, it alternates between time and date. When a message arrives, it scrolls across the display and then returns to showing the time.

🔗How the MAX7219 Works

The MAX7219 is a LED driver chip that controls an 8x8 matrix of LEDs using just three wires (SPI interface). Each chip handles 64 LEDs, and you can chain multiple chips together by connecting the DOUT of one chip to the DIN of the next. Four chained modules give you a 32x8 pixel display -- enough for scrolling text.

The SPI connection uses three signals:

SignalPurpose
DIN (Data In)Serial data from ESP32 to the first module
CLK (Clock)Clock signal that synchronizes data transfer
CS (Chip Select)Active-LOW signal that latches the data into the display

Data cascades from one module to the next through the DOUT-to-DIN chain. You only connect the ESP32 to the first module -- the rest are daisy-chained.

graph LR
    ESP32 -->|DIN| M1[Module 1]
    M1 -->|DOUT to DIN| M2[Module 2]
    M2 -->|DOUT to DIN| M3[Module 3]
    M3 -->|DOUT to DIN| M4[Module 4]

🔗Parts List

ComponentQtyNotes
ESP32 dev board1Any ESP32-WROOM-32 DevKit works
MAX7219 8x8 LED dot matrix module4Usually sold as a 4-in-1 module on a single PCB
Breadboard1Optional if using the 4-in-1 module with pin headers
Jumper wires~7Male-to-female

The 4-in-1 MAX7219 module is the easiest option. It has four 8x8 LED matrices already soldered to a single PCB with all the daisy-chain connections made. You only need to connect five wires to the ESP32.

If you buy individual 8x8 MAX7219 modules, connect the DOUT pin of each module to the DIN pin of the next one in the chain. All modules share the same CLK and CS lines.

You will also need the following Arduino libraries. Install them via Sketch > Include Library > Manage Libraries:

LibraryAuthorPurpose
MD_ParolaMajicDesignsText animation and scrolling for LED matrices
MD_MAX72XXMajicDesignsMAX7219 hardware driver (dependency of MD_Parola)
PubSubClientNick O'LearyMQTT client
WiFiEspressifBuilt into ESP32 Arduino core

The MD_Parola library provides smooth scrolling text, multiple text effects, and an easy API. It depends on MD_MAX72XX, which handles the low-level SPI communication with the MAX7219 chips.

🔗Wiring

🔗MAX7219 4-in-1 Module to ESP32

The MAX7219 communicates over SPI. The ESP32 has a hardware SPI interface (VSPI) on specific pins:

MAX7219 Module PinESP32 PinNotes
VCC5VThe MAX7219 needs 5V. Use the ESP32's 5V pin (USB power)
GNDGND
DINGPIO 23VSPI MOSI (Master Out, Slave In)
CSGPIO 5VSPI SS (Chip Select)
CLKGPIO 18VSPI SCK (Serial Clock)

The MAX7219 runs at 5V but accepts 3.3V logic on the data pins, so you can connect the ESP32 GPIO directly without level shifting.

Power note: At full brightness, a 4-module display can draw up to $320\,\text{mA}$ (each MAX7219 can draw up to $80\,\text{mA}$). The USB port on most computers can supply $500\,\text{mA}$, which is sufficient. If you use a higher brightness setting or chain more modules, use an external 5V supply.

The current draw per module depends on how many LEDs are lit. At maximum (all 64 LEDs on at full brightness):

$$I_{max} = 4 \times 80\,\text{mA} = 320\,\text{mA}$$

For scrolling text, typically 30-50% of LEDs are on at any time, so real-world current draw is closer to $100\text{--}160\,\text{mA}$.

🔗Complete Code

#include <WiFi.h>
#include <PubSubClient.h>
#include <MD_Parola.h>
#include <MD_MAX72xx.h>
#include <SPI.h>
#include <time.h>

// ==================== CONFIGURATION ====================

// WiFi
const char* WIFI_SSID     = "YOUR_WIFI_SSID";
const char* WIFI_PASSWORD  = "YOUR_WIFI_PASSWORD";

// MQTT
const char* MQTT_BROKER    = "test.mosquitto.org";
const int   MQTT_PORT      = 1883;
const char* MQTT_CLIENT_ID = "esp32_led_matrix";
const char* MQTT_MSG_TOPIC = "matrix/message";
const char* MQTT_CMD_TOPIC = "matrix/command";
const char* MQTT_STATUS_TOPIC = "matrix/status";

// NTP
const char* NTP_SERVER     = "pool.ntp.org";
const long  GMT_OFFSET_SEC = 0;      // Adjust for your timezone
const int   DST_OFFSET_SEC = 0;      // Daylight saving offset

// MAX7219
#define HARDWARE_TYPE MD_MAX72XX::FC16_HW  // Common 4-in-1 module type
#define MAX_DEVICES   4                     // Number of 8x8 modules
#define CS_PIN        5                     // Chip select pin

// Display settings
#define DEFAULT_BRIGHTNESS  4    // 0 (dimmest) to 15 (brightest)
#define DEFAULT_SCROLL_SPEED 50  // Milliseconds per frame (lower = faster)

// ==================== OBJECTS ====================

MD_Parola matrix = MD_Parola(HARDWARE_TYPE, CS_PIN, MAX_DEVICES);
WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);

// ==================== STATE ====================

char timeStr[16];
char dateStr[16];
char customMsg[256] = "";
bool hasCustomMsg = false;

int scrollSpeed = DEFAULT_SCROLL_SPEED;
int brightness  = DEFAULT_BRIGHTNESS;

// Display cycling
enum DisplayState { SHOW_TIME, SHOW_DATE, SHOW_CUSTOM };
DisplayState displayState = SHOW_TIME;

unsigned long lastTimeUpdate = 0;
unsigned long lastDisplaySwitch = 0;
const unsigned long TIME_UPDATE_INTERVAL = 1000;  // Update time every second
const unsigned long DISPLAY_CYCLE_INTERVAL = 5000; // Switch between time/date every 5s

bool timeValid = false;

// ==================== WIFI ====================

void connectWiFi() {
    if (WiFi.status() == WL_CONNECTED) return;

    Serial.print("Connecting to WiFi");
    WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
    int attempts = 0;
    while (WiFi.status() != WL_CONNECTED && attempts < 40) {
        delay(500);
        Serial.print(".");
        attempts++;
    }
    if (WiFi.status() == WL_CONNECTED) {
        Serial.println();
        Serial.print("Connected! IP: ");
        Serial.println(WiFi.localIP());
    } else {
        Serial.println();
        Serial.println("WiFi connection failed. Will retry...");
    }
}

// ==================== NTP ====================

void updateTimeStrings() {
    struct tm timeinfo;
    if (!getLocalTime(&timeinfo)) {
        strcpy(timeStr, "--:--");
        strcpy(dateStr, "No NTP");
        timeValid = false;
        return;
    }

    timeValid = true;

    // Format time as HH:MM:SS
    strftime(timeStr, sizeof(timeStr), "%H:%M:%S", &timeinfo);

    // Format date as DD Mon YYYY (e.g., "04 Feb 2026")
    strftime(dateStr, sizeof(dateStr), "%d %b %Y", &timeinfo);
}

// ==================== MQTT ====================

void mqttCallback(char* topic, byte* payload, unsigned int length) {
    String message;
    for (unsigned int i = 0; i < length; i++) {
        message += (char)payload[i];
    }
    Serial.print("MQTT [");
    Serial.print(topic);
    Serial.print("]: ");
    Serial.println(message);

    String topicStr = String(topic);

    // Handle message topic: display custom text
    if (topicStr == MQTT_MSG_TOPIC) {
        if (message.length() > 0 && message.length() < 256) {
            message.toCharArray(customMsg, sizeof(customMsg));
            hasCustomMsg = true;
            displayState = SHOW_CUSTOM;

            // Reset the display to show the new message immediately
            matrix.displayReset();
            matrix.displayText(customMsg, PA_CENTER, scrollSpeed, 2000, PA_SCROLL_LEFT, PA_SCROLL_LEFT);

            Serial.print("Custom message set: ");
            Serial.println(customMsg);
        }
        return;
    }

    // Handle command topic: settings
    // "brightness:X"  - set brightness (0-15)
    // "speed:XX"      - set scroll speed in ms (10-200)
    // "clear"         - clear custom message, return to time
    // "status"        - request current status

    if (message.startsWith("brightness:")) {
        int val = message.substring(11).toInt();
        if (val >= 0 && val <= 15) {
            brightness = val;
            matrix.setIntensity(brightness);
            Serial.print("Brightness set to: ");
            Serial.println(brightness);
        }
    }
    else if (message.startsWith("speed:")) {
        int val = message.substring(6).toInt();
        if (val >= 10 && val <= 200) {
            scrollSpeed = val;
            Serial.print("Scroll speed set to: ");
            Serial.print(scrollSpeed);
            Serial.println(" ms");
        }
    }
    else if (message == "clear") {
        hasCustomMsg = false;
        customMsg[0] = '\0';
        displayState = SHOW_TIME;
        matrix.displayReset();
        Serial.println("Custom message cleared.");
    }
    else if (message == "status") {
        publishStatus();
    }
}

void connectMQTT() {
    if (mqtt.connected()) return;

    Serial.print("Connecting to MQTT...");
    while (!mqtt.connected()) {
        if (mqtt.connect(MQTT_CLIENT_ID)) {
            Serial.println(" connected!");
            mqtt.subscribe(MQTT_MSG_TOPIC);
            mqtt.subscribe(MQTT_CMD_TOPIC);
            Serial.print("Subscribed to: ");
            Serial.print(MQTT_MSG_TOPIC);
            Serial.print(" and ");
            Serial.println(MQTT_CMD_TOPIC);
        } else {
            Serial.print(" failed (rc=");
            Serial.print(mqtt.state());
            Serial.println("). Retrying in 5 seconds...");
            delay(5000);
        }
    }
}

void publishStatus() {
    String payload = "{";
    payload += "\"brightness\":" + String(brightness) + ",";
    payload += "\"speed\":" + String(scrollSpeed) + ",";
    payload += "\"time\":\"" + String(timeStr) + "\",";
    payload += "\"date\":\"" + String(dateStr) + "\",";
    payload += "\"custom_message\":\"" + String(customMsg) + "\",";
    payload += "\"display_state\":\"";
    switch (displayState) {
        case SHOW_TIME:   payload += "time"; break;
        case SHOW_DATE:   payload += "date"; break;
        case SHOW_CUSTOM: payload += "custom"; break;
    }
    payload += "\"";
    payload += "}";
    mqtt.publish(MQTT_STATUS_TOPIC, payload.c_str());
}

// ==================== DISPLAY LOGIC ====================

void showTime() {
    matrix.setTextAlignment(PA_CENTER);
    matrix.print(timeStr);
}

void showDate() {
    matrix.setTextAlignment(PA_LEFT);
    matrix.displayText(dateStr, PA_LEFT, scrollSpeed, 2000, PA_SCROLL_LEFT, PA_SCROLL_LEFT);
}

void handleDisplay() {
    unsigned long now = millis();

    // Update time strings every second
    if (now - lastTimeUpdate >= TIME_UPDATE_INTERVAL) {
        lastTimeUpdate = now;
        updateTimeStrings();
    }

    if (displayState == SHOW_CUSTOM) {
        // Scroll the custom message
        if (matrix.displayAnimate()) {
            // Animation finished -- return to time display
            hasCustomMsg = false;
            displayState = SHOW_TIME;
            matrix.displayReset();
        }
        return;
    }

    if (displayState == SHOW_DATE) {
        // Scroll the date
        if (matrix.displayAnimate()) {
            // Animation finished -- switch back to time
            displayState = SHOW_TIME;
            matrix.displayReset();
            lastDisplaySwitch = now;
        }
        return;
    }

    // SHOW_TIME: static display, update every second
    showTime();

    // Periodically switch to date display
    if (now - lastDisplaySwitch >= DISPLAY_CYCLE_INTERVAL) {
        lastDisplaySwitch = now;
        displayState = SHOW_DATE;
        showDate();
    }
}

// ==================== SETUP ====================

void setup() {
    Serial.begin(115200);

    // Initialize the LED matrix
    matrix.begin();
    matrix.setIntensity(brightness);
    matrix.displayClear();
    matrix.setTextAlignment(PA_CENTER);
    matrix.print("INIT");
    Serial.println("LED matrix initialized.");

    // Connect WiFi
    connectWiFi();

    // Configure NTP
    configTime(GMT_OFFSET_SEC, DST_OFFSET_SEC, NTP_SERVER);
    Serial.println("NTP configured. Waiting for time sync...");

    // Wait briefly for initial NTP sync
    delay(2000);
    updateTimeStrings();
    if (timeValid) {
        Serial.print("Time synced: ");
        Serial.println(timeStr);
    } else {
        Serial.println("NTP not yet synced. Will retry automatically.");
    }

    // Initialize MQTT
    mqtt.setServer(MQTT_BROKER, MQTT_PORT);
    mqtt.setCallback(mqttCallback);
    connectMQTT();

    matrix.displayClear();
    Serial.println("LED matrix display ready.");
}

// ==================== MAIN LOOP ====================

void loop() {
    // Maintain connections
    connectWiFi();
    if (!mqtt.connected()) {
        connectMQTT();
    }
    mqtt.loop();

    // Handle display updates
    handleDisplay();
}

🔗Testing

🔗Step 1: Verify the Display

After uploading, the display should show "INIT" briefly, then switch to showing the current time. If the time shows as "--:--", NTP has not synced yet -- check your WiFi connection and wait a few seconds.

If the display shows nothing, check:

  1. The wiring matches the pin table above
  2. The HARDWARE_TYPE constant matches your module. Common types are:
    • FC16_HW -- most 4-in-1 modules sold online
    • PAROLA_HW -- Parola-branded modules
    • GENERIC_HW -- some generic Chinese modules

If the text appears mirrored or on the wrong end, try changing HARDWARE_TYPE. This is the most common issue with MAX7219 modules.

🔗Step 2: Test MQTT Messages

Send a custom message from any computer:

# Send a message to display
mosquitto_pub -h test.mosquitto.org -t "matrix/message" -m "Hello World!"

# Send a longer scrolling message
mosquitto_pub -h test.mosquitto.org -t "matrix/message" -m "ESP32 LED Matrix Display - Working!"

The custom message should scroll across the display, then the display returns to showing the time.

🔗Step 3: Adjust Settings

# Set brightness to maximum (15)
mosquitto_pub -h test.mosquitto.org -t "matrix/command" -m "brightness:15"

# Set brightness to minimum (0)
mosquitto_pub -h test.mosquitto.org -t "matrix/command" -m "brightness:0"

# Make scrolling faster (lower = faster, minimum 10)
mosquitto_pub -h test.mosquitto.org -t "matrix/command" -m "speed:25"

# Make scrolling slower (higher = slower, maximum 200)
mosquitto_pub -h test.mosquitto.org -t "matrix/command" -m "speed:100"

# Clear any custom message and return to time
mosquitto_pub -h test.mosquitto.org -t "matrix/command" -m "clear"

# Request current status
mosquitto_pub -h test.mosquitto.org -t "matrix/command" -m "status"

Subscribe to see status responses:

mosquitto_sub -h test.mosquitto.org -t "matrix/status"

Example status output:

{
  "brightness": 4,
  "speed": 50,
  "time": "14:30:45",
  "date": "04 Feb 2026",
  "custom_message": "",
  "display_state": "time"
}

🔗Step 4: Configure Your Timezone

Adjust GMT_OFFSET_SEC for your timezone. Multiply your UTC offset by 3600:

TimezoneUTC OffsetGMT_OFFSET_SEC
GMT / UTC+00
CET (Paris, Berlin)+13600
EET (Helsinki, Athens)+27200
EST (New York)-5-18000
PST (Los Angeles)-8-28800

If your region observes daylight saving time, set DST_OFFSET_SEC to 3600 during summer months.

🔗Step 5: Choose the Right Hardware Type

The HARDWARE_TYPE setting depends on how the LED modules are wired on the PCB. If text displays incorrectly, try each option:

HARDWARE_TYPESymptom if wrong
FC16_HWMost common. Try this first
PAROLA_HWText scrolls right-to-left starting from wrong end
GENERIC_HWCharacters appear mirrored horizontally
ICSTATION_HWCharacters appear upside down

Change the #define HARDWARE_TYPE line and re-upload until text appears correctly.

🔗MQTT Topics Reference

TopicDirectionDescription
matrix/messageESP32 subscribesText to display (scrolls across, then returns to time)
matrix/commandESP32 subscribesSettings commands (brightness, speed, clear, status)
matrix/statusESP32 publishesJSON with current state (in response to status command)

🔗Available Commands

CommandExampleDescription
Any text on matrix/messageHello!Scroll this text across the display
brightness:Xbrightness:8Set LED brightness (0 to 15)
speed:XXspeed:30Set scroll speed in ms per frame (10 to 200)
clearclearClear custom message, return to clock
statusstatusRequest current status JSON

🔗Understanding SPI Communication

The MAX7219 uses SPI (Serial Peripheral Interface), which is a high-speed synchronous protocol. Unlike I2C, SPI has separate lines for data in and data out, making it faster but requiring more wires:

SPI SignalESP32 PinMAX7219 PinPurpose
MOSIGPIO 23DINData from ESP32 to display
SCKGPIO 18CLKClock signal
SS (CS)GPIO 5CSChip select (active-LOW)

SPI has no addressing -- each device needs its own CS line (or is daisy-chained). The MAX7219 modules are daisy-chained, so they all share the same CS, CLK, and DIN lines. Data shifts through the chain: when you send data for four modules, the first byte passes through all modules, and each module latches its own data when CS goes HIGH.

The maximum SPI clock speed for the MAX7219 is $10\,\text{MHz}$. The MD_MAX72XX library handles the SPI timing automatically.

🔗Common Issues and Solutions

ProblemCauseFix
Display is blankWiring error or wrong CS pinVerify DIN goes to GPIO 23, CLK to GPIO 18, and CS to GPIO 5. Check that VCC is connected to 5V (not 3.3V)
Text is mirrored or upside downWrong HARDWARE_TYPETry FC16_HW, PAROLA_HW, GENERIC_HW, or ICSTATION_HW until text looks correct
Only one module lights upDaisy-chain connection brokenCheck DOUT-to-DIN connections between modules. If using a 4-in-1 PCB, check for cold solder joints
MAX_DEVICES is wrongModule count mismatchSet MAX_DEVICES to the number of 8x8 modules (4 for a standard 4-in-1 board)
Time shows "--:--"NTP has not syncedVerify WiFi is connected. NTP needs internet access. Wait 5-10 seconds after boot
Time is wrong by several hoursTimezone not configuredAdjust GMT_OFFSET_SEC. Multiply your UTC offset by 3600
Display flickers or dimsInsufficient powerThe 4-module display can draw up to 320 mA at full brightness. Use a reliable USB power source or external 5V supply
Scrolling text is too fast or too slowDefault speed not suitableSend speed:XX via MQTT (10-200 ms). Lower values scroll faster
MQTT messages do not appearTopic mismatchEnsure you publish to matrix/message (not matrix/command). Topics are case-sensitive
Characters are garbledLibrary mismatch or old versionUpdate MD_Parola and MD_MAX72XX to the latest version via Library Manager
Display shows random dots on bootNormal -- modules power on in undefined stateThe code clears the display in setup(). The brief flicker is harmless

🔗Extending the Project

Here are ideas to take this project further:

  • Weather data: Fetch temperature and conditions from a free weather API (like Open-Meteo) and display them in the rotation cycle. No API key is required for Open-Meteo
  • Notification hub: Subscribe to multiple MQTT topics (email alerts, smart home events, sensor warnings) and display them as they arrive
  • Multiple display effects: MD_Parola supports many animation effects beyond scrolling -- try PA_SCROLL_UP, PA_DISSOLVE, PA_BLINDS, PA_WIPE, and others
  • Brightness scheduling: Automatically dim the display at night using NTP time (e.g., brightness 2 from 22:00 to 07:00, brightness 8 during the day)
  • Countdown timer: Add MQTT commands to start a countdown (e.g., countdown:300 for a 5-minute timer) displayed on the matrix
  • Two-line display: Chain eight modules (8x64) and use MD_Parola zones to show time on the top row and a scrolling message on the bottom row
  • Sensor integration: Add a BME280 or DHT22 sensor and cycle through time, date, temperature, and humidity readings on the display
  • Home Assistant integration: Use MQTT discovery so Home Assistant can send notifications directly to your display