Community project

ESP32 Scrolling Message Board

James Mellors

Published July 15, 2026

ESP321 component2 assembly steps
Remix this project
Photo of 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
Wiring diagram for ESP32 Scrolling Message Board

Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.

Parts list

Bill of materials
ComponentQtyNotes
MAX7219 8x32 LED Dot Matrix Module1Four cascaded 8x8 LED matrices driven by MAX7219 chips over SPI.

Assembly

2 steps
  1. Wire 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.
  2. 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.

Firmware

ESP32
firmware.cppDeploy to device
#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.

Open in Schematik