A clock in a tube
An ESP32, a time server and eight digits. The interesting problem is not the network — it is that the colons are soldered at digits 1 and 5, so HH:MM:SS in one run is a layout this display physically cannot draw.
Where the seconds go
Press Let it run. Then switch the layout and notice what is not on offer.
HH:MM:SS with a separator in both places is not something this display can
draw. The colons are LEDs soldered into the middle of each four-digit panel, so
they sit at digits 1 and 5, and a separator between the minutes and the seconds
would need one at digit 3. There is no LED there and no register that could
light it.
What the geometry does give you is two NN:NN fields, which is a genuinely
useful shape:
- hours and minutes on the left, seconds pushed to the right-hand edge;
- hours and minutes on the left, day and month on the right;
- two time zones;
- a time and a temperature.
The sketch below takes the first. The two middle digits are blanked, which lines the gap up with the seam between the panels and looks deliberate rather than like a display with a fault.
Blanking is a character
Worth its own sentence because it is the one non-obvious call in the sketch.
There is no "turn this digit off" function in LedControl. What there is, is
code B character 0x0F, which the decoder draws as a blank:
lc.setDigit(0, 4, 0x0F, false); // digit 4 shows nothinglc.clearDisplay(0) blanks all eight, which is the wrong tool inside a loop
that runs every second — it writes eight registers to undo work you are about to
redo.
Only what changed
The chip is holding all eight bytes and scanning them itself, so a digit you do not rewrite keeps showing what it was showing. That means the loop only has to write what moved:
- the seconds change every second — two digits, sometimes one;
- the colon blinks — one digit, and it is a digit that is already being written;
- the minutes change once a minute;
- the hours, once an hour.
The sketch here rewrites eight digits every second anyway, because eight register writes is sixteen bytes and costs nothing measurable. It is worth knowing that you could write two, though — it is the difference between a clock and something that has to keep talking to stay lit.
Setting itself
configTzTime does two jobs in one call: it starts an NTP client and it
installs a POSIX timezone rule. The rule is the part worth getting right.
const char* TZ = "AEST-10AEDT,M10.1.0,M4.1.0/3";That string says "standard time is UTC+10, daylight time starts on the first Sunday in October and ends on the first Sunday in April at 3 am". With it, the clock in the tube changes itself twice a year and you never touch the sketch. An offset in hours does not, and is wrong for half the year.
After that, getLocalTime fills a struct tm from the ESP32's own system
clock, which keeps running from the crystal whether or not the network is there.
The board resyncs about once an hour in the background.
The code
A clock that sets itself over Wi-Fi and never drifts: HH:MM on the left panel with a colon that blinks once a second, and the seconds on the right-hand edge. Written for an ESP32; the display half is identical on any board.
// A clock in a glass tube: eight 0.28-inch digits on a MAX7219, set from NTP.
//
// Wiring, driver board to ESP32:
// GND -> GND
// VCC -> 5V or VIN (the chip needs 4.0 V minimum; NOT the 3V3 pin)
// DIN -> GPIO 23
// CLK -> GPIO 18
// CS -> GPIO 5
//
// The ESP32's 3.3 V outputs are below the MAX7219's guaranteed 3.5 V input
// threshold. It works on most parts; for anything permanent put a level
// converter in DIN, CLK and CS.
//
// Arduino IDE: board "ESP32 Dev Module", and the "LedControl" library by
// Eberhard Fahle from Sketch > Include Library > Manage Libraries.
#include <WiFi.h>
#include <time.h>
#include <LedControl.h>
const char* SSID = "your-network";
const char* PASS = "your-password";
// POSIX timezone string. This one is Melbourne / Sydney, DST rule included.
const char* TZ = "AEST-10AEDT,M10.1.0,M4.1.0/3";
const int DIN_PIN = 23;
const int CLK_PIN = 18;
const int CS_PIN = 5;
LedControl lc = LedControl(DIN_PIN, CLK_PIN, CS_PIN, 1);
// Code B sends 0x0F as "blank". The two middle digits are never used.
const byte BLANK = 0x0F;
void setup() {
Serial.begin(115200);
lc.shutdown(0, false); // the chip powers up blanked
lc.setScanLimit(0, 7); // all eight digits
lc.setIntensity(0, 6);
lc.clearDisplay(0);
WiFi.begin(SSID, PASS);
while (WiFi.status() != WL_CONNECTED) {
delay(250);
Serial.print(".");
}
Serial.println(WiFi.localIP());
// Fetch the time and apply the timezone rule in one call.
configTzTime(TZ, "pool.ntp.org", "time.nist.gov");
struct tm t;
while (!getLocalTime(&t, 500)) Serial.println("waiting for NTP");
}
void loop() {
struct tm t;
if (!getLocalTime(&t)) return;
static int lastSecond = -1;
if (t.tm_sec == lastSecond) return; // only redraw when it changes
lastSecond = t.tm_sec;
bool colon = (t.tm_sec % 2) == 0; // blink once a second
// Digits 0-3: HH:MM. The colon is the point of digit 1.
lc.setDigit(0, 0, t.tm_hour / 10, false);
lc.setDigit(0, 1, t.tm_hour % 10, colon);
lc.setDigit(0, 2, t.tm_min / 10, false);
lc.setDigit(0, 3, t.tm_min % 10, false);
// Digits 4-5 blank, seconds on the right-hand edge. There is no colon
// between the minutes and the seconds because digit 3 has no LED there.
lc.setDigit(0, 4, BLANK, false);
lc.setDigit(0, 5, BLANK, false);
lc.setDigit(0, 6, t.tm_sec / 10, false);
lc.setDigit(0, 7, t.tm_sec % 10, false);
}configTzTime takes a POSIX timezone string, and the one here is Australian eastern time with its daylight-saving rule built in — so the clock changes itself in October and April with nothing to update. Look yours up rather than adding an offset by hand; an offset goes wrong twice a year.
When it does not work
NTP has not answered. The board joined Wi-Fi — otherwise setup would still be printing dots — but nothing came back from the time server, which is usually a network that blocks outbound UDP on port 123 or a captive portal. getLocalTime returning false is how you see it; the sketch prints that.
The timezone string. configTzTime applies a POSIX TZ rule, not an offset, and the one in the sketch is Australian eastern. Look yours up rather than adding hours by hand: a plain offset is right for half the year and an hour out for the other half.
Both come from the same getLocalTime call, so if one is moving the other should be. Check you have not left a delay in loop that is longer than a second — the second-change test only fires when loop actually runs.
Yes, and it is the better use of that half: the right panel has its own colon at digit 5, so DD:MM reads properly there where the seconds do not. t.tm_mday and t.tm_mon + 1 are the two numbers, and tm_mon counts from zero.
It does not. configTzTime sets the ESP32's own system clock and the chip keeps counting from its crystal, so a dropped network costs accuracy over days, not the time itself. It resyncs on its own about once an hour.
Yes, with a real-time clock module on I2C, and the display half of the sketch does not change. A bare ESP32 with no RTC and no network has no idea what time it is at power-up, which is the one thing a clock cannot work around.
Six symptoms, and the single register write that tells you which half of the problem you are in.
When a digit goes missing →Edit this page — content/books/max7219-tube-clock/a-clock-in-a-tube.mdx
Questions about this product
See what other owners have asked, and read their solutions.
MAX7219 Vintage Tube LED Clock Kit
Loading discussions…
Discuss this article
Ask about this page. The answer stays here, on the page it belongs to, for whoever hits the same wall next.