Initial Idea
I planned to use an SN74HC595N shift register and a ULN2003AG transistor array I intended to salvage from an old circuit board.
After writing the code to control the shift register, I started researching how to control the ULN2003AG, during which I happened to see something about the MAX7219 (a dedicated LED driver).
I checked an old LED matrix from my Arduino kit, and luckily, it used that exact chip. This made the SN74HC595N and ULN2003AG redundant, simplifying the circuit.
Coding
I’d love to say I used ESP-IDF, but I’m just a hobbyist, so I stuck with PlatformIO and the Arduino framework to keep things simple. I still intend to rewrite it later without the Arduino framework as a learning exercise, though performance-wise it won't make a noticeable difference for a basic clock.
- Getting The Time
This was surprisingly easy. I'll admit I asked an AI to map out a plan for me (Not Any Code)
because I thought it would be a bit complex, but it just suggested looking at time.h.
That made things very simple. You can even configure the clock to automatically adjust for British Summer Time changes using:
configTzTime("GMT0BST,M3.5.0/1,M10.5.0/2","pool.ntp.org");
To explain, M3.5.0/1 specifies Month 3 (March), Week 5, Sunday (0 being Sunday threw me off a bit) at 1 AM. When this rule triggers, it automatically shifts the clock forward one hour based on the GMT0BST setting.
- Controlling the MAX7219
This was my favourite part of this project. I'd not used this chip before and so It started with having a quick read of the datasheet, wiring it into my breadboard, then I wrote my first function to control it.
void sendToMax(byte reg, byte code){
//Start transmission
digitalWrite(LATCH_PIN, 0);
//Select the register
for (int i = 7; i >= 0; i--){
bool bitVal = (reg >> i) & 1;
digitalWrite(DATA_PIN,bitVal);
digitalWrite(SHIFT_PIN,0);
digitalWrite(SHIFT_PIN,1);
}
//Send the value
for (int i = 7; i >= 0; i--){
bool bitVal = (code >> i) & 1;
digitalWrite(DATA_PIN,bitVal);
digitalWrite(SHIFT_PIN,0);
digitalWrite(SHIFT_PIN,1);
}
//Finish transmission
digitalWrite(LATCH_PIN,1);
}
While I didn't need to write this function myself, doing it manually helped me understand how the chip actually operates. The code pulls the latch pin low to open communication with the MAX7219, then uses two loops to break the register and data bytes down bit by bit. It pushes each bit out, pulsing the shift pin to clock it into the chip, before finally pulling the latch pin high to commit the data and update the display.
Initializing the chip
Before using the MAX7219, it must be initialized every time it powers on. This requires waking the chip up, setting the scan limit to display four digits, and configuring the decode mode for those digits:
// Wake up the chip
sendToMax(0x0C, 0x01);
// Set scan limit to 3 (displays digits 0-3)
sendToMax(0x0B, 0x03);
// Enable Code B decode mode for digits 0-3
sendToMax(0x09, 0x0F);
With initialization out of the way, displaying a number is as simple as selecting a digit and assigning it a value. The snippet below displays "1.234" by using the 8th bit (adding 128 in decimal) to turn on the decimal point for the first digit:
sendToMax(0x01, 129); // Displays '1.' (1 + 128)
sendToMax(0x02, 2); // Displays '2'
sendToMax(0x03, 3); // Displays '3'
sendToMax(0x04, 4); // Displays '4'
To avoid writing those lines repeatedly, I created a helper function that takes a value and a decimal position and handles the math automatically. I originally planned to support scrolling for numbers larger than the display, but dropped the feature since it wasn't needed for a simple clock. Because of this, the function currently is only effective for up to four digits, although more can be sent to it.
void sendScrollingToMax(int val, int decimalPos){
int length = 0;
int tempVal = val;
while (tempVal > 0)
{
length++;
tempVal /= 10;
}
if (length < 4) sendToMax(0x01,0x0F);
int pos = 4;
for (int i = 1; i <= length; i++){
if (i == decimalPos){
sendToMax(pos,(byte)((val % 10)+128));
}
else{
sendToMax(pos,(byte)(val % 10));
}
pos--;
if (pos < 1) {
pos = 4;
}
val /= 10;
}
}
Power Saving & Interface Tricks
Since I eventually want to power this from a battery, efficiency was key. The ESP32 spends most of its time in light sleep, which keeps the time accurate and maintains power to the MAX7219 so the display stays on (unlike deep sleep). The microcontroller wakes up once a minute, updates the time, reads the LDR to scale the brightness, and drops straight back into sleep.
I also used a physical button to handle manual overrides. A quick press forces a network re-sync, while a double-click turns off the automatic LDR scaling and cycles through a static array of hex brightness values so I can set the dimming manually.
Where the project is at now
My next step will be to draft a case for it, keeping in mind a space for a battery. I have got a PCB designed, but the cost of having a one-off made isn't worth it, so for now I'll keep to the prototype board.
Click to see full source code
#include <Arduino.h>
#include <WiFi.h>
#include <time.h>
#include "esp_sntp.h"
#include "credentials.h"
const uint8_t DATA_PIN = 4;
const uint8_t SHIFT_PIN = 3;
const uint8_t LATCH_PIN = 10;
const uint8_t LDR_PIN = 1;
const uint8_t BUTTON_PIN = 0;
const byte BRIGTHNESS_HEX[] = {
0x01,
0x02,
0x03,
0x04,
0x05,
0x06,
0x07,
0x08,
0x09,
0x0A,
0x0B,
0x0C,
0x0D,
0x0E,
0x0F
};
unsigned long lastClickT = 0;
bool clicking = 0;
uint8_t currentBrightness = 0;
bool autoBrightness = 1;
time_t now;
struct tm* lt;
void dataShift(byte data);
void sendToMax(byte reg, byte code);
void sendScrollingToMax(int val, int decimalPos);
void resync();
void changeBrightnessTask();
void setup() {
pinMode(DATA_PIN, OUTPUT);
pinMode(SHIFT_PIN, OUTPUT);
pinMode(LATCH_PIN, OUTPUT);
pinMode(BUTTON_PIN, INPUT_PULLUP);
Serial.begin(115200);
// Wake up the chip
sendToMax(0x0C, 0x01);
Serial.println("\nMAX is awake!");
// Set scan limit to 3 (displays digits 0-3)
sendToMax(0x0B, 0x03);
Serial.println("\nscan limit set");
// Enable Code B decode mode for digits 0-3
sendToMax(0x09, 0x0F);
Serial.println("\ndecode set");
while(time(nullptr) < 1000000000){
resync();
}
}
void loop() {
esp_sleep_wakeup_cause_t t = esp_sleep_get_wakeup_cause();
if (t == ESP_SLEEP_WAKEUP_GPIO){
unsigned long startT = millis();
while (digitalRead(BUTTON_PIN) == LOW);
unsigned long deltaT = millis() - startT;
if (deltaT < 500){
int64_t currentTime = esp_timer_get_time();
if (clicking && ((currentTime - lastClickT) < 300000ULL)){
autoBrightness = 1;
uint8_t i = 0;
while (i < 14)
{
i++;
sendToMax(0x0A,BRIGTHNESS_HEX[i]);
delay(60);
}
}
else{
if (clicking){
currentBrightness++;
if (currentBrightness >= 15) currentBrightness = 0;
sendToMax(0x0A, BRIGTHNESS_HEX[currentBrightness]);
autoBrightness = 0;
clicking = 0;
}
else{
clicking = 1;
lastClickT = currentTime;
}
}
}
else{
resync();
}
sendToMax(0x01,0x0F);
sendToMax(0x02,0x0F);
sendToMax(0x03,0x0F);
sendToMax(0x04,0x0F);
}
time(&now);
lt = localtime(&now);
int shortTime = (lt->tm_hour * 100) + lt->tm_min;
sendScrollingToMax(shortTime,3);
Serial.println(lt);
int sleepTime = (60 - lt->tm_sec);
uint16_t ldrVal = analogRead(LDR_PIN);
if (autoBrightness) sendToMax(0x0A,BRIGTHNESS_HEX[map(ldrVal,5,4095,0,14)]);
esp_sleep_enable_timer_wakeup(sleepTime * 1000000);
gpio_wakeup_enable((gpio_num_t)BUTTON_PIN, GPIO_INTR_LOW_LEVEL);
esp_sleep_enable_gpio_wakeup();
gpio_hold_en((gpio_num_t)BUTTON_PIN);
Serial.flush();
esp_light_sleep_start();
gpio_hold_dis((gpio_num_t)BUTTON_PIN);
pinMode(BUTTON_PIN,INPUT_PULLUP);
}
//MAX7219
void sendToMax(byte reg, byte code){
digitalWrite(LATCH_PIN, 0);
for (int i = 7; i >= 0; i--){
bool bitVal = (reg >> i) & 1;
digitalWrite(DATA_PIN,bitVal);
digitalWrite(SHIFT_PIN,0);
digitalWrite(SHIFT_PIN,1);
}
for (int i = 7; i >= 0; i--){
bool bitVal = (code >> i) & 1;
digitalWrite(DATA_PIN,bitVal);
digitalWrite(SHIFT_PIN,0);
digitalWrite(SHIFT_PIN,1);
}
digitalWrite(LATCH_PIN,1);
}
//send int add a decimal at decimalPos right to left
void sendScrollingToMax(int val, int decimalPos){
int length = 0;
int tempVal = val;
while (tempVal > 0)
{
length++;
tempVal /= 10;
}
if (length < 4) sendToMax(0x01,0x0F);
int pos = 4;
for (int i = 1; i <= length; i++){
if (i == decimalPos){
sendToMax(pos,(byte)((val % 10)+128));
}
else{
sendToMax(pos,(byte)(val % 10));
}
pos--;
if (pos < 1) {
pos = 4;
}
val /= 10;
}
}
void resync(){
sendToMax(0x01,128);
sendToMax(0x02,1);
sendToMax(0x03,2);
sendToMax(0x04,3);
WiFi.mode(WIFI_STA);
WiFi.begin(ssid,password);
Serial.write("Connecting ");
uint8_t trys = 0;
uint8_t val = 0;
while (WiFi.status() != WL_CONNECTED){
delay(200);
sendToMax(abs(trys % 4) + 1, val+128);
if (abs(trys % 4) + 1 == 4) val++;
Serial.print(".");
trys++;
if (trys > 100) {
Serial.println("\nConnection failed!");
break;
}
}
configTzTime("GMT0BST,M3.5.0/1,M10.5.0/2","pool.ntp.org");
esp_sleep_pd_config(ESP_PD_DOMAIN_XTAL, ESP_PD_OPTION_ON);
if (WiFi.status() == WL_CONNECTED){
Serial.println("\nConnected successfully!");
time_t lastT = time(&now);
sntp_stop();
sntp_init();
//turn off decode
sendToMax(0x09, 0x00);
trys = 0;
int i;
while(time(nullptr) < 1000000000){
trys++;
if (trys > 5){
if (lastT > 1000000000){
struct timeval tv;
tv.tv_sec = lastT;
tv.tv_usec = 0;
settimeofday(&tv,NULL);
};
break;
}
i=4;
while (i >= 1){
sendToMax(i, 0b00001000);
delay(100);
i--;
}
sendToMax(0x01, 0b00000100);
delay(100);
sendToMax(0x01, 0b00000010);
delay(100);
i=1;
while (i <= 4){
sendToMax(i, 0b01000000);
delay(100);
i++;
}
sendToMax(0x04, 0b00100000);
delay(100);
sendToMax(0x04, 0b00010000);
delay(100);
};
//turn on decode
sendToMax(0x09, 0x0F);
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
}
}