ttgo_clock:一个支持互联网的复古数字时钟





5.00/5 (5投票s)
一个同步 NTP 时间并从 IP 获取时区的、具有炫酷数字的时钟。

引言
由于上了年纪,我养成了奇怪的睡眠习惯,经常在深夜醒来。在那些时间里,挑战是如何自娱自乐而不吵醒家里的所有人。正是这些深夜的“女巫时间”,我制作了像这样的项目。
我想要一个复古的 LCD 时钟界面。我不知道为什么,就是觉得它很有吸引力,而且看起来是个有趣的项目,所以就这样了。
由于 TTGO T1 没有内置实时时钟,我直接让它使用互联网来同步时间。
必备组件
- 您需要一台装有 VS Code 和 Platform IO 扩展的 PC。(安装 Python 并将其添加到路径中,然后在安装该扩展之前注销 Windows 并重新登录)
- 您需要一个 Lilygo TTGO T-Display T1。这些设备通常可以在亚马逊、Lilygo 官网或 AliExpress 上找到。或者,您也可以使用 M5Stack Core2。
理解这段乱码
这个项目使用了我的 htcw_gfx 库来绘制图形。它使用了很久以前我在网上找到的一个 TrueType 字体来显示数字。它还使用了我之前为一个天气时钟项目编写的 IP 定位类和 NTP 类。
基本上,在主循环中,会发生几件事情:
- 管理连接状态。如果断开连接,它会重新连接。
- 连接后,它将根据您的 IP 获取您估计的地理位置,并解析 NTP 服务器的 IP 地址以供使用。
- 连接期间,每 30 秒发送一次 NTP 请求。
- 每秒更新一次时间计数器。
- 每秒切换一次时钟界面上的点,然后重新绘制界面。
另外,如果按下按钮(TTGO)或触摸屏幕(Core 2),时钟将在 24 小时制和 12 小时制之间切换。
编写这个混乱的程序
主要代码 - main.cpp
导入 htcw_ttgo 库并包含 <ttgo.hpp>
后,一切基本就绪。Core 2 的设置稍微复杂一些,因为它没有一个集成的库。
#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
除非您使用的是 M5Stack Core2 并想使用它,否则您可以忽略 M5STACK_CORE2
部分。我将其添加到项目中只是为了演示添加更多设备支持是多么直接。
// set these to assign an SSID and pass for WiFi
constexpr static const char* ssid = nullptr;
constexpr static const char* pass = nullptr;
您需要分配这些,除非您的 ESP32 记住了您上次使用过的 Wi-Fi 凭据,这是默认行为。
#define DSEG14CLASSIC_REGULAR_IMPLEMENTATION
#include <assets/DSEG14Classic_Regular.hpp>
static const gfx::open_font& text_font = DSEG14Classic_Regular;
这是我们使用我的图形库的 在线转换器工具 将 TrueType 字体导入为头文件。
// 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;
这是我们的 NTP 配置。您应该不需要更改它。
全局导入和全局变量紧随其后。
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;
下一个例程计算我们的文本边界和大小。
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());
}
我们根据屏幕宽度和数字“0”的宽度来计算。之所以可以这样做,是因为我们的字体是等宽的,但许多 TTF 字体不是。
现在是一个按钮处理程序 - 按下按钮会在 24 小时制和 12 小时制之间切换。
#ifdef TTGO_T1
void on_pressed_changed(bool pressed, void* state) {
if(pressed) {
am_pm = !am_pm;
calculate_positioning();
}
}
#endif
在 setup()
函数中,我们初始化所有内容并为显示器分配内存。
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;
}
基本上,我们初始化设备,然后分配一个 LCD 缓冲区来支持我们将要使用的位图。将位图绘制到显示器通常比直接绘制到显示器更有效。位图只需要足够大以容纳文本,而不是整个屏幕。如果板子有 PSRAM,我们会使用它,但我们无法使用 PSRAM 进行 DMA,所以如果我们使用它,异步传输将默默失败。我们使用这些传输将位图发送到显示器,但在这种情况下,为了弥补,如果我们使用 PSRAM,我们就不使用异步传输。性能损失可以忽略不计。无论如何,我们都不需要异步传输。
最后,在初始化 WiFi 无线电后,我们将初始时间设置为 12:00。
loop()
函数的第一部分被分割到一个 switch
语句中,其中包含不同连接状态的 case
。
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;
}
这里发生的是我们正在经历 3 种可能的状态。第一种是断开连接,在这种情况下,我们尝试同步连接。我可以异步完成,而且我经常这样做,但它会使代码复杂化,而且通常连接也花不了多长时间。我编写此代码是为了能够在不破坏代码的情况下将其异步化。在下一个 case 中,是我们首次连接的时候。我们获取 NTP 服务器 IP,并使用 IP 定位服务获取我们的位置信息。在此 case 中特别值得关注的是 UTC 偏移量。最后一个 case 是当我们已连接时。我们所做的只是监控以确保我们保持连接,然后根据需要更新 NTP 请求。
循环的下一部分处理时钟递增和绘图逻辑。
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
我们做的第一件事是维护一个计时器,每秒运行一次。在这一秒钟,我们切换数字之间冒号(:
)的状态,并在已连接的情况下递增时间。否则,如果未连接,时间将重置为 12:00。
之后,我们将 setup()
中分配的 LCD 内存缓冲区用一个位图 fb
(framebuffer) 包装起来。
我们用背景色填充它,然后混合一种浅灰色,用于在实际文本后面绘制虚化的 LCD 印象。所有段都填充的字符代码是 \x7E
。
我们将该文本绘制到位图中,然后将颜色设置为黑色,这次,将时间直接绘制在我们刚刚绘制的内容之上,这将填充相应的段。
最后,我们将整个位图发送到显示器。
由于它可以由电池供电,TTGO 库包含一个调光器小部件,该小部件会在超时后逐渐淡出显示器。我们不想要这个功能,所以我们每次都调用 wake()
。
IP 定位服务代码 - ip_loc.hpp/.cpp
#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);
};
}
此方法用于通过 IP 定位器 REST 服务一次性获取纬度、经度、UTC 偏移量、区域、区域大小、城市和城市大小。
#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
该服务以 CSV 格式报告信息,因此无需包含 JSON 解析器等重量级库即可轻松解析。这就是此代码大部分功能。
NTP 服务 - ntp_time.hpp/.cpp
此代码负责异步与 NTP 服务器进行交互。
#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
本质上,您调用 begin_request()
,传入 NTP 服务器的 IP 地址,以及一个可选的回调函数,再加上任何用户定义的要传递给该回调函数的状态。
然后,您需要持续调用 update()
,直到收到回调并且/或者 request_received()
返回 true。然后,您可以获取 request_result()
,或者它将被传递到回调函数。
#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
它是一个 UDP 轮询器,带有一些数学计算。仅此而已。NTP 相当简单,尽管在某些地方可能有点晦涩。
历史
- 2024 年 3 月 18 日 - 首次提交
- 2024 年 3 月 19 日 - 添加了 am/pm 模式切换