Community project
ESP32 Scrolling Message Board

Build a WiFi-connected scrolling message board using an ESP32 microcontroller and a MAX7219 LED dot matrix module. This project displays custom messages, real-time clock updates, and animated transitions across a bright 8×32 pixel display that can be controlled remotely via a web interface.
The guide includes a complete wiring diagram showing how to connect the LED matrix to the ESP32, a full parts list, and ready-to-flash firmware with NTP time synchronization and a REST API for posting messages. Assembly takes under an hour, and the step-by-step instructions cover hardware setup, WiFi configuration, and testing the display and web controls.
Wiring diagram
Interactive · read-only
Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.
Parts list
Bill of materials| Component | Qty | Notes |
|---|---|---|
| MAX7219 8x32 LED Dot Matrix Module | 1 | Four cascaded 8x8 LED matrices driven by MAX7219 chips over SPI. |
Assembly
2 stepsWire the LED matrix to the ESP32
Connect the MAX7219 module VCC to 5V, GND to GND, DIN to GPIO23, CS to GPIO5, and CLK to GPIO18.
- Tip: GPIO23 is the default VSPI MOSI pin, GPIO18 is VSPI CLK, and GPIO5 is VSPI SS, so this keeps your SPI bus on its default hardware pins.
- Tip: If the module has a 4-in-1 design, only the input header needs wiring. The four 8x8 panels are already daisy-chained.
- ⚠ Power the matrix from the USB 5V rail, not the 3.3V pin. The MAX7219 needs 5V to drive the LEDs at full brightness.
Flash and test
Upload the sketch and open Serial Monitor at 115200 baud. You should see scrolling text across all four panels. Adjust display.setIntensity(4) from 0 to 15 to control brightness.
- Tip: If text scrolls backwards or panels appear in the wrong order, try changing HARDWARE_TYPE to MD_MAX72XX::GENERIC_HW.
- Tip: Lower intensity values save power and reduce eye strain at your desk.
Pin assignments
Board wiring reference| Pin | Connection | Type |
|---|---|---|
| 5V | led-matrix-1 VCC | power |
| GND | led-matrix-1 GND | ground |
| GPIO 23 | led-matrix-1 DIN | spi |
| GPIO 5 | led-matrix-1 CS | spi |
| GPIO 18 | led-matrix-1 CLK | spi |
Firmware
ESP32#include <Arduino.h>
#include <SPI.h>
#include <MD_Parola.h>
#include <MD_MAX72xx.h>
#include <WiFi.h>
#include <WebServer.h>
#include <ArduinoJson.h>
#include <time.h>
// ESPmDNS removed — IP shown on display at boot for 60s
// ─── NTP / Time ───────────────────────────────────────────────────────────────
#define NTP_SERVER "pool.ntp.org"
#define GMT_OFFSET_SEC 3600 // UK GMT+0 (BST = 3600, change to 0 in winter)
#define DAYLIGHT_OFFSET 3600 // 1 hour DST
// Forward declarations
// Forward declarations
void playWipeAnimation();
bool updateClock();
bool ntpSynced = false;
unsigned long lastClockUpdate = 0;
char clockStr[16]; // e.g. "14:32:05"
// ─── User config ────────────────────────────────────────────────────────────
#define WIFI_SSID "YOUR_SSID"
#define WIFI_PASSWORD "YOUR_PASSWORD"
#define DEVICE_NAME "matrix" // Access via http://matrix.local
// ─── Display ─────────────────────────────────────────────────────────────────
#define HARDWARE_TYPE MD_MAX72XX::FC16_HW
#define MAX_DEVICES 4
#define DATA_PIN 23
#define CS_PIN 5
#define CLK_PIN 18
#define SCROLL_SPEED 40 // ms per frame — lower = faster
#define BRIGHTNESS 4 // 0-15
// Forward declarations
void showCurrent();
void jsonReply(int code, const String &body);
void handlePostMessage();
void handlePutNow();
void handleGetMessages();
void handleDeleteMessages();
void handlePatchSettings();
void handleRoot();
void handleGetStatus();
void checkWiFi();
MD_Parola display(HARDWARE_TYPE, DATA_PIN, CLK_PIN, CS_PIN, MAX_DEVICES);
// ─── Message queue ────────────────────────────────────────────────────────────
#define MAX_MESSAGES 20
#define MAX_MSG_LEN 128
char msgQueue[MAX_MESSAGES][MAX_MSG_LEN];
int msgPlayCount[MAX_MESSAGES]; // how many times each message has scrolled
int msgCount = 0;
int currentMsg = 0;
#define MSG_PLAY_LIMIT 3 // remove a message after scrolling this many times
// Update clockStr with current NTP time, returns true if time is valid
bool updateClock() {
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) return false;
strftime(clockStr, sizeof(clockStr), "%H:%M", &timeinfo);
return true;
}
// Wave: fill all LEDs on left-to-right, then wipe off left-to-right
void playWipeAnimation() {
int totalCols = MAX_DEVICES * 8; // 32 columns
MD_MAX72XX *mx = display.getGraphicObject();
// Phase 1: fill on
for (int col = 0; col < totalCols; col++) {
mx->setColumn(col, 0xFF);
delay(12);
}
delay(80);
// Phase 2: wipe off
for (int col = 0; col < totalCols; col++) {
mx->setColumn(col, 0x00);
delay(12);
}
delay(40);
}
void showCurrent() {
if (msgCount == 0) {
// Idle — show HH:MM clock, static
if (ntpSynced && updateClock()) {
display.displayText(clockStr, PA_CENTER, 0, 2000, PA_PRINT, PA_NO_EFFECT);
} else {
display.displayScroll("Waiting for NTP...", PA_LEFT, PA_SCROLL_LEFT, SCROLL_SPEED);
}
} else {
// Wipe animation before each message so it stands apart from the clock
playWipeAnimation();
display.displayScroll(msgQueue[currentMsg], PA_LEFT, PA_SCROLL_LEFT, SCROLL_SPEED);
}
}
// ─── Wi-Fi watchdog ──────────────────────────────────────────────────────────
#define WIFI_CHECK_INTERVAL_MS 10000 // check every 10 seconds
#define WIFI_CONNECT_TIMEOUT_MS 15000 // max time to wait for a connection attempt
#define IP_SCROLL_DURATION_MS 60000 // scroll IP for 60 seconds on boot
unsigned long lastWiFiCheck = 0;
unsigned long reconnectStarted = 0; // when the last reconnect attempt began
bool reconnecting = false; // true while waiting for WL_CONNECTED
bool wifiConnected = false;
bool showingOffline = false;
bool showingIP = false;
unsigned long ipScrollEnd = 0;
unsigned long uptimeStart = 0;
char ipScrollMsg[32];
// ─── Web server ───────────────────────────────────────────────────────────────
WebServer server(80);
// Helper: send JSON response
void jsonReply(int code, const String &body) {
server.sendHeader("Access-Control-Allow-Origin", "*");
server.send(code, "application/json", body);
}
// POST /message {"text":"Hello!"}
// Appends a new message to the queue
void handlePostMessage() {
if (!server.hasArg("plain")) {
jsonReply(400, "{\"error\":\"No body\"}");
return;
}
StaticJsonDocument<256> doc;
DeserializationError err = deserializeJson(doc, server.arg("plain"));
if (err || !doc.containsKey("text")) {
jsonReply(400, "{\"error\":\"JSON parse error or missing 'text' field\"}");
return;
}
const char *text = doc["text"];
if (strlen(text) == 0) {
jsonReply(400, "{\"error\":\"text is empty\"}");
return;
}
if (msgCount >= MAX_MESSAGES) {
jsonReply(429, "{\"error\":\"Queue full — clear messages first\"}");
return;
}
strncpy(msgQueue[msgCount], text, MAX_MSG_LEN - 1);
msgQueue[msgCount][MAX_MSG_LEN - 1] = '\0';
msgPlayCount[msgCount] = 0;
msgCount++;
Serial.printf("[API] Added: %s\n", text);
jsonReply(201, "{\"status\":\"added\",\"total\":" + String(msgCount) + "}");
}
// PUT /message/now {"text":"Urgent!"}
// Immediately interrupts the display with a one-shot message (not queued)
void handlePutNow() {
if (!server.hasArg("plain")) {
jsonReply(400, "{\"error\":\"No body\"}");
return;
}
StaticJsonDocument<256> doc;
DeserializationError err = deserializeJson(doc, server.arg("plain"));
if (err || !doc.containsKey("text")) {
jsonReply(400, "{\"error\":\"JSON parse error or missing 'text' field\"}");
return;
}
const char *text = doc["text"];
// Insert at front so it plays next
if (msgCount < MAX_MESSAGES) {
// Shift everything right
for (int i = msgCount; i > 0; i--) {
memcpy(msgQueue[i], msgQueue[i - 1], MAX_MSG_LEN);
msgPlayCount[i] = msgPlayCount[i - 1];
}
msgCount++;
}
strncpy(msgQueue[0], text, MAX_MSG_LEN - 1);
msgQueue[0][MAX_MSG_LEN - 1] = '\0';
msgPlayCount[0] = 0;
currentMsg = 0;
showCurrent();
Serial.printf("[API] Immediate: %s\n", text);
jsonReply(200, "{\"status\":\"showing now\"}");
}
// GET /messages — list all queued messages
void handleGetMessages() {
String out = "{\"count\":" + String(msgCount) + ",\"current\":" + String(currentMsg) + ",\"messages\":[";
for (int i = 0; i < msgCount; i++) {
// Escape quotes in message text
String msg = msgQueue[i];
msg.replace("\"", "\\\"");
out += "\"" + msg + "\"";
if (i < msgCount - 1) out += ",";
}
out += "]}";
jsonReply(200, out);
}
// DELETE /messages — clear the queue
void handleDeleteMessages() {
msgCount = 0;
currentMsg = 0;
display.displayClear();
showCurrent();
Serial.println("[API] Queue cleared");
jsonReply(200, "{\"status\":\"cleared\",\"total\":0}");
}
// PATCH /settings {"brightness":8,"speed":30}
void handlePatchSettings() {
if (!server.hasArg("plain")) {
jsonReply(400, "{\"error\":\"No body\"}");
return;
}
StaticJsonDocument<128> doc;
if (deserializeJson(doc, server.arg("plain"))) {
jsonReply(400, "{\"error\":\"JSON parse error\"}");
return;
}
if (doc.containsKey("brightness")) {
int b = constrain((int)doc["brightness"], 0, 15);
display.setIntensity(b);
Serial.printf("[API] Brightness -> %d\n", b);
}
if (doc.containsKey("speed")) {
// Speed is stored by re-launching the current scroll with the new value
int s = constrain((int)doc["speed"], 10, 200);
display.displayScroll(msgQueue[currentMsg], PA_LEFT, PA_SCROLL_LEFT, s);
Serial.printf("[API] Speed -> %d ms\n", s);
}
jsonReply(200, "{\"status\":\"updated\"}");
}
// GET /status — Wi-Fi + system health
void handleGetStatus() {
bool connected = (WiFi.status() == WL_CONNECTED);
unsigned long uptimeSec = (millis() - uptimeStart) / 1000;
String wifiStatus;
switch (WiFi.status()) {
case WL_CONNECTED: wifiStatus = "connected"; break;
case WL_NO_SSID_AVAIL: wifiStatus = "ssid_not_found"; break;
case WL_CONNECT_FAILED: wifiStatus = "connect_failed"; break;
case WL_CONNECTION_LOST: wifiStatus = "connection_lost"; break;
case WL_DISCONNECTED: wifiStatus = "disconnected"; break;
default: wifiStatus = "unknown"; break;
}
String rssiQuality;
if (!connected) rssiQuality = "offline";
else if (WiFi.RSSI() >= -50) rssiQuality = "excellent";
else if (WiFi.RSSI() >= -65) rssiQuality = "good";
else if (WiFi.RSSI() >= -75) rssiQuality = "fair";
else rssiQuality = "poor";
String out = "{";
out += "\"wifi\":{";
out += "\"status\":\"" + wifiStatus + "\",";
out += "\"connected\":" + String(connected ? "true" : "false") + ",";
out += "\"ssid\":\"" + String(connected ? WiFi.SSID() : "") + "\",";
out += "\"ip\":\"" + String(connected ? WiFi.localIP().toString() : "") + "\",";
out += "\"rssi\":" + String(connected ? WiFi.RSSI() : 0) + ",";
out += "\"rssi_quality\":\"" + rssiQuality + "\"";
out += "},";
out += "\"uptime_sec\":" + String(uptimeSec) + ",";
out += "\"messages\":{\"count\":" + String(msgCount) + ",\"current\":" + String(currentMsg) + "}";
out += "}";
jsonReply(200, out);
}
// GET / — simple status page
void handleRoot() {
String ip = WiFi.localIP().toString();
String html =
"<!DOCTYPE html><html><head><title>LED Matrix API</title></head><body>"
"<h2>LED Matrix Message Board</h2>"
"<p>IP: " + ip + "</p>"
"<h3>Endpoints</h3><pre>"
"POST /message {\"text\":\"Hello!\"} — add to queue\n"
"PUT /message/now {\"text\":\"Urgent!\"} — show immediately\n"
"GET /messages — list queue\n"
"DELETE /messages — reset to defaults\n"
"PATCH /settings {\"brightness\":8,\"speed\":30} — tweak display\n"
"GET /status — wifi + system health\n"
"</pre></body></html>";
server.send(200, "text/html", html);
}
// ─── Setup ────────────────────────────────────────────────────────────────────
void setup() {
Serial.begin(115200);
delay(100);
// Display init
display.begin();
display.setIntensity(BRIGHTNESS);
display.displayClear();
// Queue starts empty — API adds messages
msgCount = 0;
currentMsg = 0;
// Wi-Fi — scan for networks first (debug)
Serial.println("\n[WiFi] Scanning for networks...");
WiFi.mode(WIFI_STA);
int numNets = WiFi.scanNetworks();
if (numNets == 0) {
Serial.println("[WiFi] No networks found!");
} else {
Serial.printf("[WiFi] %d network(s) found:\n", numNets);
Serial.println(" # | RSSI | Enc | SSID");
Serial.println("-----|------|------|-----------------------------");
bool targetFound = false;
for (int i = 0; i < numNets; i++) {
String enc;
switch (WiFi.encryptionType(i)) {
case WIFI_AUTH_OPEN: enc = "Open "; break;
case WIFI_AUTH_WEP: enc = "WEP "; break;
case WIFI_AUTH_WPA_PSK: enc = "WPA "; break;
case WIFI_AUTH_WPA2_PSK: enc = "WPA2 "; break;
case WIFI_AUTH_WPA_WPA2_PSK: enc = "WPA/2"; break;
case WIFI_AUTH_WPA2_ENTERPRISE: enc = "WPA2E"; break;
default: enc = "? "; break;
}
bool isTarget = (WiFi.SSID(i) == String(WIFI_SSID));
if (isTarget) targetFound = true;
Serial.printf(" %2d | %4d | %s | %s%s\n",
i + 1,
WiFi.RSSI(i),
enc.c_str(),
WiFi.SSID(i).c_str(),
isTarget ? " <<<< TARGET" : "");
}
if (!targetFound) {
Serial.printf("[WiFi] WARNING: '%s' not found in scan!\n", WIFI_SSID);
Serial.println("[WiFi] -> Check SSID spelling, or network may be 5GHz only");
} else {
Serial.printf("[WiFi] Target '%s' found — connecting...\n", WIFI_SSID);
}
}
WiFi.scanDelete(); // free scan memory
// Connect
Serial.printf("[WiFi] Connecting to %s", WIFI_SSID);
display.displayScroll("Connecting...", PA_CENTER, PA_NO_EFFECT, 0);
display.displayAnimate();
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) {
wifiConnected = true;
Serial.printf("\nConnected! IP: %s\n", WiFi.localIP().toString().c_str());
// Sync NTP time
configTime(GMT_OFFSET_SEC, DAYLIGHT_OFFSET, NTP_SERVER);
Serial.print("[NTP] Syncing time");
struct tm timeinfo;
int ntpAttempts = 0;
while (!getLocalTime(&timeinfo) && ntpAttempts < 20) {
delay(500);
Serial.print(".");
ntpAttempts++;
}
if (getLocalTime(&timeinfo)) {
ntpSynced = true;
strftime(clockStr, sizeof(clockStr), "%H:%M", &timeinfo);
Serial.printf("\n[NTP] Time synced: %s\n", clockStr);
} else {
Serial.println("\n[NTP] Sync failed — will retry");
}
// Scroll IP for 60 seconds
snprintf(ipScrollMsg, sizeof(ipScrollMsg), "IP: %s", WiFi.localIP().toString().c_str());
showingIP = true;
ipScrollEnd = millis() + IP_SCROLL_DURATION_MS;
display.displayScroll(ipScrollMsg, PA_LEFT, PA_SCROLL_LEFT, SCROLL_SPEED);
} else {
Serial.println("\nWi-Fi failed — running offline (no API)");
display.displayScroll("No WiFi - offline mode", PA_LEFT, PA_SCROLL_LEFT, SCROLL_SPEED);
while (!display.displayAnimate()) delay(10);
}
// Register routes
server.on("/", HTTP_GET, handleRoot);
server.on("/message", HTTP_POST, handlePostMessage);
server.on("/message/now", HTTP_PUT, handlePutNow);
server.on("/messages", HTTP_GET, handleGetMessages);
server.on("/messages", HTTP_DELETE, handleDeleteMessages);
server.on("/settings", HTTP_PATCH, handlePatchSettings);
server.on("/status", HTTP_GET, handleGetStatus);
server.begin();
Serial.println("HTTP server started");
uptimeStart = millis();
lastWiFiCheck = millis();
// Begin normal scrolling
showCurrent();
}
// ─── Wi-Fi watchdog ──────────────────────────────────────────────────────────
void checkWiFi() {
wl_status_t status = WiFi.status();
bool nowConnected = (status == WL_CONNECTED);
// ── Case 1: Just connected (was reconnecting or first boot) ──────────────
if (nowConnected && !wifiConnected) {
wifiConnected = true;
reconnecting = false;
showingOffline = false;
Serial.printf("[WiFi] Connected! IP: %s RSSI: %d dBm\n",
WiFi.localIP().toString().c_str(), WiFi.RSSI());
String ipMsg = "Back online: " + WiFi.localIP().toString();
display.displayScroll(ipMsg.c_str(), PA_LEFT, PA_SCROLL_LEFT, SCROLL_SPEED);
lastWiFiCheck = millis();
return;
}
// ── Case 2: Still connected — periodic signal check ──────────────────────
if (nowConnected && wifiConnected) {
if (millis() - lastWiFiCheck < WIFI_CHECK_INTERVAL_MS) return;
lastWiFiCheck = millis();
Serial.printf("[WiFi] OK IP: %s RSSI: %d dBm\n",
WiFi.localIP().toString().c_str(), WiFi.RSSI());
return;
}
// ── Case 3: We are mid-reconnect — wait for timeout before trying again ──
if (reconnecting) {
if (millis() - reconnectStarted < WIFI_CONNECT_TIMEOUT_MS) return; // still waiting
// Timed out — disconnect cleanly and try again
Serial.println("[WiFi] Reconnect timed out — retrying...");
WiFi.disconnect(true);
delay(200);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
reconnectStarted = millis();
return;
}
// ── Case 4: Just lost connection — start a fresh reconnect ───────────────
if (!nowConnected && wifiConnected) {
wifiConnected = false;
showingOffline = true;
Serial.println("[WiFi] Connection lost — reconnecting...");
display.displayScroll("WiFi Lost...", PA_LEFT, PA_SCROLL_LEFT, SCROLL_SPEED);
WiFi.disconnect(true);
delay(200);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
reconnecting = true;
reconnectStarted = millis();
lastWiFiCheck = millis();
return;
}
// ── Case 5: Still disconnected and not yet reconnecting ──────────────────
if (millis() - lastWiFiCheck < WIFI_CHECK_INTERVAL_MS) return;
lastWiFiCheck = millis();
Serial.println("[WiFi] Still disconnected — starting reconnect attempt...");
WiFi.disconnect(true);
delay(200);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
reconnecting = true;
reconnectStarted = millis();
}
// ─── Loop ─────────────────────────────────────────────────────────────────────
void loop() {
server.handleClient();
checkWiFi();
if (display.displayAnimate()) {
// Still in the 60-second IP scroll window — keep looping the IP
if (showingIP) {
if (millis() < ipScrollEnd) {
display.displayScroll(ipScrollMsg, PA_LEFT, PA_SCROLL_LEFT, SCROLL_SPEED);
return;
} else {
// Time's up — drop into normal message queue
showingIP = false;
Serial.println("[Display] IP scroll done — starting message queue");
showCurrent();
return;
}
}
// If we just finished scrolling an offline/reconnect notice, resume queue
if (showingOffline) {
showCurrent();
return;
}
if (msgCount == 0) {
// Queue empty — refresh clock every 30s, display static HH:MM
unsigned long now = millis();
if (now - lastClockUpdate >= 30000) {
lastClockUpdate = now;
updateClock();
}
if (ntpSynced) {
display.displayText(clockStr, PA_CENTER, 0, 2000, PA_PRINT, PA_NO_EFFECT);
} else {
showCurrent();
}
} else {
// Increment play count for the message that just finished
msgPlayCount[currentMsg]++;
Serial.printf("[Display] '%s' played %d/%d\n",
msgQueue[currentMsg], msgPlayCount[currentMsg], MSG_PLAY_LIMIT);
if (msgPlayCount[currentMsg] >= MSG_PLAY_LIMIT) {
// Remove this message — shift queue left
Serial.printf("[Display] Removing message: %s\n", msgQueue[currentMsg]);
for (int i = currentMsg; i < msgCount - 1; i++) {
memcpy(msgQueue[i], msgQueue[i + 1], MAX_MSG_LEN);
msgPlayCount[i] = msgPlayCount[i + 1];
}
msgCount--;
// Stay at same index (now points to next message), wrap if needed
if (msgCount == 0) {
currentMsg = 0;
Serial.println("[Display] Queue empty — returning to idle");
} else {
currentMsg = currentMsg % msgCount;
}
} else {
// Move to next message
currentMsg = (currentMsg + 1) % msgCount;
}
showCurrent();
if (msgCount > 0)
Serial.printf("[Display] Now showing: %s\n", msgQueue[currentMsg]);
}
}
}“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.
Remix this project
Make it yours in one click
Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.