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

用于 IoT 的互联网连接模拟时钟 (ESP32)

starIconstarIconstarIconstarIconstarIcon

5.00/5 (7投票s)

2022 年 6 月 10 日

MIT

6分钟阅读

viewsIcon

14410

downloadIcon

120

使用 GFX 和少量代码创建简单的同步模拟时钟

worldtime clock

引言

时钟非常有用,但大多数物联网设备都没有内置时钟来保持时间。即使你添加了一个,你仍然需要某种方式来设置它,而大多数物联网设备的输入功能都有待改进。

另外,为了更 Fancy,我们将以模拟形式显示时间。

请注意,此项目是为 M5 Stack 编写的,但可以轻松地与任何 ESP32 一起使用。你只需要更改驱动程序。切换到 ILI9341 只需要在一行代码中更改几个字符。

背景

Worldtime API 是一项服务,允许你通过基于 JSON 的 REST 服务检索时间。我们将使用它来同步时钟。

此外,我们将使用我的 GFX 库来绘制时钟。除此之外,事情都相当直接。

我最初因为时区而偏爱 Worldtime API 而不是 NTP,但我发现 IP 检测非常不可靠——我的 IP 检测结果离我住的地方有几个时区的距离。不过,使用它为我们提供了更高级和完整的基于 Web 的时间服务的基准,如果它们可用的话。

使用此方法的一个优点仍然是闪存空间。NTP 需要 UDP,但大多数服务使用 TCP。你可能需要为许多应用程序同时包含这两种,这并不微不足道。如果你已经在使用的 Web 服务,此库的额外开销是微不足道的。

更新:我已经将 GitHub 上的代码更新为使用 NTP。这样做是为了测试它对这个时钟的可行性,而且它运行得很棒,所以我将保留它。我已经将 worldtime 代码保留在项目中,但它不再被使用。

编写这个混乱的程序

worldtime.cpp

#include <Arduino.h>
#ifdef ESP32
#include <pgmspace.h>
#include <HTTPClient.h>
#else
#include <avr/pgmspace.h>
#endif
#include <worldtime.hpp>
#ifdef ESP32
time_t worldtime::now(int8_t utc_offset) {
    constexpr static const char* url = "http://worldtimeapi.org/api/timezone/Etc/UTC";
    HTTPClient client;
    client.begin(url);
    if(0>=client.GET()) {
        return time_t(0);
    }
    time_t result = parse(client.getStream());
    client.end();
    return (time_t)result+(utc_offset*3600);
}

#endif
time_t worldtime::parse(Stream& stm) {
    if(!stm.available()) {
        return (time_t)0;
    }
    if(!stm.find("unixtime\":")) {
        return (time_t)0;
    }
    int ch = stm.read();
    long long lt = 0;
    while(ch>='0' && ch<='9') {
        lt*=10;
        lt+=(ch-'0');
        ch=stm.read();
    }
    return (time_t)lt;
}

你可以看到实际的网络请求仅适用于 ESP32,但我已经包含了 parse() 方法,适用于任何平台,以防你有替代库来发出此类请求。

URL 从服务器获取 UTC 时间,并根据 utc_offset 进行偏移。每小时有 3600 秒,所以你只需为每个偏移量加或减。

我们不使用该服务的时间区功能。起初我打算使用,但它没有标准的时间区名称,并且 IP 检测非常不可靠。此外,返回的 unixtime 没有按时区偏移。要获得该偏移量,需要检索多个字段,这些字段可以以任何顺序返回。这给解析文档带来了严重的复杂性。我想避免依赖完整的 JSON 解析器,而是倾向于从机器服务中抓取以节省内存和闪存。它不像真正的 JSON 阅读器那样健壮,但它确实有效,并且对于机器生成的内容来说,它相当可靠。

parse() 相当简单。我们返回 0 表示错误,否则我们查找字符串 unixtime":,然后解析紧跟其后的数字。解析整数很容易。每次我们将累加器乘以 10,然后将字符减去字符 0(ASCII 48)的结果相加,以获得每个数字的值。

main.cpp

首先,我们必须包含并配置所有内容。如果你使用的是 ILI9341 或 ILI9341V,你可能需要稍微更改此代码,如果使用不同的显示器,则需要更多更改,并且还要更改 platformio.ini

你还需要插入你的 SSID、密码和时区的 UTC 偏移量。然而,这其中大部分是样板代码。

#include <Arduino.h>
#include <WiFi.h>
#include <WiFiMulti.h>

#include <gfx_cpp14.hpp>
#include <ili9341.hpp>
#include <tft_io.hpp>
#include <worldtime.hpp>
using namespace arduino;
using namespace gfx;

// wifi
constexpr static const char* ssid = "SSID";
constexpr static const char* password = "PASSWORD";

// timezone
constexpr static const int8_t utc_offset = 0; // UTC

// synchronize with worldtime every 60 seconds
constexpr static const int sync_seconds = 60;

constexpr static const size16 clock_size = {120, 120};

constexpr static const uint8_t spi_host = VSPI;
constexpr static const int8_t lcd_pin_bl = 32;
constexpr static const int8_t lcd_pin_dc = 27;
constexpr static const int8_t lcd_pin_cs = 14;
constexpr static const int8_t spi_pin_mosi = 23;
constexpr static const int8_t spi_pin_clk = 18;
constexpr static const int8_t lcd_pin_rst = 33;
constexpr static const int8_t spi_pin_miso = 19;

using bus_t = tft_spi_ex<spi_host, 
                        lcd_pin_cs, 
                        spi_pin_mosi, 
                        spi_pin_miso, 
                        spi_pin_clk, 
                        SPI_MODE0,
                        false, 
                        320 * 240 * 2 + 8, 2>;
using lcd_t = ili9342c<lcd_pin_dc, 
                      lcd_pin_rst, 
                      lcd_pin_bl, 
                      bus_t, 
                      1, 
                      true, 
                      400, 
                      200>;
using color_t = color<typename lcd_t::pixel_type>;

lcd_t lcd;

接下来是我们进行绘制和保持时间的地方。我本可以分解时钟的绘制,但我对代码的封装性尚不完全满意。一方面,它只在原点 (0,0) 绘制。这对于此应用程序来说很棒,因为我们无论如何都会进行双缓冲,但不适合作为库。

template <typename Destination>
void draw_clock(Destination& dst, tm& time, const ssize16& size) {
    using view_t = viewport<Destination>;
    srect16 b = size.bounds().normalize();
    uint16_t w = min(b.width(), b.height());
    srect16 sr(0, 0, w / 16, w / 5);
    sr.center_horizontal_inplace(b);
    view_t view(dst);
    view.center(spoint16(w / 2, w / 2));
    static const float rot_step = 360.0/6.0;
    for (float rot = 0; rot < 360; rot += rot_step) {
        view.rotation(rot);
        spoint16 marker_points[] = {
            view.translate(spoint16(sr.x1, sr.y1)),
            view.translate(spoint16(sr.x2, sr.y1)),
            view.translate(spoint16(sr.x2, sr.y2)),
            view.translate(spoint16(sr.x1, sr.y2))};
        spath16 marker_path(4, marker_points);
        draw::filled_polygon(dst, marker_path, 
          color<typename Destination::pixel_type>::gray);
    }
    sr = srect16(0, 0, w / 16, w / 2);
    sr.center_horizontal_inplace(b);
    view.rotation((time.tm_sec / 60.0) * 360.0);
    spoint16 second_points[] = {
        view.translate(spoint16(sr.x1, sr.y1)),
        view.translate(spoint16(sr.x2, sr.y1)),
        view.translate(spoint16(sr.x2, sr.y2)),
        view.translate(spoint16(sr.x1, sr.y2))};
    spath16 second_path(4, second_points);

    view.rotation((time.tm_min / 60.0) * 360.0);
    spoint16 minute_points[] = {
        view.translate(spoint16(sr.x1, sr.y1)),
        view.translate(spoint16(sr.x2, sr.y1)),
        view.translate(spoint16(sr.x2, sr.y2)),
        view.translate(spoint16(sr.x1, sr.y2))};
    spath16 minute_path(4, minute_points);

    sr.y1 += w / 8;
    view.rotation(((time.tm_hour%12) / 12.0) * 360.0);
    spoint16 hour_points[] = {
        view.translate(spoint16(sr.x1, sr.y1)),
        view.translate(spoint16(sr.x2, sr.y1)),
        view.translate(spoint16(sr.x2, sr.y2)),
        view.translate(spoint16(sr.x1, sr.y2))};
    spath16 hour_path(4, hour_points);

    draw::filled_polygon(dst, 
                        minute_path, 
                        color<typename Destination::pixel_type>::black);

    draw::filled_polygon(dst, 
                        hour_path, 
                        color<typename Destination::pixel_type>::black);

    draw::filled_polygon(dst, 
                        second_path, 
                        color<typename Destination::pixel_type>::red);
}

GFX viewport<> 允许你在现有的绘制目标上创建旋转和/或偏移的绘制目标。我们不需要所有这些,它在理论上比实践中效果更好,但我们可以使用它的 translate() 函数来旋转时钟指针和刻度标记的一些点。首先,我们每 60 度绘制一次刻度标记。接下来,我们为每个指针创建路径。我们通过在 12:00:00 位置创建(重新使用)我们的矩形 sr 来做到这一点。然后,我们根据小时、分钟或秒的数量将其旋转一个分数。最后,我们绘制指针的多边形。

接下来,我们有我们的全局变量和 setup()

uint32_t update_ts;
uint32_t sync_count;
time_t current_time;
srect16 clock_rect;
using clock_bmp_t = bitmap<typename lcd_t::pixel_type>;
uint8_t clock_bmp_buf[clock_bmp_t::sizeof_buffer(clock_size)];
clock_bmp_t clock_bmp(clock_size, clock_bmp_buf);
void setup() {
    Serial.begin(115200);
    lcd.fill(lcd.bounds(),color_t::white);
    Serial.print("Connecting");
    WiFi.begin(ssid, password);
    while (!WiFi.isConnected()) {
        Serial.print(".");
        delay(1000);
    }
    Serial.println();
    Serial.println("Connected");
    clock_rect = srect16(spoint16::zero(), (ssize16)clock_size);
    clock_rect.center_inplace((srect16)lcd.bounds());
    sync_count = sync_seconds;
    current_time = worldtime::now(utc_offset);
    update_ts = millis();
}

在全局变量中,我们保存一个时间戳,同步前的秒数计数,然后是当前时间和时钟所在的矩形,这样我们只需计算一次。

我们还声明了一个位图和一个缓冲区来保存它。我们使用这个位图进行绘制,然后将其blit 到屏幕上,使绘制更流畅。这称为双缓冲。

setup() 方法中,我们用白色填充屏幕,连接到 WiFi,然后计算时钟矩形,设置下一次同步倒计时,同步时间,然后准备时间戳。

接下来,我们有 loop() 方法。

void loop() {
    uint32_t ms = millis();
    if (ms - update_ts >= 1000) {
        update_ts = ms;
        ++current_time;
        tm* t = localtime(&current_time);
        Serial.println(asctime(t));
        draw::wait_all_async(lcd);
        draw::filled_rectangle(clock_bmp, 
                              clock_size.bounds(), 
                              color_t::white);
        draw_clock(clock_bmp, *t, (ssize16)clock_size);
        draw::bitmap_async(lcd, 
                          clock_rect, 
                          clock_bmp, 
                          clock_bmp.bounds());
        if (0 == --sync_count) {
            sync_count = sync_seconds;
            current_time = worldtime::now(utc_offset);
        }
    }
}

我们使用标准的模式每秒运行一次,这正是 update_ts 的作用。在这秒钟内,我们增加当前时间,并从中创建一个 tm 结构。我们将其转储到串行端口。接下来,我们告诉 GFX 等待 lcd 的所有待处理操作完成。这是因为我们以异步方式绘制以获得最佳性能,并且虽然它永远不应该超过一秒,但如果没有这一行,它就会导致内存损坏和可能的异常。原因是不能在后台设备传输的同时写入缓冲区。

接下来,我们通过用白色矩形填充来清除我们之前创建的时钟位图。然后我们根据当前时间将其绘制到上面。

现在我们将位图异步发送到设备。这使用 DMA 进行传输,因此调用会立即返回,并且传输会在后台继续,从而提供略微更好的性能。

最后,我们在必要时发出同步时钟的请求。这就是我们之前的 DMA 传输派上用场的地方,因为它允许我们同时与显示器和 WiFi 硬件通信,而不是连续通信,希望可以消除时钟同步时的潜在“跳帧”,但这都取决于网络带宽和延迟。

关注点

我在 include 文件夹中包含了一个名为“telegrama”的小型 True Type 字体。这可以使用 GFX 来打印文本消息或数字时钟到显示器。在这个项目中,我避免了简化复杂性。

历史

  • 2022 年 6 月 10 日 - 首次提交
  • 2022 年 6 月 11 日 - 更新 GitHub 版本以使用 NTP
© . All rights reserved.