A small, self-contained controller for a 4 × 8×8 LED matrix (MAX7219 / FC-16) driven by an ESP32. It shows either an NTP-synced clock or a scrolling text message, and everything about it — mode, brightness, scroll speed, orientation — is controlled over a plain HTTP GET API or from an included PHP web page.
No app, no cloud, no MQTT broker: the ESP32 runs a tiny web server on a fixed IP in your LAN, and every setting survives a power cut because it is stored in the ESP32's flash.
/api?mode=text&text=Hello%20World&speed=30&bright=12
- Clock mode, time via NTP, in two styles:
HH:mm:ssin a custom 3-pixel-wide digit font (fits 8 characters onto 32 px)HH:mmin the regular font — much easier to read from a distance
- Text mode: free scrolling message with adjustable speed (5–200 ms per frame),
UTF-8 aware — umlauts,
ß, accents,°and€render correctly - 26 brightness levels, including 10 extra-dim "night" levels below the MAX7219's darkest hardware step, done with software PWM — genuinely usable as a bedside clock
- 180° rotation, with the scroll direction rotated along with it
- Persistent settings: mode, clock style, brightness, flip, speed and the message text are restored after a reboot
- Everything is a GET request — trivial to drive from a shell script, a home automation system, or a browser bookmark
- Optional PHP front-end that proxies the API server-side (no CORS issues), with a German/English language switch
| Matrix module | ESP32 pin |
|---|---|
| VCC | 5V |
| GND | GND |
| DIN | GPIO23 |
| CS | GPIO5 |
| CLK | GPIO18 |
- Developed on a Wemos D1 Mini ESP32 (ESP32-WROOM-32); any ESP32 board works, adjust the pins in the sketch if needed.
- Connect to the IN side of the first module (the arrows point away from the cable), then chain the modules IN → OUT → IN.
- Power the matrix from 5V, not 3V3 — the MAX7219 needs at least ~4 V. The logic inputs are happy with the ESP32's 3.3 V levels.
Install via the Arduino IDE library manager:
- MD_Parola (MajicDesigns)
- MD_MAX72XX (MajicDesigns) — pulled in as a dependency
Plus the ESP32 board package by Espressif. Preferences is part of that
package, nothing extra to install.
The header is spelled
MD_MAX72xx.hwith a lower-casexx. Getting this wrong is the single most common build error with these libraries.
cp matrix_control/secrets.h.example matrix_control/secrets.hThen edit secrets.h:
const char* WIFI_SSID = "YOUR_WIFI_SSID";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";secrets.h is in .gitignore, so your credentials stay on your machine.
The sketch uses a static IP so the web page always finds the display. Adjust
the CONFIGURATION block near the top of matrix_control.ino to your network:
IPAddress local_IP(192, 168, 178, 18); // free address for the ESP32
IPAddress gateway (192, 168, 178, 1); // your router
IPAddress subnet (255, 255, 255, 0);
IPAddress dns (192, 168, 178, 1);While you are there, TZ_INFO holds the POSIX timezone string — the default is
Central European Time with automatic DST (CET-1CEST,M3.5.0,M10.5.0/3).
Select your ESP32 board and port, hit upload. The display shows WiFi... while
connecting; the assigned IP is printed on the serial monitor at 115200 baud and
also served at http://<esp-ip>/.
Base URL: http://<esp-ip>
| Parameter | Values | Meaning |
|---|---|---|
mode |
clock | text |
display mode |
text |
up to 150 characters, UTF-8 | scroll message (implies mode=text) |
clockstyle |
full | short |
HH:mm:ss compact font, or HH:mm regular font |
bright |
0–25 |
0–9 = night levels, 10 = hardware minimum, 25 = maximum |
speed |
5–200 |
milliseconds per frame — smaller is faster |
flip |
0 | 1 |
180° rotation |
All parameters can be combined freely, and every response is the full current state as JSON:
$ curl "http://192.168.178.18/api?mode=text&text=Hello&speed=30"
{"mode":"text","brightness":15,"brightmax":25,"flip":0,"speed":30,
"speedmin":5,"speedmax":200,"clockstyle":"full","text":"Hello",
"ip":"192.168.178.18"}GET /status returns the same JSON without changing anything, and GET /
serves a short overview page. Responses carry
Access-Control-Allow-Origin: *, so a browser-side page can talk to the ESP32
directly if you prefer that over the PHP proxy.
Every change is written back to flash automatically (see Persistence below).
web/matrix.php is a single self-contained file — drop it into any webroot with
PHP available and set the IP at the top:
$ESP_IP = "192.168.178.18";It gives you mode buttons, the clock-format switch, a text field, and sliders for brightness and scroll speed. Requests are proxied server-side, so the browser never talks to the ESP32 directly — no CORS setup, and the page also works when only the web server can reach the display.
The interface is bilingual (German / English). The DE / EN buttons in the
header switch instantly without a page reload, the choice is remembered in
localStorage, and first-time visitors get whatever their browser prefers. All
strings live in a single I18N object at the top of the script block — adding a
third language means adding one more block of key/value pairs.
Requires allow_url_fopen = On (the PHP default); otherwise switch the proxy in
matrix.php over to cURL.
HH:mm:ss is 8 characters. In the default font (5 px per character plus spacing,
≈ 40 px) that does not fit onto four modules (32 px). The sketch therefore embeds
clockFont[], a hand-built font containing only 0–9 and : at 3 px per
digit — about 27 px in total. Text mode switches back to the stock font with
setFont(nullptr).
If you prefer legibility over seconds, clockstyle=short shows HH:mm in the
regular font.
Browsers send text as UTF-8, where every non-ASCII character occupies two or more
bytes — ä is 0xC3 0xA4. The MD_MAX72xx font, on the other hand, is a plain
single-byte table:
| Range | Contents |
|---|---|
0x20–0x7E |
ASCII |
0x80–0x99 |
extra symbols: €, Greek letters, ≥, ≤, √, ™ |
0xA0–0xFF |
ISO-8859-1 — all umlauts, ß, °, accented letters |
Handing the raw UTF-8 bytes to MD_Parola therefore drew two wrong glyphs per
umlaut. utf8ToFont() decodes the byte stream into code points and emits the
matching font byte instead. Characters with no glyph but an obvious ASCII
equivalent are transliterated (– → -, „ → ", … → ...); anything
genuinely unavailable, an emoji for instance, becomes ?.
The message is kept in two buffers: textStr[] holds the original UTF-8 (that is
what /status returns and what goes into flash, so the web page keeps showing
the text as typed), while dispStr[] holds the converted bytes MD_Parola scrolls.
180° rotation is done with the zone effects PA_FLIP_UD + PA_FLIP_LR. The
catch: PA_FLIP_LR mirrors the motion as well, so with the display flipped a
PA_SCROLL_LEFT message visibly scrolls to the right. setModeText() therefore
swaps the direction to PA_SCROLL_RIGHT whenever the flip is active, so text
always reads right-to-left.
Because MD_Parola only picks up the direction at displayText() time, any change
to flip, speed or mode re-issues the call through applyMode().
The MAX7219 offers 16 intensity steps, and even step 0 is bright enough to light
up a dark bedroom. To get below it, the sketch rapidly toggles the chip's
shutdown register at 100 Hz (updateDimming()), giving 10 additional night
levels at 2–50 % duty cycle:
bright |
Effect |
|---|---|
| 0–9 | intensity 0 + software PWM at 2/3/4/5/7/10/15/22/33/50 % |
| 10 | hardware minimum, permanently on |
| 11–25 | hardware intensity 1–15 |
The night levels live in NIGHT_DUTY[] and can be re-tuned or extended freely —
the scale, NIGHT_COUNT and the brightmax field in the JSON all follow via
sizeof. Below roughly 3 % duty the on-window starts colliding with the
MAX7219's internal multiplexing and a level can look almost dark; that is the
physical floor of this approach. Night levels can shimmer slightly under fast
scrolling, but they are ideal for the static clock.
Mode, clock style, brightness, flip, scroll speed and the message text are kept
in the ESP32's NVS flash (Preferences, namespace matrix) and restored by
loadSettings() at boot.
Writes are deferred: a change only sets a flag, and saveIfDue() commits
everything in one go three seconds after the last change. Dragging a slider
across its full range therefore costs a single flash write instead of dozens,
which matters for flash wear. On first boot, before the namespace exists, the
defaults from the sketch apply.
To reset everything: Arduino IDE → Tools → Erase All Flash Before Sketch Upload: Enabled, upload once, then set it back to Disabled.
| Symptom | Likely cause |
|---|---|
MD_MAX72XX.h: No such file or directory |
header must be MD_MAX72xx.h, lower-case xx |
secrets.h: No such file or directory |
copy secrets.h.example to secrets.h |
| Only the first module lights up, steady | wiring — check the IN side, CS and a common GND |
| Text mirrored or scrambled | try a different HARDWARE_TYPE: FC16_HW → GENERIC_HW → PAROLA_HW → ICSTATION_HW |
| Web page says the ESP is unreachable | check $ESP_IP, whether the ESP32 joined the Wi-Fi, and allow_url_fopen |
| Display dark or flickering | powered from 3V3 instead of 5V, or an undersized supply |
| Umlauts show as two wrong glyphs | an older MD_MAX72xx (< 3.x) whose default font stops at ASCII 127 — update the library |
matrix_control/
matrix_control.ino ESP32 firmware
secrets.h.example template for your Wi-Fi credentials
web/
matrix.php optional PHP web interface (DE/EN)
docs/ photos and screenshots used in this README
MIT — see LICENSE.
Built on the excellent MD_Parola and MD_MAX72XX libraries by MajicDesigns.

