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





5.00/5 (7投票s)
使用 GFX 和少量代码创建简单的同步模拟时钟
引言
时钟非常有用,但大多数物联网设备都没有内置时钟来保持时间。即使你添加了一个,你仍然需要某种方式来设置它,而大多数物联网设备的输入功能都有待改进。
另外,为了更 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(¤t_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