🔗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| BThe 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:
| Signal | Purpose |
|---|---|
| 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
| Component | Qty | Notes |
|---|---|---|
| ESP32 dev board | 1 | Any ESP32-WROOM-32 DevKit works |
| MAX7219 8x8 LED dot matrix module | 4 | Usually sold as a 4-in-1 module on a single PCB |
| Breadboard | 1 | Optional if using the 4-in-1 module with pin headers |
| Jumper wires | ~7 | Male-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:
| Library | Author | Purpose |
|---|---|---|
| MD_Parola | MajicDesigns | Text animation and scrolling for LED matrices |
| MD_MAX72XX | MajicDesigns | MAX7219 hardware driver (dependency of MD_Parola) |
| PubSubClient | Nick O'Leary | MQTT client |
| WiFi | Espressif | Built 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 Pin | ESP32 Pin | Notes |
|---|---|---|
| VCC | 5V | The MAX7219 needs 5V. Use the ESP32's 5V pin (USB power) |
| GND | GND | |
| DIN | GPIO 23 | VSPI MOSI (Master Out, Slave In) |
| CS | GPIO 5 | VSPI SS (Chip Select) |
| CLK | GPIO 18 | VSPI 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:
- The wiring matches the pin table above
- The
HARDWARE_TYPEconstant matches your module. Common types are:FC16_HW-- most 4-in-1 modules sold onlinePAROLA_HW-- Parola-branded modulesGENERIC_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:
| Timezone | UTC Offset | GMT_OFFSET_SEC |
|---|---|---|
| GMT / UTC | +0 | 0 |
| CET (Paris, Berlin) | +1 | 3600 |
| EET (Helsinki, Athens) | +2 | 7200 |
| 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_TYPE | Symptom if wrong |
|---|---|
FC16_HW | Most common. Try this first |
PAROLA_HW | Text scrolls right-to-left starting from wrong end |
GENERIC_HW | Characters appear mirrored horizontally |
ICSTATION_HW | Characters appear upside down |
Change the #define HARDWARE_TYPE line and re-upload until text appears correctly.
🔗MQTT Topics Reference
| Topic | Direction | Description |
|---|---|---|
matrix/message | ESP32 subscribes | Text to display (scrolls across, then returns to time) |
matrix/command | ESP32 subscribes | Settings commands (brightness, speed, clear, status) |
matrix/status | ESP32 publishes | JSON with current state (in response to status command) |
🔗Available Commands
| Command | Example | Description |
|---|---|---|
Any text on matrix/message | Hello! | Scroll this text across the display |
brightness:X | brightness:8 | Set LED brightness (0 to 15) |
speed:XX | speed:30 | Set scroll speed in ms per frame (10 to 200) |
clear | clear | Clear custom message, return to clock |
status | status | Request 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 Signal | ESP32 Pin | MAX7219 Pin | Purpose |
|---|---|---|---|
| MOSI | GPIO 23 | DIN | Data from ESP32 to display |
| SCK | GPIO 18 | CLK | Clock signal |
| SS (CS) | GPIO 5 | CS | Chip 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
| Problem | Cause | Fix |
|---|---|---|
| Display is blank | Wiring error or wrong CS pin | Verify 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 down | Wrong HARDWARE_TYPE | Try FC16_HW, PAROLA_HW, GENERIC_HW, or ICSTATION_HW until text looks correct |
| Only one module lights up | Daisy-chain connection broken | Check DOUT-to-DIN connections between modules. If using a 4-in-1 PCB, check for cold solder joints |
MAX_DEVICES is wrong | Module count mismatch | Set MAX_DEVICES to the number of 8x8 modules (4 for a standard 4-in-1 board) |
| Time shows "--:--" | NTP has not synced | Verify WiFi is connected. NTP needs internet access. Wait 5-10 seconds after boot |
| Time is wrong by several hours | Timezone not configured | Adjust GMT_OFFSET_SEC. Multiply your UTC offset by 3600 |
| Display flickers or dims | Insufficient power | The 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 slow | Default speed not suitable | Send speed:XX via MQTT (10-200 ms). Lower values scroll faster |
| MQTT messages do not appear | Topic mismatch | Ensure you publish to matrix/message (not matrix/command). Topics are case-sensitive |
| Characters are garbled | Library mismatch or old version | Update MD_Parola and MD_MAX72XX to the latest version via Library Manager |
| Display shows random dots on boot | Normal -- modules power on in undefined state | The 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:300for 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