Updated on 2024-03-19
Just a clock with snazzy digits that syncs using NTP and gets your timezone from your IP.
I am awake at odd hours due to a funny sleep pattern that I've settled into as I've gotten older. During those hours, the challenge becomes how to occupy oneself without waking everyone in the house. It's those witchy periods of the night where I make projects like this.
I wanted a retro LCD clock face. I don't know why, it just appealed to me, and seemed like something fun to build, so here we are.
Since the TTGO T1 doesn't have a built in real time clock, I just make it use the Internet to sync time.
This project uses my htcw_gfx library to do the drawing. It uses TrueType font I found on the Internet somewhere quite some time ago for the digits. It uses an IP to location class and an NTP class I built prior for a weather clock I made.
Basically in the main loop, several things happen:
Separately if a button is pressed (TTGO) or the screen is touched (Core 2) the clock will change from 24 hour to 12 hour form or back.
After importing the htcw_ttgo library and including <ttgo.hpp>, everything is pretty much ready to go. The Core 2 setup is a bit more involved, since it doesn't have an all in one library.
#include <Arduino.h>
#include <WiFi.h>
#include <gfx.hpp>
#include <ntp_time.hpp>
#include <ip_loc.hpp>
#ifdef TTGO_T1
#include <ttgo.hpp>
#endif
#ifdef M5STACK_CORE2
#include <tft_io.hpp>
#include <ili9341.hpp>
#include <ft6336.hpp>
#include <m5core2_power.hpp>
#define LCD_SPI_HOST VSPI
#define LCD_PIN_NUM_MOSI 23
#define LCD_PIN_NUM_CLK 18
#define LCD_PIN_NUM_CS 5
#define LCD_PIN_NUM_DC 15
using tft_bus_t = arduino::tft_spi_ex<LCD_SPI_HOST,LCD_PIN_NUM_CS,LCD_PIN_NUM_MOSI,-1,LCD_PIN_NUM_CLK,0,false>;
using lcd_t = arduino::ili9342c<LCD_PIN_NUM_DC,-1,-1,tft_bus_t,1>;
lcd_t lcd;
static m5core2_power power;
using touch_t = arduino::ft6336<280,320>;
touch_t touch(Wire1);
#endif
You can pretty well ignore the M5STACK_CORE2 stuff unless you have an M5Stack Core2 and would like to use it. I simply added it to the project to demonstrate how straightforward it can be to add more device support.
// set these to assign an SSID and pass for WiFi
constexpr static const char* ssid = nullptr;
constexpr static const char* pass = nullptr;
You'll want to assign these unless your ESP32 remembers your WiFi credentials from the last time you used it, which is default behavior.
#define DSEG14CLASSIC_REGULAR_IMPLEMENTATION
#include <assets/DSEG14Classic_Regular.hpp>
static const gfx::open_font& text_font = DSEG14Classic_Regular;
This is our TrueType font imported as a header using my graphics library's online converter tool.
// NTP server
constexpr static const char* ntp_server = "pool.ntp.org";
// synchronize with NTP every 60 seconds
constexpr static const int clock_sync_seconds = 60;
That is our NTP configuration. You shouldn't need to change it.
Global imports and global variables are next:
using namespace arduino;
using namespace gfx;
using color_t = color<lcd_t::pixel_type>;
using fb_type = bitmap<lcd_t::pixel_type>;
static uint8_t* lcd_buffer;
static int connect_state = 0;
static char timbuf[16];
static tm tim;
static ntp_time ntp;
static float latitude;
static float longitude;
static long utc_offset;
static char region[128];
static bool am_pm = false;
static char city[128];
static open_text_info oti;
static bool got_time = false;
static bool refresh = false;
static time_t current_time;
static IPAddress ntp_ip;
rect16 text_bounds;
The next routine calculates our text bounds and size:
void calculate_positioning() {
refresh = true;
lcd.fill(lcd.bounds(),color_t::dark_gray);
float scl = text_font.scale(lcd.dimensions().height - 2);
ssize16 dig_size = text_font.measure_text(ssize16::max(), spoint16::zero(), "0", scl);
ssize16 am_pm_size = {0,0};
int16_t w = (dig_size.width + 1) * 6;
if(am_pm) {
am_pm_size = text_font.measure_text(ssize16::max(), spoint16::zero(), ".", scl);
w+=am_pm_size.width;
}
float mult = (float)(lcd.dimensions().width - 2) / (float)w;
if (mult > 1.0f) mult = 1.0f;
int16_t lh = (lcd.dimensions().height - 2) * mult;
const char* str = am_pm?"\x7E\x7E:\x7E\x7E.":"\x7E\x7E:\x7E\x7E";
oti=open_text_info(str,text_font,text_font.scale(lh));
text_bounds = (rect16)text_font.measure_text(
ssize16::max(),
oti.offset,
oti.text,
oti.scale,
oti.scaled_tab_width,
oti.encoding,
oti.cache).bounds();
// set to the screen's width
text_bounds.x2=text_bounds.x1+lcd.dimensions().width-1;
text_bounds=text_bounds.center(lcd.bounds());
}
We do this based on the screen width and the width of the zero numeric character. This works because our font is monospace, but many TTF fonts are not.
And now a button handler - pressing changes from 24-hour to 12-hour mode and back:
#ifdef TTGO_T1
void on_pressed_changed(bool pressed, void* state) {
if(pressed) {
am_pm = !am_pm;
calculate_positioning();
}
}
#endif
In setup(), we initialize everything and allocate memory for the display:
void setup()
{
Serial.begin(115200);
#ifdef M5STACK_CORE2
power.initialize();
touch.initialize();
touch.rotation(1);
#endif
#ifdef TTGO_T1
ttgo_initialize();
button_a_raw.on_pressed_changed(on_pressed_changed);
button_b_raw.on_pressed_changed(on_pressed_changed);
#endif
lcd.initialize();
#ifdef TTGO_T1
lcd.rotation(3);
#endif
calculate_positioning();
size_t sz = fb_type::sizeof_buffer(text_bounds.dimensions());
#ifdef BOARD_HAS_PSRAM
lcd_buffer = (uint8_t*)ps_malloc(sz);
#else
lcd_buffer = (uint8_t*)malloc(sz);
#endif
if(lcd_buffer==nullptr) {
Serial.println("Out of memory allocating LCD buffer");
while(1);
}
lcd.fill(lcd.bounds(),color_t::dark_gray);
WiFi.mode(WIFI_STA);
WiFi.disconnect();
// get_build_tm(&tim);
tim.tm_hour = 12;
tim.tm_min = 0;
tim.tm_sec = 0;
}
Basically we initialize the devices, and then allocate an LCD buffer to back a bitmap we're going to use in order to draw. It's generally more efficient to draw to a bitmap and then send that to the display than it is to draw directly to the display. The bitmap only needs to be large enough to hold the text, not the entire screen. If the board has PSRAM, we use that but we can't do DMA with PSRAM, so asynchronous transfers will silently fail if we use it. We use said transfers to send the bitmap to the display, but in this case to compensate, we simply don't use asynchronous transfers if we're using PSRAM. The performance hit is negligible. We didn't really need async either way.
Finally, we set the initial time to 12:00 after initializing the WiFi radio.
The first part of loop() is split over a switch with cases for different connection states:
static uint32_t ntp_ts = 0;
switch(connect_state) {
case 0: // DISCONNECTED
Serial.println("WiFi Connecting");
if(ssid==NULL) {
WiFi.begin();
} else {
WiFi.begin(ssid, pass);
}
connect_state = 1;
break;
case 1: // CONNECTION ESTABLISHED
if(WiFi.status()==WL_CONNECTED) {
got_time = false;
Serial.println("WiFi Connected");
ntp_ip = false;
connect_state = 2;
WiFi.hostByName(ntp_server, ntp_ip);
Serial.print("NTP IP: ");
Serial.println(ntp_ip.toString());
ip_loc::fetch(&latitude, &longitude, &utc_offset, region, 128, city, 128);
Serial.print("City: ");
Serial.println(city);
}
break;
case 2: // CONNECTED
if (WiFi.status() != WL_CONNECTED) {
connect_state = 0;
} else {
if(!ntp_ts || millis() > ntp_ts + (clock_sync_seconds*got_time*1000)
+((!got_time)*250)) {
ntp_ts = millis();
Serial.println("Sending NTP request");
ntp.begin_request(ntp_ip,[] (time_t result, void* state) {
Serial.println("NTP response received");
current_time = utc_offset + result;
got_time = true;
});
}
ntp.update();
}
break;
}
What's happening here is we're moving through 3 possible states. The first is disconnected, in which case we try to connect synchronously. I could have done this async, and I often do, but it complicates the code, and usually doesn't take that long to connect anyway. I did write this code so that it could be turned asynchronously without gutting it all. In the next case, that's when we first connect. We grab the NTP server IP and also use the IP location service to get our location information. Of particular interest in this case is the UTC offset. The final case is when we are connected. All we do is monitor to make sure we're remaining connected, and then updating the NTP requesting as necessary.
The next part of the loop handles the clock incrementing and drawing logic:
static uint32_t ts_sec = 0;
static bool dot = false;
// once every second...
if (!ts_sec || millis() > ts_sec + 1000) {
refresh = true;
ts_sec = millis();
if(connect_state==2) { // is connected?
++current_time;
} else {
current_time = 12*60*60;
}
tim = *localtime(¤t_time);
if (dot) {
if(am_pm) {
if(tim.tm_hour>=12) {
strftime(timbuf, sizeof(timbuf), "%I:%M.", &tim);
} else {
strftime(timbuf, sizeof(timbuf), "%I:%M", &tim);
}
if(tim.tm_hour%12<10) {
*timbuf='!';
}
} else {
strftime(timbuf, sizeof(timbuf), "%H:%M", &tim);
}
} else {
if(am_pm) {
if(tim.tm_hour>=12) {
strftime(timbuf, sizeof(timbuf), "%I %M.", &tim);
} else {
strftime(timbuf, sizeof(timbuf), "%I %M", &tim);
}
if(tim.tm_hour%12<10) {
*timbuf='!';
}
} else {
strftime(timbuf, sizeof(timbuf), "%H %M", &tim);
}
}
dot = !dot;
}
if(refresh) {
refresh = false;
fb_type fb(text_bounds.dimensions(),lcd_buffer);
fb.fill(fb.bounds(),color_t::dark_gray);
typename lcd_t::pixel_type px = color_t::black.blend(color_t::white,0.42f);
if(am_pm) {
oti.text = "\x7E\x7E:\x7E\x7E.";
} else {
oti.text = "\x7E\x7E:\x7E\x7E";
}
draw::text(fb,fb.bounds(),oti,px);
oti.text = timbuf;
px = color_t::black;
draw::text(fb,fb.bounds(),oti,px);
#ifdef BOARD_HAS_PSRAM
draw::bitmap(lcd,text_bounds,fb,fb.bounds());
#else
draw::wait_all_async(lcd);
draw::bitmap_async(lcd,text_bounds,fb,fb.bounds());
#endif
}
#ifdef TTGO_T1
dimmer.wake();
ttgo_update();
#endif
#ifdef M5STACK_CORE2
touch.update();
uint16_t x,y;
if(touch.xy(&x,&y)) {
am_pm = !am_pm;
calculate_positioning();
}
#endif
The first thing we do is keep a timer which we run every second. On that second, we toggle the state of the : dots between the numbers, and also increment the time if we're connected already. Otherwise, if we aren't connected, the time returns to 12:00.
After that, we wrap our LCD memory buffer we allocated in setup() with a bitmap, fb (framebuffer).
We fill it with the background color, and then blend a light gray to use to draw the ghosted LCD impressions behind the actual text. The character code for all segments filled in is \x7E.
We draw that text to the bitmap, and then set the color to black, and this time, draw the time right over the top of what we just drew which will fill in the appropriate segments.
Finally, we send the entire bitmap to the display.
Due to the fact that it can run on a battery, the TTGO library incorporates a dimmer widget that fades out the display after a timeout. We don't want that feature so we just wake() it every time.
#pragma once
#ifndef ESP32
#error "This library only supports the ESP32 MCU."
#endif
#include <Arduino.h>
namespace arduino {
struct ip_loc final {
static bool fetch(float* out_lat,
float* out_lon,
long* out_utc_offset,
char* out_region,
size_t region_size,
char* out_city,
size_t city_size);
};
}
This one method is used to fetch latitude, longitude, UTC offset, region, region size, city, and city size in one call using an IP locator REST service.
#ifdef ESP32
#include <ip_loc.hpp>
#include <HTTPClient.h>
namespace arduino {
bool ip_loc::fetch(float* out_lat,
float* out_lon,
long* out_utc_offset,
char* out_region,
size_t region_size,
char* out_city,
size_t city_size) {
// URL for IP resolution service
constexpr static const char* url =
"http://ip-api.com/csv/?fields=lat,lon,region,city,offset";
HTTPClient client;
client.begin(url);
if(0>=client.GET()) {
return false;
}
Stream& stm = client.getStream();
String str = stm.readStringUntil(',');
int ch;
if(out_region!=nullptr && region_size>0) {
strncpy(out_region,str.c_str(),min(str.length(),region_size));
}
str = stm.readStringUntil(',');
if(out_city!=nullptr && city_size>0) {
strncpy(out_city,str.c_str(),min(str.length(),city_size));
}
float f = stm.parseFloat();
if(out_lat!=nullptr) {
*out_lat = f;
}
ch = stm.read();
f = stm.parseFloat();
if(out_lon!=nullptr) {
*out_lon = f;
}
ch = stm.read();
long lt = stm.parseInt();
if(out_utc_offset!=nullptr) {
*out_utc_offset = lt;
}
client.end();
return true;
}
}
#endif
The service reports the information in CSV format, so it's really easy to parse without having to include something heavy handed like a JSON parser. That's most of what's happening in this code.
This code handles interfacing with an NTP server asynchronously.
#pragma once
#ifndef ESP32
#error "This library only supports the ESP32 MCU."
#endif
#include <Arduino.h>
namespace arduino {
typedef void(*ntp_time_callback)(time_t, void*);
class ntp_time final {
bool m_requesting;
time_t m_request_result;
byte m_packet_buffer[48];
ntp_time_callback m_callback;
void* m_callback_state;
public:
inline ntp_time() : m_requesting(false),m_request_result(0) {}
void begin_request(IPAddress address,
ntp_time_callback callback = nullptr,
void* callback_state = nullptr);
inline bool request_received() const { return m_request_result!=0;}
inline time_t request_result() const { return m_request_result; }
inline bool requesting() const { return m_requesting; }
void update();
};
} // namespace arduino
Essentially, you call begin_request() with an IP of an NTP server, and an optional callback, plus any user defined state to pass along to that callback.
You then continually call update() until you're called back and/or request_received() returns true. You can then get the request_result() or it will be passed to the callback.
#ifdef ESP32
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClient.h>
#include <WiFiUdp.h>
#include <ntp_time.hpp>
namespace arduino {
WiFiUDP g_ntp_time_udp;
void ntp_time::begin_request(IPAddress address,
ntp_time_callback callback,
void* callback_state) {
memset(m_packet_buffer, 0, 48);
m_packet_buffer[0] = 0b11100011; // LI, Version, Mode
m_packet_buffer[1] = 0; // Stratum, or type of clock
m_packet_buffer[2] = 6; // Polling Interval
m_packet_buffer[3] = 0xEC; // Peer Clock Precision
// 8 bytes of zero for Root Delay & Root Dispersion
m_packet_buffer[12] = 49;
m_packet_buffer[13] = 0x4E;
m_packet_buffer[14] = 49;
m_packet_buffer[15] = 52;
//NTP requests are to port 123
g_ntp_time_udp.beginPacket(address, 123);
g_ntp_time_udp.write(m_packet_buffer, 48);
g_ntp_time_udp.endPacket();
m_request_result = 0;
m_requesting = true;
m_callback_state = callback_state;
m_callback = callback;
}
void ntp_time::update() {
m_request_result = 0;
if(m_requesting) {
// read the packet into the buffer
// if we got a packet from NTP, read it
if (0 < g_ntp_time_udp.parsePacket()) {
g_ntp_time_udp.read(m_packet_buffer, 48);
//the timestamp starts at byte 40 of the received packet and is four bytes,
// or two words, long. First, extract the two words:
unsigned long hi = word(m_packet_buffer[40], m_packet_buffer[41]);
unsigned long lo = word(m_packet_buffer[42], m_packet_buffer[43]);
// combine the four bytes (two words) into a long integer
// this is NTP time (seconds since Jan 1 1900):
unsigned long since1900 = hi << 16 | lo;
// Unix time starts on Jan 1 1970. In seconds, that's 2208988800:
constexpr const unsigned long seventyYears = 2208988800UL;
// subtract seventy years:
m_request_result = since1900 - seventyYears;
m_requesting = false;
if(m_callback!=nullptr) {
m_callback(m_request_result,m_callback_state);
}
}
}
}
}
#endif
It's a UDP poller with some math. That's it. NTP is pretty simple, if a bit cryptic at points despite that.