Core 2 Clock - 深入了解我的 IoT 生态系统





5.00/5 (6投票s)
使用我的 IoT 生态系统制作一个互联网连接的时钟
引言
更新:一个依赖的 LCD 驱动程序已更改。如果您的屏幕显示空白,请重新下载此代码,或从 Github 获取。
我喜欢用时钟作为演示代码。它倾向于能展示相当多的功能,同时又不会过于复杂,考虑到我们正在全力测试 ESP32。在这里,我们将探讨我的时钟代码,重点是使用我的生态系统来构建 ESP32 或其他 MCU 项目,可以使用 Arduino 或(仅限 ESP32)ESP-IDF。
在这里,我们将使用我的图形库 htcw_gfx、我的 UI 库 htcw_uix、我的 Wi-Fi 管理库 htcw_esp_wifi_manager、我的 NTP 时间库 htcw_esp_ntp_time 以及 IP 地理定位库 htcw_esp_ip_loc,以及我的跨平台 I2C 初始化库 htcw_esp_i2c 和一些硬件驱动程序。
必备组件
- 您需要安装最新版本的 Python 并将其添加到您的 PATH 中(对于 Platform IO - 如果您已经让 Platform IO 工作,则不需要此步骤)
- 您需要安装了 Platform IO IDE 扩展的 VS Code
- 您需要一个 M5 Stack Core 2 或 M5 Stack Tough
- 您需要在 include/wifi_creds.h 文件中提供您的 Wi-Fi 凭据 - 在该文件中定义
WIFI_SSID "my_ssid"
和WIFI_PASS "my_password"
。
这是 wifi_creds.h 的模板
#ifndef WIFI_CREDS_H
#define WIFI_CREDS_H
#define WIFI_SSID "my_ssid"
#define WIFI_PASS "my_password"
#endif
背景
基本上,这个项目是一个时钟。它使用 ip-api.com 进行 IP 地理定位,并使用 ntp.org 获取时间。它显示一个模拟时钟、电池电量计、Wi-Fi 指示图标、日期和时间文本以及时区。它将时间存储在内部时钟中,并使用该时钟来计时,尽管由于我们已连接互联网,因此我们不需要这样做。不过,这样设置好的时钟可以在没有互联网连接的情况下工作。
I2C 初始化
Arduino、ESP-IDF 4.0+ 和 ESP-IDF 5.0+ 各有不同的 I2C 驱动机制。我将初始化中的差异抽象到了一个 esp_i2c<>
模板中,该模板接受端口号(0 或 1)、SDA 引脚和 SCL 引脚,然后初始化总线,并报告一个静态 instance
句柄,该句柄可以传递给我基于 I2C 的驱动库的构造函数。这使得在不同平台上轻松初始化驱动程序成为可能。
显示面板
该项目使用 ESP LCD Panel API 将屏幕部分的位图发送到显示屏。
它使用我的 htcw_uix UI 库屏幕对象,根据屏幕上布局的控件/小部件(在此项目中我们只使用一个屏幕)来生成这些位图。
htcw_uix 使用我的图形库 htcw_gfx 来实际绘制到这些位图上。
它使用我的 htcw_ft6336 库(Core 2)或我的 htcw_chsc6540(Tough)触摸屏驱动程序进行触摸输入。触摸输入会馈送到 htcw_uix,后者将触摸事件分派给相关的控件/小部件。
将所有这些连接在一起的相关代码位于 include/panel.hpp 和 src/panel.cpp 中。
Wi-Fi 管理
此代码使用我的 wifi_manager
来管理 ESP-IDF 或 Arduino 下的 Wi-Fi 连接。它提供了一个简单的、一致的接口,无论平台如何。此项目使用它简短地连接到网络并从在线服务获取相关时间信息,然后再次关闭无线电。
电源管理
M5 Stack Core 2 和 Tough 都集成了电池和 AXP192 电源管理 IC。每次使用它们时都必须“触碰”AXP192,否则屏幕将不显示任何内容,并可能出现其他奇怪的问题。为此,我创建了 m5stack_power 和 m5tough_power 类,它们在初始化时会进行适当的“触碰”,然后为您提供对电池状态和 AC 状态的访问,我们使用这些来显示电池信息。
时间管理
这是代码中最复杂的部分,原因是使用了多个在线服务以及一些硬件来驱动时钟。
ip_loc
类用于查询 ip-api.com。它底层使用了我的 JSON 拉解析器库和我的嵌入式 IO 流库来读取结果。该 API 通过一个简单的 fetch
方法暴露,该方法可以选择性地接受 API 可以返回的各种信息的几个参数。
ntp_time
类用于查询 pool.ntp.org
获取当前时间,该时间根据 ip_loc
返回的信息为您所在的时区进行偏移。由于该域如名称所示是一个池,因此类在每次查找时都会执行域名解析。这可能有点慢,但由于更新频率非常低(默认每 10 分钟一次),因此可能无关紧要。最终更慢的是实际的 NTP UDP 来回通信,我们的代码尝试在获取时间时对其进行补偿。
bm8563
类管理设备内置的实时时钟外设。每次从在线服务获取时间(同样,默认每 10 分钟一次)时,它都会设置时钟。否则,它会在固件主循环的每次迭代中读取时钟并更新屏幕上的变化。
用户界面
用户界面由几个控件(也称为小部件)组成:有两个用于 Wi-Fi 和电池指示灯的画布控件,两个用于日期/时间的时钟标签,以及一个基于 SVG 的模拟时钟。我的图形库支持 SVG,并且可以在不访问 XML 的情况下在内存中构建 SVG,尽管它也可以解析简单的 SVG 来自 XML。我的用户界面库中内置了包括此钟表在内的几个控件。ESP32 的浮点处理器性能很差,因此它 apenas 足够快地同时在屏幕上绘制几个交互式 SVG 控件。要节约使用。最好确保您的面板传输缓冲区足够大,能够容纳您最大的 SVG 控件。这样做可以防止 UIX 多次重绘控件以更新显示。
这里的主要工作是创建我们的模板实例化别名,并声明屏幕和控件,这些在本项目中位于 include/ui.hpp 中。这些项的实际实现位于 src/main.cpp 中。
在 setup()
/app_main()
初始化代码中,我们设置了我们将要使用的屏幕和控件。这基本上包括设置各种属性,包括各种颜色和指示控件在屏幕上布局位置的边界。对于画布控件,我们设置了绘制回调。
使用代码
src/main.cpp
我们将花费大部分精力来探讨 src/main.cpp,因为那里是大部分活动发生的地方。如有必要,我们还将介绍其他文件。从顶部开始
#if __has_include(<Arduino.h>)
#include <Arduino.h>
#else
#include <freertos/FreeRTOS.h>
#include <stdint.h>
void loop();
static uint32_t millis() {
return pdTICKS_TO_MS(xTaskGetTickCount());
}
#endif
这是一点魔法酱,可以让这段代码在 ESP-IDF 或 Arduino 下工作。如果 Arduino.h 头文件可用,我们假定是 Arduino。否则,我们假定是 ESP-IDF。对于后者,我们为 loop()
提供了一个原型,以便稍后调用它,并提供了一个包装器,它公开自启动以来的毫秒数,以与 Arduino 兼容。
#include <esp_i2c.hpp> // i2c initialization
#ifdef M5STACK_CORE2
#include <m5core2_power.hpp> // AXP192 power management (core2)
#endif
#ifdef M5STACK_TOUGH
#include <m5tough_power.hpp> // AXP192 power management (tough)
#endif
#include <bm8563.hpp> // real-time clock
#include <uix.hpp> // user interface library
#include <gfx.hpp> // graphics library
#include <wifi_manager.hpp> // wifi connection management
#include <ip_loc.hpp> // ip geolocation service
#include <ntp_time.hpp> // NTP client service
// font is a TTF/OTF from downloaded from fontsquirrel.com
// converted to a header with https://honeythecodewitch.com/gfx/converter
#define OPENSANS_REGULAR_IMPLEMENTATION
#include "assets/OpenSans_Regular.hpp" // our font
// icons generated using https://honeythecodewitch.com/gfx/iconPack
#define ICONS_IMPLEMENTATION
#include "assets/icons.hpp" // our icons
// include this after everything else except ui.hpp
#include "config.hpp" // time and font configuration
#include "ui.hpp" // ui declarations
#include "panel.hpp" // display panel functionality
这些是我们的包含文件。有很多,但我已在上面的注释中简要总结了它们的作用。
// namespace imports
#ifdef ARDUINO
using namespace arduino; // libs (arduino)
#else
using namespace esp_idf; // libs (idf)
#endif
using namespace gfx; // graphics
using namespace uix; // user interface
我们的命名空间导入在上。这些不需要太多解释。
#ifdef M5STACK_CORE2
using power_t = m5core2_power;
#endif
#ifdef M5STACK_TOUGH
using power_t = m5tough_power;
#endif
// for AXP192 power management
static power_t power(esp_i2c<1,21,22>::instance);
这是我们的电源管理类声明。根据设备的不同,我们选择合适的类。请注意,我们是如何使用 esp_i2c
API 在指定的宿主机和引脚上初始化 I2C,然后将 instance
传递给构造函数的。
// for the time stuff
static bm8563 time_rtc(esp_i2c<1,21,22>::instance);
static char time_buffer[64];
static long time_offset = 0;
static ntp_time time_server;
static char time_zone_buffer[64];
static bool time_fetching=false;
在这里,我们声明了我们的时钟,同样使用 esp_i2c
来初始化它。我们声明了一个缓冲区来保存时间字符串、UTC 偏移量(以秒为单位)、NTP 时间客户端类、一个缓冲区来保存时区字符串,以及一个标志,指示我们是否正在获取时间。
// connection state for our state machine
typedef enum {
CS_IDLE,
CS_CONNECTING,
CS_CONNECTED,
CS_FETCHING,
CS_POLLING
} connection_state_t;
static connection_state_t connection_state = CS_IDLE;
我们在 loop()
中使用一个简单的状态机来管理 Wi-Fi 连接和在线数据获取。这样做可以避免在可能耗时的操作中阻塞,从而在获取过程中时钟能够平稳运行。
static wifi_manager wifi_man;
在这里,我们简单地声明了用于管理我们 Wi-Fi 连接的 WiFi 管理器类。
// the screen/control definitions
screen_t main_screen;
svg_clock_t ana_clock(main_screen);
label_t dig_clock(main_screen);
label_t time_zone(main_screen);
canvas_t wifi_icon(main_screen);
canvas_t battery_icon(main_screen);
这些是我们的 UIX 控件和屏幕定义。它们在 include/ui.hpp 中声明,但在代码中实现。我们有主屏幕,所有控件都在上面布局。我们有模拟时钟、“数字”时钟(这只是一个标签)、时区标签,以及用于绘制 Wi-Fi 和电池图标的画布。
// updates the time string with the current time
static void update_time_buffer(time_t time) {
char sz[64];
tm tim = *localtime(&time);
*time_buffer = 0;
strftime(sz, sizeof(sz), "%D ", &tim);
strcat(time_buffer,sz);
strftime(sz, sizeof(sz), "%I:%M %p", &tim);
if(*sz=='0') {
*sz=' ';
}
strcat(time_buffer,sz);
}
此例程将一个 time_t 转换为 12 小时格式的日期和时间字符串,存储在 time_buffer
中。在代码的最后部分,我们删除了小时数的前导零,因为这样看起来不太好。
static void wifi_icon_paint(surface_t& destination,
const srect16& clip,
void* state) {
// if we're using the radio, indicate it
// with white. otherwise dark gray
auto px = rgb_pixel<16>(3,6,3);
if(time_fetching) {
px = color_t::white;
}
draw::icon(destination,point16::zero(),faWifi,px);
}
这处理了一个画布控件的“绘制时”回调。destination
是我们正在绘制的目标绘制表面,clip
是目标内我们需要绘制的矩形 - 您可以忽略它,但它出于性能原因而存在。state
是每次调用时传递的用户定义值。我们在这里不使用它。
我们正在以 RGB565 格式声明一个深灰色像素。R=3 (0-31),G=6 (0-63),B=3 (0-31)。如果我们在获取时间,则将其变为白色。请注意,我们仅使用 color_t
枚举(在 include/ui.hpp 中声明)来实现此目的,因为它很简单。
最后,我们只需将 faWiFi
图标(include/assets/icons.hpp)绘制到 destination
的 (0,0) 位置,并使用指定的颜色像素 px
。您应该注意到,图标只是 alpha 透明度图。它们没有内在的颜色。您在绘制图标时提供颜色,就像我们在这里所做的那样。
static void battery_icon_paint(surface_t& destination,
const srect16& clip,
void* state) {
// show in green if it's on ac power.
int pct = power.battery_level();
auto px = power.ac_in()?color_t::green:color_t::white;
if(!power.ac_in() && pct<25) {
px=color_t::red;
}
// draw an empty battery
draw::icon(destination,point16::zero(),faBatteryEmpty,px);
// now fill it up
if(pct==100) {
// if we're at 100% fill the entire thing
draw::filled_rectangle(destination,rect16(3,7,22,16),px);
} else {
// otherwise leave a small border
draw::filled_rectangle(destination,rect16(4,9,4+(0.18f*pct),14),px);
}
}
我们在这里也做了类似的事情,只是我们处理的是电池,并且有一些额外的步骤。如果设备已插入外部电源,ac_in()
将返回 true,在这种情况下,我们将电池变为绿色,否则为白色。我们还采样电池百分比。如果低于 25% 且未插入电源,我们将整个电池变为红色。
接下来,我们绘制一个空的电池图标(faBatteryEmpty
)。然后根据我们收到的百分比填充该电池。我们在这里使用了一些魔术数字来在电池图标内部绘制一个小填充矩形。
#ifdef ARDUINO
void setup() {
Serial.begin(115200);
#else
extern "C" void app_main() {
#endif
这是更多用于 Arduino/ESP-IDF 兼容性的代码。它相应地声明了我们的初始化例程,无论是 setup()
(Arduino)还是 app_main()
(ESP-IDF)。
power.initialize(); // do this first
panel_init(); // do this next
power.lcd_voltage(3.0);
time_rtc.initialize();
puts("Clock booted");
if(power.charging()) {
puts("Charging");
} else {
puts("Not charging"); // M5 Tough doesn't charge!?
}
我们在这里初始化一些东西。首先是电源管理,它必须最先进行。之后,我们初始化 LCD 面板和传输缓冲区,它们必须紧随其后。我们将 LCD 电压设置为 3.0,只是因为。老实说,这并非必需,但我认为这样可以节省一点电量。接下来是时钟硬件,之后我们指示已启动以及电池是否正在充电。我的 Tough 的 AXP192 库实现将电池置于非充电状态,我不知道为什么。最终我会弄清楚的,这段代码就会正常工作。
// init the screen and callbacks
main_screen.dimensions({320,240});
main_screen.buffer_size(panel_transfer_buffer_size);
main_screen.buffer1(panel_transfer_buffer1);
main_screen.buffer2(panel_transfer_buffer2);
main_screen.background_color(color_t::black);
我们的屏幕需要一些信息才能工作。它需要知道屏幕的大小、传输缓冲区的大小、指向由 panel_init()
创建的传输缓冲区的指针(第二个是可选的,但支持 DMA)。最后,我们设置背景颜色。默认情况下它是黑色的,所以那一行是可选的。
让我们退一步。如果您熟悉 LVGL,它的工作方式很相似。它使用一个或两个传输缓冲区来支持位图,然后在这些位图上绘制控件,然后将这些位图发送到显示屏。我们创建了 2 个 32KB 的传输缓冲区,以在 ESP32 上实现最大性能,其 DMA 限制为 32KB 传输。我们使用两个缓冲区的原因是,UIX 可以一边向一个缓冲区绘制,一边发送另一个缓冲区,以充分利用 DMA 性能。
// init the analog clock, 128x128
ana_clock.bounds(srect16(0,0,127,127).center_horizontal(main_screen.bounds()));
ana_clock.face_color(color32_t::light_gray);
// make the second hand semi-transparent
auto px = ana_clock.second_color();
// use pixel metadata to figure out what half of the max value is
// and set the alpha channel (A) to that value
px.template channel<channel_name::A>(
decltype(px)::channel_by_name<channel_name::A>::max/2);
ana_clock.second_color(px);
// do similar with the minute hand as the second hand
px = ana_clock.minute_color();
// same as above, but it handles it for you, using a scaled float
px.template channelr<channel_name::A>(0.5f);
ana_clock.minute_color(px);
// make the whole thing dark
ana_clock.hour_border_color(color32_t::gray);
ana_clock.minute_border_color(ana_clock.hour_border_color());
ana_clock.face_color(color32_t::black);
ana_clock.face_border_color(color32_t::black);
main_screen.register_control(ana_clock);
uix::svg_clock<>
有很多属性。这里我们正在设置它。LVGL 有 Squareline Studio 来做这种事情。我的库没有这样的运气,尽管我希望将来能推出一个在线的基于 Web 的设计器。
我们为此(以及几乎任何控件)做的第一件事是告诉 UIX 它在屏幕上的位置。这是通过向 bounds()
访问器提供一个矩形来完成的。在我们的例子中,我们从一个 128x128 的矩形开始,然后将其水平居中在屏幕上,并将结果传递给 bounds()
访问器方法。
现在我们设置了一组颜色。请注意,我们在这里使用的是 RGBA8888 像素格式,或者在 htcw_gfx 术语中是 rgba_pixel<32>
。UIX 中的所有内容(除了屏幕背景色(使用原生格式))都接受此 32 位格式的颜色值。
这里唯一不完全直接的是 alpha 混合。我们将秒针和分针设置为半透明。我们通过设置 alpha 通道 (A) 的值小于 255(整数)或 1.0(缩放实数)来实现此目的,使用 channel<>()
或 channelr<>()
模板访问器方法。这里我们做了两次 - 分别一次用于秒针和分针,第一次通过计算该通道最大值(255)的一半,解析为 127。我们可以只指定 127,但上述技术适用于任何像素类型/格式。
更简单的方法是第二种,但它需要浮点缩放。您可以将任何通道的“实际值”设置为 0 到 1.0 之间的浮点数。例如,0.5 将表示一半,或者 127 缩放到我们的像素格式。
总之,在设置完所有颜色后,我们将控件注册到屏幕(这是必需的,否则它将不会显示)。请注意,时钟的默认颜色在许多情况下都适用,但在这里我们想要一种深色主题。
// init the digital clock, (screen width)x40, below the analog clock
dig_clock.bounds(
srect16(0,0,main_screen.bounds().x2,39)
.offset(0,128));
update_time_buffer(time_rtc.now()); // prime the digital clock
dig_clock.text(time_buffer);
dig_clock.text_open_font(&text_font);
dig_clock.text_line_height(35);
dig_clock.text_color(color32_t::white);
dig_clock.text_justify(uix_justify::top_middle);
main_screen.register_control(dig_clock);
现在我们设置了显示日期和时间的“数字”时钟的标签。我们像以前一样设置 bounds()
,将标签放置在时钟下方,占据屏幕的宽度。请注意,我们正在将所有内容相对于其他内容进行构建。这不仅比计算一堆魔术坐标更容易,而且理论上意味着相同的代码可以用于不同分辨率的屏幕。在这种情况下,这无关紧要,但对于在许多设备上运行的其他项目来说,这可能非常重要。
现在我们使用前面的 update_time_buffer()
方法更新 time_buffer
,以便我们的标签在启动时有一个有意义的值,然后再设置标签的 text()
访问器。
标签需要字体。我们正在使用 gfx::open_font
,因此我们将 text_open_font()
访问器设置为我们 text_font
open_font
实例的指针。如果命名上看起来有点奇怪,请考虑 UIX 和 GFX 支持三种不同的字体格式 - TTF/OTF 矢量(我们正在使用)、FON 光栅和 VLW 抗锯齿光栅。
由于它是矢量字体,我们以像素为单位设置其高度,以便它按我们想要的方式缩放。在某些应用程序中,可能需要根据屏幕大小来确定行高,但在这里我采取了捷径,仅将其设置为 35 像素。
颜色使用 color32_t
枚举(include/ui.hpp)设置为白色,然后我们将对齐方式设置为沿 x 轴居中文本。
最后,我们注册控件,使其能够出现在屏幕上。
const uint16_t tz_top = dig_clock.bounds().y1+dig_clock.dimensions().height;
time_zone.bounds(srect16(0,tz_top,main_screen.bounds().x2,tz_top+40));
time_zone.text_open_font(&text_font);
time_zone.text_line_height(30);
time_zone.text_color(color32_t::light_sky_blue);
time_zone.text_justify(uix_justify::top_middle);
main_screen.register_control(time_zone);
我们对时区也做了类似的处理,只是颜色不同,字体稍微小一些(时区字符串可能很长),并且我们没有初始的 text()
来设置它。
// set up a custom canvas for displaying our wifi icon
wifi_icon.bounds(
srect16(spoint16(0,0),(ssize16)wifi_icon.dimensions())
.offset(main_screen.dimensions().width-
wifi_icon.dimensions().width,0));
wifi_icon.on_paint_callback(wifi_icon_paint);
wifi_icon.on_touch_callback([](size_t locations_size,
const spoint16* locations,
void* state){
if(connection_state==CS_IDLE) {
connection_state = CS_CONNECTING;
}
},nullptr);
main_screen.register_control(wifi_icon);
这实际上很简单。我们将边界框放置在屏幕的右上角,并将绘制回调设置为我们之前涵盖的方法,然后注册控件。一个小技巧是,我们通过将 connection_state
更新为 CS_CONNECTING
来处理触摸回调,如果它处于空闲状态。
// set up a custom canvas for displaying our battery icon
battery_icon.bounds(
(srect16)faBatteryEmpty.dimensions().bounds());
battery_icon.on_paint_callback(battery_icon_paint);
main_screen.register_control(battery_icon);
电池画布与 Wi-Fi 画布类似,只是没有触摸事件,并且它位于屏幕的左上角。
panel_set_active_screen(main_screen);
最后,我们告知面板代码我们当前正在使用主屏幕。在这个应用程序中,我们永远不会更改此设置。
#ifndef ARDUINO
while(1) {
loop();
vTaskDelay(5);
}
#endif
如果我们是在 ESP-IDF 下运行,我们不希望 app_main()
退出,因此我们启动一个无限循环并调用 loop()
方法,在 loop()
调用之间将控制权交给 RTOS。
void loop()
{
///////////////////////////////////
// manage connection and fetching
///////////////////////////////////
static uint32_t connection_refresh_ts = 0;
static uint32_t time_ts = 0;
switch(connection_state) {
在我们的 loop()
方法中,我们声明了几个静态簿记变量来保存时间戳,然后进入我们的连接状态机。
case CS_IDLE:
if(connection_refresh_ts==0 || millis() > (connection_refresh_ts+
(time_refresh_interval*
1000))) {
connection_refresh_ts = millis();
connection_state = CS_CONNECTING;
}
break;
在空闲情况下,我们只检查 time_refresh_interval
(在 include/config.hpp 中指定,单位为秒)是否已过,如果是,则将状态设置为 CS_CONNECTING
。
case CS_CONNECTING:
time_ts = 0; // for latency correction
time_fetching = true; // indicate that we're fetching
wifi_icon.invalidate(); // tell wifi_icon to repaint
// if we're not in process of connecting and not connected:
if(wifi_man.state()!=wifi_manager_state::connected &&
wifi_man.state()!=wifi_manager_state::connecting) {
puts("Connecting to network...");
// connect
wifi_man.connect(wifi_ssid,wifi_pass);
connection_state =CS_CONNECTED;
} else if(wifi_man.state()==wifi_manager_state::connected) {
// if we went from connecting to connected...
connection_state = CS_CONNECTED;
}
break;
这里我们根据 wifi_man.state()
来处理开始连接和等待连接。
case CS_CONNECTED:
if(wifi_man.state()==wifi_manager_state::connected) {
puts("Connected.");
connection_state = CS_FETCHING;
} else if(wifi_man.state()==wifi_manager_state::error) {
connection_refresh_ts = 0; // immediately try to connect again
connection_state = CS_IDLE;
time_fetching = false;
}
break;
一旦连接成功,我们就开始获取。否则,如果出现错误,我们就通过重置状态机和刷新时间戳来重新尝试连接。
case CS_FETCHING:
puts("Retrieving time info...");
connection_refresh_ts = millis();
// grabs the timezone offset based on IP
if(!ip_loc::fetch(nullptr,
nullptr,
&time_offset,
nullptr,
0,
nullptr,
0,
time_zone_buffer,
sizeof(time_zone_buffer))) {
// retry
connection_state = CS_FETCHING;
break;
}
time_ts = millis(); // we're going to correct for latency
time_server.begin_request();
connection_state = CS_POLLING;
break;
这是我们开始获取时间信息的地方。我们首先重置刷新时间戳,然后立即访问 ip-api.com 来获取我们的时区偏移量和时区字符串。不幸的是,这是一个同步操作,但实际上并不需要很长时间,所以您应该不会注意到延迟。如果失败,我们会重试。否则,我们取当前时间戳进行延迟校正,并在继续之前启动我们的异步 NTP 请求。
case CS_POLLING:
if(time_server.request_received()) {
const int latency_offset = ((millis()-time_ts)+500)/1000;
time_rtc.set((time_t)(time_server.request_result()+
time_offset+latency_offset));
puts("Clock set.");
// set the digital clock - otherwise it only updates once a minute
update_time_buffer(time_rtc.now());
dig_clock.invalidate();
time_zone.text(time_zone_buffer);
connection_state = CS_IDLE;
puts("Turning WiFi off.");
wifi_man.disconnect(true);
time_fetching = false;
wifi_icon.invalidate();
} else if(millis()>time_ts+(wifi_fetch_timeout*1000)) {
puts("Retrieval timed out. Retrying.");
connection_state = CS_FETCHING;
}
break;
现在我们持续查找 NTP 响应,直到超时。
如果我们收到了响应,我们会设置时钟,并进行延迟和时区偏移量的调整。然后,我们使用新的日期和时间更新 time_buffer
。我们将 dig_clock
标签设置为无效,以指示文本已更改,需要重绘。然后,我们将时区标签的 text()
访问器设置为新的时区字符串,这将触发其重绘。最后,我们将状态机重置为空闲状态,并关闭 Wi-Fi 无线电。因此,我们必须告知 wifi_icon
画布重绘。
如果超时,我们只需再次获取。
这就是状态机的全部内容。
///////////////////
// update the UI
//////////////////
time_t time = time_rtc.now();
ana_clock.time(time);
// only update every minute (efficient)
if(0==(time%60)) {
update_time_buffer(time);
// tell the label the text changed
dig_clock.invalidate();
}
// update the battery level
static int bat_level = power.battery_level();
if((int)power.battery_level()!=bat_level) {
bat_level = power.battery_level();
battery_icon.invalidate();
}
static bool ac_in = power.ac_in();
if(power.ac_in()!=ac_in) {
ac_in = power.ac_in();
battery_icon.invalidate();
}
现在我们更新 UI,首先通过从时钟获取当前时间。然后我们将 ana_clock
设置为该时间。接下来,每隔 60 秒(在分钟处)一次,我们使用新值更新 time_buffer
,并强制 dig_clock
重绘,因为文本缓冲区已更改。
现在我们显示电池电量。我们首先将电量转换为整数百分比并静态存储。然后将其与当前电池电量进行比较,如果已更改,则强制电池图标失效。
我们对 ac_in()
状态也做了类似的处理。
//////////////////////////
// pump various objects
/////////////////////////
time_server.update();
panel_update();
这只是为了保持我们的 NTP 客户端和显示面板的更新。
include/ui.hpp
#pragma once
#include <gfx.hpp>
#include <uix.hpp>
// colors for the UI
using color_t = gfx::color<gfx::rgb_pixel<16>>; // native
using color32_t = gfx::color<gfx::rgba_pixel<32>>; // uix
// the screen template instantiation aliases
using screen_t = uix::screen<gfx::rgb_pixel<16>>;
using surface_t = screen_t::control_surface_type;
// the control template instantiation aliases
using svg_clock_t = uix::svg_clock<surface_t>;
using label_t = uix::label<surface_t>;
using canvas_t = uix::canvas<surface_t>;
// the screen/control declarations
extern screen_t main_screen;
extern svg_clock_t ana_clock;
extern label_t dig_clock;
extern label_t time_zone;
extern canvas_t wifi_icon;
extern canvas_t battery_icon;
这基本上是样板声明。我们创建了用于显示使用的 16 位 RGB 像素的颜色枚举,以及用于 UIX 使用的 32 位 RGBA 像素的颜色枚举。
接下来,我们实例化 screen<>
模板,将显示器的原生像素类型馈送给它。然后,我们为其 control_surface_type
创建一个别名,因为稍后实例化控件类型时我们会用到它。
然后,我们声明我们使用的每种控件类型的别名,将 surface_t
作为参数传递。
最后,我们声明这些类型的实际实例,使用 extern
使它们可以在另一个文件中实现并在整个项目中引用。
在使用 UIX 时,这种情况很常见,请牢记这一点,因为您的 UI 头文件至少会包含类似的内容。
src/panel.cpp
此文件在驱动程序级别处理我们的显示屏和触摸屏。它处理初始化并将屏幕连接到 LCD 驱动程序和触摸屏驱动程序。虽然实际的 LCD 初始化取决于显示屏,但大部分代码都可以用很少的修改用于其他项目,因此至少熟悉它只会带来好处。
#include "panel.hpp"
#include "ui.hpp"
#include <driver/gpio.h>
#include <driver/spi_master.h>
#include <esp_lcd_panel_io.h>
#include <esp_lcd_panel_ops.h>
#include <esp_lcd_panel_vendor.h>
#include <esp_lcd_panel_ili9342.h>
#include <esp_i2c.hpp>
#ifdef M5STACK_CORE2
#include <ft6336.hpp>
#endif
#ifdef M5STACK_TOUGH
#include <chsc6540.hpp>
#endif
#ifdef ARDUINO
using namespace arduino;
#else
using namespace esp_idf;
#endif
using namespace uix;
这些是处理 ESP LCD 面板 API 和我们的触摸驱动程序的样板包含和导入。
// handle to the display
static esp_lcd_panel_handle_t lcd_handle;
// the transfer buffers
uint8_t* panel_transfer_buffer1 = nullptr;
uint8_t* panel_transfer_buffer2 = nullptr;
// the currently active screen
static screen_t* panel_active_screen = nullptr;
// for the touch panel
#ifdef M5STACK_CORE2
using touch_t = ft6336<320,280>;
#endif
#ifdef M5STACK_TOUGH
using touch_t = chsc6540<320,240,39>;
#endif
static touch_t touch(esp_i2c<1,21,22>::instance);
这些是我们面板的定义,包括句柄、传输缓冲区、活动屏幕和触摸驱动程序。
// tell UIX the DMA transfer is complete
static bool panel_flush_ready(esp_lcd_panel_io_handle_t panel_io,
esp_lcd_panel_io_event_data_t* edata,
void* user_ctx) {
if(panel_active_screen!=nullptr) {
panel_active_screen->flush_complete();
}
return true;
}
由于我们正在使用 DMA,UIX 需要知道 DMA 传输何时完成。ESP LCD 面板 API 使用此回调来通知传输已完成,然后我们将其告知当前活动的屏幕。
// tell the lcd panel api to transfer data via DMA
static void panel_on_flush(const rect16& bounds, const void* bmp, void* state) {
int x1 = bounds.x1, y1 = bounds.y1, x2 = bounds.x2 + 1, y2 = bounds.y2 + 1;
esp_lcd_panel_draw_bitmap(lcd_handle, x1, y1, x2, y2, (void*)bmp);
}
UIX 调用此例程以将位图发送到显示屏。它使用 ESP LCD 面板 API 来完成此操作。该 API 的一个怪癖是 x2 和 y2 必须比实际目标多 1。这里已进行了纠正。
// for the touch panel
static void panel_on_touch(point16* out_locations,
size_t* in_out_locations_size,
void* state) {
// UIX supports multiple touch points.
// so does the FT6336 so we potentially have
// two values
*in_out_locations_size = 0;
uint16_t x,y;
if(touch.xy(&x,&y)) {
out_locations[0]=point16(x,y);
++*in_out_locations_size;
if(touch.xy2(&x,&y)) {
out_locations[1]=point16(x,y);
++*in_out_locations_size;
}
}
}
UIX 也调用此例程来获取触摸输入。触摸屏支持同时进行双指触摸,因此我们处理了这一点,尽管对于此应用程序,我们从未真正使用第二组坐标。
void panel_set_active_screen(screen_t& new_screen) {
if(panel_active_screen!=nullptr) {
// wait until any DMA transfer is complete
while(panel_active_screen->flushing()) {
vTaskDelay(5);
}
panel_active_screen->on_flush_callback(nullptr);
panel_active_screen->on_touch_callback(nullptr);
}
panel_active_screen=&new_screen;
new_screen.on_flush_callback(panel_on_flush);
new_screen.on_touch_callback(panel_on_touch);
panel_active_screen->invalidate();
}
在这里,我们将活动屏幕连接到我们的刷新回调。在这样做之前,如果已有屏幕,我们会等待它完成任何 DMA 传输,然后再进行切换。一旦我们按需挂钩和取消挂钩,我们就会使屏幕失效以强制重绘整个屏幕。
void panel_update() {
if(panel_active_screen!=nullptr) {
panel_active_screen->update();
}
// FT6336 chokes if called too quickly
static uint32_t touch_ts = 0;
if(pdTICKS_TO_MS(xTaskGetTickCount())>touch_ts+13) {
touch_ts = pdTICKS_TO_MS(xTaskGetTickCount());
touch.update();
}
}
在这里,我们只是更新屏幕,每 13 毫秒更新一次触摸面板。
// initialize the screen using the esp panel API
void panel_init() {
panel_transfer_buffer1 = (uint8_t*)heap_caps_malloc(panel_transfer_buffer_size,MALLOC_CAP_DMA);
panel_transfer_buffer2 = (uint8_t*)heap_caps_malloc(panel_transfer_buffer_size,MALLOC_CAP_DMA);
if(panel_transfer_buffer1==nullptr||panel_transfer_buffer2==nullptr) {
puts("Out of memory allocating transfer buffers");
while(1) vTaskDelay(5);
}
spi_bus_config_t buscfg;
memset(&buscfg, 0, sizeof(buscfg));
buscfg.sclk_io_num = 18;
buscfg.mosi_io_num = 23;
buscfg.miso_io_num = -1;
buscfg.quadwp_io_num = -1;
buscfg.quadhd_io_num = -1;
buscfg.max_transfer_sz = panel_transfer_buffer_size + 8;
// Initialize the SPI bus on VSPI (SPI3)
spi_bus_initialize(SPI3_HOST, &buscfg, SPI_DMA_CH_AUTO);
esp_lcd_panel_io_handle_t io_handle = NULL;
esp_lcd_panel_io_spi_config_t io_config;
memset(&io_config, 0, sizeof(io_config));
io_config.dc_gpio_num = 15,
io_config.cs_gpio_num = 5,
io_config.pclk_hz = 40*1000*1000,
io_config.lcd_cmd_bits = 8,
io_config.lcd_param_bits = 8,
io_config.spi_mode = 0,
io_config.trans_queue_depth = 10,
io_config.on_color_trans_done = panel_flush_ready;
// Attach the LCD to the SPI bus
esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)SPI3_HOST, &io_config, &io_handle);
lcd_handle = NULL;
esp_lcd_panel_dev_config_t panel_config;
memset(&panel_config, 0, sizeof(panel_config));
panel_config.reset_gpio_num = -1;
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0)
panel_config.rgb_endian = LCD_RGB_ENDIAN_BGR;
#else
panel_config.color_space = ESP_LCD_COLOR_SPACE_BGR;
#endif
panel_config.bits_per_pixel = 16;
// Initialize the LCD configuration
esp_lcd_new_panel_ili9342(io_handle, &panel_config, &lcd_handle);
// Reset the display
esp_lcd_panel_reset(lcd_handle);
// Initialize LCD panel
esp_lcd_panel_init(lcd_handle);
// esp_lcd_panel_io_tx_param(io_handle, LCD_CMD_SLPOUT, NULL, 0);
// Swap x and y axis (Different LCD screens may need different options)
esp_lcd_panel_swap_xy(lcd_handle, false);
esp_lcd_panel_set_gap(lcd_handle, 0, 0);
esp_lcd_panel_mirror(lcd_handle, false, false);
esp_lcd_panel_invert_color(lcd_handle, true);
// Turn on the screen
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0)
esp_lcd_panel_disp_on_off(lcd_handle, true);
#else
esp_lcd_panel_disp_off(lcd_handle, true);
#endif
touch.initialize();
touch.rotation(0);
}
这里有很多代码,但幸运的是,很多都是样板代码。不幸的是,涵盖 ESP LCD 面板 API 的细节超出了本文的范围。有关信息,请参阅 文档。
历史
- 2024 年 6 月 12 日 - 初次提交