ESP32-C3 micro NTP Clock

NTP Clock Prototype

This project came about when I decided it would be nice to have a clock at my desk again after going without one for many years. However, rather than buying a standard clock, I decided to build one from scratch that automatically sets its own time and dims the display based on ambient light.

This project is ongoing

Components Used:

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); 
  }  
}
 
        
Schematic
Breadboard Circuit Design Prototype Rear Prototype Front