65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIconstarIcon

5.00/5 (5投票s)

2024 年 3 月 18 日

MIT

6分钟阅读

viewsIcon

9638

downloadIcon

207

一个同步 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 类。

基本上,在主循环中,会发生几件事情:

  1. 管理连接状态。如果断开连接,它会重新连接。
  2. 连接后,它将根据您的 IP 获取您估计的地理位置,并解析 NTP 服务器的 IP 地址以供使用。
  3. 连接期间,每 30 秒发送一次 NTP 请求。
  4. 每秒更新一次时间计数器。
  5. 每秒切换一次时钟界面上的点,然后重新绘制界面。

另外,如果按下按钮(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(&current_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 模式切换
© . All rights reserved.