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

一个支持 Web 的智能时钟和天气站

starIconstarIconstarIconstarIconstarIcon

5.00/5 (2投票s)

2022年6月16日

MIT

12分钟阅读

viewsIcon

7700

downloadIcon

125

创建一个利用多个互联网服务来获取天气、日期和时间的时钟

Weather Clock

引言

物联网设备本质上是可连接的。让我们使用 ESP32 进行一些认真的互联网连接,为您提供您的位置、天气以及本地时间和日期。该项目使用了多种技术,包括 WPS、REST 服务和 NTP,以提供完全自动化的体验。

编写这堆乱七八糟的代码

注意:在运行此项目之前,请务必上传 SPIFFs 镜像。

此项目包含几个部分,我们将逐个文件进行处理,最后再介绍 main.cpp,因为它将所有内容整合在一起。

open_weather_map_api_key.h

让我们从一个您还没有的文件开始。您需要在项目中创建 open_weather_api_key.h 文件,并添加您从 Open Weather Map 生成的 API 密钥。

#ifndef OPEN_WEATHER_API_KEY
#define OPEN_WEATHER_API_KEY "my_api_key"
#endif 

ip_loc.cpp

现在让我们看看地理定位服务。

#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;
}
}

所有基本工作都在 fetch() 方法中完成。我们正在向 ip-api.com 的服务发送 HTTP 请求,并以 CSV 格式接收响应。此时,我们只需提取每个字段并将其复制到我们的返回值中。

mpu6886.hpp/mpu6886.cpp

此文件驱动 M5 Stack 中的 MPU6886 加速度计/陀螺仪/温度传感器。实际上,这是我制作的一个 Platform IO 库,但我将操作文件复制到了本地,因为我正在调整温度输出。数据手册强烈暗示您需要为其加上 25 摄氏度,但根据我的经验,这会使其比已经不准确的情况更不准确。我不会在这里介绍这个文件,因为它有点超出了我们正在探索的范围,而且它相对较长。

我应该指出,在我进行的测试中,温度传感器不准确。但是,为了完整起见,我包含了它。您始终可以修改软件和电路以包含您自己的温度传感器。

ntp_time.cpp

此文件发送 NTP 请求并收集时间数据。默认情况下,它使用 time.nist.gov 作为其时间服务器,但您可以使用其他服务器。NTP 协议基于 UDP,因此可能会丢失数据包,并且使用此库时您必须轮询才能获得响应。以下是相关代码:

#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);
            }
        }
    }
}
}

情况是这样的:首先,您可以选择使用 callback 设置一个回调方法。您调用 begin_request() 来发起当前时间的 NTP 请求。此时,您必须通过调用 update() 来轮询响应。如果您不使用回调,您将检查 request_received() 以查看是否收到了结果。如果收到了,您可以使用 request_result() 将结果作为 UTC 时间的 time_t 类型获取。如果您使用回调,它将在调用 update() 收到响应后立即触发。

这部分大部分只是数据处理,以使 NTP 生效。但是,我们还管理我们当前是否正在请求,并在 update() 方法中检查数据包,解码它,设置相关变量,然后在设置了回调的情况下触发回调。

open_weather.cpp

此文件处理 Open Weather Map API 服务,该服务返回 JSON。我们使用 ArduinoJSON 来处理它,因为信息量相对较少。这非常直接。我们只需将所有相关的 JSON 字段复制到一个巨大的结构体中。内部所有内容均为公制。唯一奇怪的是 JSON 本身,它有点笨拙。

#include <open_weather.hpp>
#include <ArduinoJson.hpp>
#include <HTTPClient.h>
#include "open_weather_api_key.h"
using namespace ArduinoJson;
namespace arduino {
bool open_weather::fetch(float latitude, 
                        float longitude,
                        open_weather_info* out_info) {
    constexpr static const char *url_format = 
        "http://api.openweathermap.org/data/2.5/weather?lat=%f&lon=
         %f&units=metric&lang=en&appid=%s";
    if(out_info==nullptr) { 
        return false;
    }
    char url[512];
    sprintf(url,url_format,latitude,longitude,OPEN_WEATHER_API_KEY);
    HTTPClient client;
    client.begin(url);
    if(0>=client.GET()) {
        return false;
    }
    DynamicJsonDocument doc(8192);
    deserializeJson(doc,client.getString());
    client.end();
    JsonObject obj = doc.as<JsonObject>();
    String str = obj[F("name")];
    strncpy(out_info->city,str.c_str(),64);
    out_info->visiblity = obj[F("visibility")];
    out_info->utc_offset = (long)obj[F("timezone")];
    out_info->timestamp = (time_t)(long)obj[F("dt")];
    JsonObject so = obj[F("weather")].as<JsonArray>().getElement(0).as<JsonObject>();
    str=so[F("main")].as<String>();
    strncpy(out_info->main,str.c_str(),32);
    str=so[F("description")].as<String>();
    strncpy(out_info->description,str.c_str(),128);
    str=so[F("icon")].as<String>();
    strncpy(out_info->icon,str.c_str(),8);
    so = obj[F("main")].as<JsonObject>();
    out_info->temperature = so[F("temp")];
    out_info->feels_like  = so[F("feels_like")];
    out_info->pressure = so[F("pressure")];
    out_info->humidity = so[F("humidity")];
    so = obj[F("wind")].as<JsonObject>();
    out_info->wind_speed = so[F("speed")];
    out_info->wind_direction = so[F("deg")];
    out_info->wind_gust = so[F("gust")];
    so = obj[F("clouds")].as<JsonObject>();
    out_info->cloudiness = so[F("all")];
    so = obj[F("rain")];
    out_info->rain_last_hour = so[F("1h")];
    so = obj[F("snow")];
    out_info->snow_last_hour = so[F("1h")];
    so = obj[F("sys")];
    out_info->sunrise = (time_t)(long)so[F("sunrise")];
    out_info->sunset = (time_t)(long)so[F("sunset")];
    return true;
}
}

正如您所见,我们所做的就是构建 URL,然后解析结果并将其打包到一个结构体中。

wifi_wps.cpp

此文件比我们到目前为止处理过的任何文件都要复杂得多。关键在于 update() 中的一个状态机,它处理连接过程的各个阶段。另一个主要方面是处理我们从 WiFi 子系统收到的事件。此代码会自动扫描 WPS 信号(如果无法连接)。如果找到一个,它会使用该信号,然后存储凭据以备将来使用——该存储由 WiFi 子系统自动处理。在任何时候,如果断开连接,它都会尝试重新连接。如果 WiFi 凭据发生更改或因其他原因失效,同样会启动 WPS 扫描。由于我们使用事件,因此我们可以处理所有内容,而不会冻结应用程序等待连接完成。整个过程在后台运行。您可以选择在 WiFi 连接或断开连接时接收回调。请注意,我们为状态使用了一个原子整数。这是因为 WiFi 事件可能发生在单独的线程上,因此我们必须小心修改该字段以避免竞态条件。原子类型为我们处理了这个问题。我们在源文件中使用了全局变量来简化一切,因为回调不接受状态。

#include <wifi_wps.hpp>
#include <Wifi.h>
namespace arduino {
std::atomic_int wifi_wps_state;
uint32_t wifi_wps_connect_ts;
static esp_wps_config_t wifi_wps_config;
wifi_wps_callback fn_wifi_wps_callback;
void* wifi_wps_callback_state;
uint32_t wifi_wps_connect_timeout;
void wifi_wps_wifi_event(arduino_event_t* event) {
  switch (event->event_id) {
    case ARDUINO_EVENT_WIFI_STA_GOT_IP:
      wifi_wps_connect_ts = millis();
      wifi_wps_state = 4; // connected
      break;
    case ARDUINO_EVENT_WPS_ER_SUCCESS:
      esp_wifi_wps_disable();
      delay(10);
      wifi_wps_connect_ts = millis();
      wifi_wps_state = 1; // connecting
      WiFi.begin();
      break;
    case ARDUINO_EVENT_WPS_ER_FAILED:
      esp_wifi_wps_disable();
      wifi_wps_connect_ts = millis();
      wifi_wps_state = 0; // connect
      break;
    case ARDUINO_EVENT_WPS_ER_TIMEOUT:
      esp_wifi_wps_disable();
      wifi_wps_connect_ts = millis();
      wifi_wps_state = 0; // connect
      break;
    case ARDUINO_EVENT_WPS_ER_PIN:
      // not used yet
      break;
    default:
      break;
  }
}
wifi_wps::wifi_wps(uint32_t connect_timeout) {
    wifi_wps_state = -1;
    fn_wifi_wps_callback = nullptr;
    wifi_wps_callback_state = nullptr;
    wifi_wps_connect_timeout = connect_timeout;
}
void wifi_wps::callback(wifi_wps_callback callback, void* state) {
    fn_wifi_wps_callback = callback;
    wifi_wps_callback_state = state;
    
}
void wifi_wps::update() {
    if (wifi_wps_state==-1) {
        wifi_wps_config.wps_type = WPS_TYPE_PBC;
        strcpy(wifi_wps_config.factory_info.manufacturer, "ESPRESSIF");
        strcpy(wifi_wps_config.factory_info.model_number, "ESP32");
        strcpy(wifi_wps_config.factory_info.model_name, "ESPRESSIF IOT");
        strcpy(wifi_wps_config.factory_info.device_name, "ESP32");
        WiFi.onEvent( wifi_wps_wifi_event);
        wifi_wps_connect_ts = millis();
        wifi_wps_state=0;
    }
    if(WiFi.status()!= WL_CONNECTED) {
        switch(wifi_wps_state) {
            case 0: // connect start
            wifi_wps_connect_ts = millis();
            WiFi.begin();
            wifi_wps_state = 1;
            wifi_wps_connect_ts = millis();
            break;
        case 1: // connect continue
            if(WiFi.status()==WL_CONNECTED) {
                wifi_wps_state = 4;
                if(fn_wifi_wps_callback != nullptr) {
                    fn_wifi_wps_callback(true,wifi_wps_callback_state);
                }
                Serial.println("WiFi connected to ");
                Serial.println(WiFi.SSID());
                
            } else if(millis()-wifi_wps_connect_ts>=wifi_wps_connect_timeout) {
                WiFi.disconnect();
                wifi_wps_connect_ts = millis();
                // begin wps_search
                wifi_wps_state = 2;
            }
            break;
        case 2: // WPS search
            wifi_wps_connect_ts = millis();
            esp_wifi_wps_enable(&wifi_wps_config);
            esp_wifi_wps_start(0);
            wifi_wps_state = 3; // continue WPS search
            break;
        case 3: // continue WPS search
            // handled by callback
            break;
        case 4:
            wifi_wps_state = 1; // connecting
            if(fn_wifi_wps_callback != nullptr) {
                fn_wifi_wps_callback(false,wifi_wps_callback_state);
            }
            wifi_wps_connect_ts = millis();
            WiFi.reconnect();
        }
    }
}
}  // namespace arduino

draw_screen.hpp

此源文件包含绘制时钟和天气的代码。这是文件顶部,我们只包含一些头文件。

#pragma once
#include <Arduino.h>
#include <SPIFFS.h>
#include <open_weather.hpp>
#include <gfx_cpp14.hpp>
#include <telegrama.hpp>

现在,实际代码的第一部分绘制天气图标。

template <typename Destination>
void draw_weather_icon(Destination& dst, arduino::open_weather_info& info,
                       gfx::size16 weather_icon_size) {
    const char* path;
    if(!strcmp(info.icon,"01d")) {
        path = "/sun.jpg";
    } else if(!strcmp(info.icon,"01n")) {
        path = "/moon.jpg";
    } else if(!strcmp(info.icon,"02d")) {
        path = "/partly.jpg";
    } else if(!strcmp(info.icon,"02n")) {
        path = "/partlyn.jpg";
    } else if(!strcmp(info.icon,"10d") || !strcmp(info.icon,"10n")) {
        path = "/rain.jpg";
    } else if(!strcmp(info.icon,"04d") || !strcmp(info.icon,"04n")) {
        path = "/cloud.jpg";
    } else if(!strcmp(info.icon,"03d") || !strcmp(info.icon,"03n")) {
        if(info.snow_last_hour>0) {
            path = "/snow.jpg";
        } else if(info.rain_last_hour>0) {
            path = "/rain.jpg";
        } else {
            path = "/cloud.jpg";
        }
    } else {
        path = nullptr;
    }
    
    if(path!=nullptr) {
        File file = SPIFFS.open(path,"rb");
        gfx::draw::image( dst,dst.bounds(),&file);
        file.close();
    } else {
        Serial.print("Icon not recognized: ");
        Serial.println(info.icon);
        Serial.println(info.main);
        gfx::draw::filled_rectangle(dst,
                                    weather_icon_size.bounds(),
                                    gfx::color<typename Destination::pixel_type>::white);
    }
}

我们所做的是从天气信息中获取图标字符串,然后尝试找到最匹配它的图标。图标是 48x48 的 JPG 文件,通过 SPIFFS 存储在闪存中。然后将其绘制到 Destination dst。您会注意到这是一个模板函数。这些绘图函数之所以成为模板函数,是因为它们可以绘制到任何类型的 GFX 绘图目标。GFX 不使用虚拟类接口进行绑定。它通过模板使用源级别绑定。因此,绘图目标类型被接受为模板参数。

此例程尚未涵盖所有可能的图标结果。如果遇到无法识别的图标,它将将其转储到串行端口并且不显示图标。

接下来,我们绘制温度文本。

template <typename Destination>
void draw_temps(Destination& dst, arduino::open_weather_info& info, float inside) {
    char sz[32];
    float tmpF = info.temperature*1.8+32;
    sprintf(sz,"%.1fF",tmpF);
    float fscale = Telegrama_otf.scale(24);
    gfx::draw::text(dst,
                    dst.bounds().offset(0,12*(inside!=inside)),
                    gfx::spoint16::zero(),
                    sz,
                    Telegrama_otf,
                    fscale,
                    gfx::color<typename Destination::pixel_type>::black);
    if(inside==inside) {
        tmpF = inside*1.8+32;
        sprintf(sz,"%.1fF",tmpF);
        gfx::draw::text(dst,
                        dst.bounds().offset
                        (0,dst.dimensions().height/2).crop(dst.bounds()),
                        gfx::spoint16::zero(),
                        sz,
                        Telegrama_otf,
                        fscale,
                        gfx::color<typename Destination::pixel_type>::blue);
    }
}

这非常直接。我们从天气信息中绘制室外温度,然后绘制室内温度(如果可用)。如果不可用,我们会垂直居中文本温度。请注意,温度值已从摄氏度转换为华氏度。

现在来看绘制时钟。这稍微复杂一些。

template <typename Destination>
void draw_clock(Destination& dst, time_t time, const gfx::ssize16& size) {
    using view_t = gfx::viewport<Destination>;
    gfx::srect16 b = size.bounds().normalize();
    uint16_t w = min(b.width(), b.height());
    
    float txt_scale = Telegrama_otf.scale(w/10);
    char* sz = asctime(localtime(&time));
    *(sz+3)=0;
    gfx::draw::text(dst,
            dst.bounds(),
            gfx::spoint16::zero(), 
            sz,
            Telegrama_otf,
            txt_scale,
            gfx::color<typename Destination::pixel_type>::black);
    sz+=4;
    *(sz+6)='\0';

    gfx::ssize16 tsz = Telegrama_otf.measure_text(gfx::ssize16::max(),
                                                gfx::spoint16::zero(),
                                                sz,
                                                txt_scale);
    gfx::draw::text(dst,
            dst.bounds().offset(dst.dimensions().width-tsz.width-1,0).crop(dst.bounds()),
            gfx::spoint16::zero(), 
            sz,
            Telegrama_otf,
            txt_scale,
            gfx::color<typename Destination::pixel_type>::black);

    gfx::srect16 sr(0, 0, w / 16, w / 5);
    sr.center_horizontal_inplace(b);
    view_t view(dst);
    view.center(gfx::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);
        gfx::spoint16 marker_points[] = {
            view.translate(gfx::spoint16(sr.x1, sr.y1)),
            view.translate(gfx::spoint16(sr.x2, sr.y1)),
            view.translate(gfx::spoint16(sr.x2, sr.y2)),
            view.translate(gfx::spoint16(sr.x1, sr.y2))};
        gfx::spath16 marker_path(4, marker_points);
        gfx::draw::filled_polygon(dst, marker_path, 
          gfx::color<typename Destination::pixel_type>::gray);
    }
    sr = gfx::srect16(0, 0, w / 16, w / 2);
    sr.center_horizontal_inplace(b);
    view.rotation(((time%60) / 60.0) * 360.0);
    gfx::spoint16 second_points[] = {
        view.translate(gfx::spoint16(sr.x1, sr.y1)),
        view.translate(gfx::spoint16(sr.x2, sr.y1)),
        view.translate(gfx::spoint16(sr.x2, sr.y2)),
        view.translate(gfx::spoint16(sr.x1, sr.y2))};
    gfx::spath16 second_path(4, second_points);

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

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

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

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

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

这里有很多代码,但思路相对简单。对于表盘上的每个标记或指针,我们首先在 12:00 位置绘制一个矩形(作为多边形)来开始绘制。然后使用 GFX 的 viewport<> 功能对其进行旋转。旋转完成后,我们将其绘制出来。

我们首先绘制日期。我们通过巧妙地修改 asctime() 的结果,在日期和日期的末尾插入空字符终止符来实现这一点。然后,我们使用这些部分字符串在表盘顶部打印日期。

接下来,我们在循环中绘制表盘周围的标记。

现在来看计算指针。对于每个指针,我们找到经过的时间单位——小时、分钟或秒,然后将该数字调整为 0 到 1 之间的比例,然后乘以 360 来找到角度。然后,我们将该指针的矩形/多边形旋转该量。我们对每个指针都这样做。

最后,我们绘制每个指针的多边形。

main.cpp

最后,我们可以将所有内容整合在一起。所有其他重要的代码都在这个文件中。让我们从顶部开始。

#define M5STACK
#include <Arduino.h>
#include <SPIFFS.h>
#include <WiFi.h>
#include <WiFiMulti.h>
#include <gfx_cpp14.hpp>
#include <ili9341.hpp>
#include <tft_io.hpp>
#include <telegrama.hpp>
#include <wifi_wps.hpp>
#include <ip_loc.hpp>
#include <ntp_time.hpp>
#include <open_weather.hpp>
#include <draw_screen.hpp>
#ifdef M5STACK
#include <mpu6886.hpp>
#endif
using namespace arduino;
using namespace gfx; 

首先,有一个 M5STACK 的 #define。如果您不使用 M5 Stack,请将其删除。

现在我们有了所有需要的 include,然后我们导入相关的命名空间。

接下来是我们编译的配置值。这些决定了引脚分配、刷新时间等。我们本来可以使用 #define,但我更喜欢尽可能使用 C++ 构造。

// NTP server
constexpr static const char* ntp_server = "time.nist.gov";

// synchronize with NTP every 60 seconds
constexpr static const int clock_sync_seconds = 60;

// synchronize weather every 5 minutes
constexpr static const int weather_sync_seconds = 60 * 5;

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

constexpr static const size16 weather_icon_size = {48, 48};

constexpr static const size16 weather_temp_size = {120, 48};

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;

现在我们声明我们的屏幕驱动程序。大多数 GFX 驱动程序都使用与驱动程序本身解耦的总线类。这样,您就可以通过更改总线类型,例如,在 SPI 或并行总线上使用 ILI9341。驱动程序保持不变。以下设置了一个支持 DMA 的 SPI 链接和显示驱动程序。对于不同的显示器,您将不得不更改显示驱动程序。

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>;

现在来完成我们的 GFX 声明。

using color_t = color<typename lcd_t::pixel_type>;

lcd_t lcd;

我们所做的就是为访问命名 16 位 RGB 颜色提供一个简写,然后声明一个 lcd_t 实例以供绘制。我们为访问命名 16 位 RGB 颜色提供了一个简写,然后声明一个 lcd_t 实例以供绘制。

接下来的几行仅适用于 M5 Stack。它们设置了加速度计/陀螺仪/温度传感器,我们使用它来获取室内温度。

#ifdef M5STACK
mpu6886 mpu(i2c_container<0>::instance());
#endif

您是否看到了 i2c_container<0>::instance() 表达式?总线框架包含一种跨平台的方式来检索给定主机(在本例中为第一个主机 - 索引为零)的 I2C 和 SPI 类实例。由于每个 Arduino 框架实现都有自己的检索方式,这有助于使代码在不同平台上保持一致。它做的另一件事是确保多个设备可以正确共享总线。否则,传递这个就相当于传递 Wire

接下来我们声明几个我们的服务——在本例中是 WPS 管理器和 NTP 服务管理器。

wifi_wps wps;
ntp_time ntp;

现在我们可以继续处理我们用于存储主应用程序函数数据的全局变量了。

uint32_t update_ts;
uint32_t clock_sync_count;
uint32_t weather_sync_count;
time_t current_time;
srect16 clock_rect;
srect16 weather_icon_rect;
srect16 weather_temp_rect;
IPAddress ntp_ip;
float latitude;
float longitude;
long utc_offset;
char region[128];
char city[128];
open_weather_info weather_info;

这些内容包含我们的更新时间戳,用于跟踪经过的秒数,距离下一次 NTP 时钟同步剩余的秒数,然后是天气服务同步。

接下来,以自 UNIX 纪元以来的秒数形式表示的本地当前时间。

然后我们计算了一些矩形,以指示我们要绘制的各种元素的 PosiPosaitions。

接下来,是 NTP 服务器的 IP 地址,我们对其进行缓存,以避免重复查找。

现在是我们的纬度和经度。

接下来是本地时间的 UTC 偏移量(以秒为单位)。

之后,我们有几个字段来存储来自我们地理定位服务的地区和城市。

最后,我们有一个 struct 来存储天气信息。

现在我们声明一些我们将用于绘图的位图。我们不是直接绘制到屏幕,而是绘制到位图中,然后将这些位图块传输到屏幕。这被称为“双缓冲”,可以防止绘图过程中的闪烁。

using bmp_t = bitmap<typename lcd_t::pixel_type>;
uint8_t bmp_buf[bmp_t::sizeof_buffer(clock_size)];
bmp_t clock_bmp(clock_size, bmp_buf);
bmp_t weather_icon_bmp(weather_icon_size, bmp_buf);
bmp_t weather_temp_bmp(weather_temp_size, bmp_buf);

如果您仔细看,您会注意到我们所有的位图都共享同一个内存缓冲区。原因在于,对于每个项目,我们将其绘制到一个位图中,绘制该位图,然后继续进行下一个。因此,任何时候我们只使用一个位图,所以我们可以创建一个足够大的缓冲区来容纳其中最大的一个,然后在绘制时回收该缓冲区。此代码假定时钟是最大的位图。

现在我们有了我们的 setup 函数。

void setup() {
    Serial.begin(115200);
    SPIFFS.begin(false);
    lcd.fill(lcd.bounds(),color_t::white);
    Serial.println("Connecting...");
    while(WiFi.status()!=WL_CONNECTED) {
        wps.update();
    }
    clock_rect = srect16(spoint16::zero(), (ssize16)clock_size);
    clock_rect.offset_inplace(lcd.dimensions().width-clock_size.width ,
                            lcd.dimensions().height-clock_size.height);
    weather_icon_rect=(srect16)weather_icon_size.bounds();
    weather_icon_rect.offset_inplace(20,20);
    weather_temp_rect = (srect16)weather_temp_size.bounds().offset(68,20);
    clock_sync_count = clock_sync_seconds;
    WiFi.hostByName(ntp_server,ntp_ip);
    ntp.begin_request(ntp_ip);
    while(ntp.requesting()) {
        ntp.update();
    }
    if(!ntp.request_received()) {
        Serial.println("Unable to retrieve time");
        while (true);
    }
    ip_loc::fetch(&latitude,&longitude,&utc_offset,region,128,city,128);
    weather_sync_count =1; // sync on next iteration
    Serial.println(weather_info.city);
    current_time = utc_offset+ntp.request_result();
    update_ts = millis();
}

我们首先设置我们的串行输出并挂载 SPIFFS。然后我们将屏幕填充为白色,并开始尝试连接,通过驱动 wps 服务。这将导致设备扫描 WPS 信号,除非之前存储了有效的凭据,在这种情况下,它将直接连接。

在等待连接后,我们计算屏幕各个元素的矩形,设置我们的时钟同步,解析 NTP IP,然后进行初始 NTP 请求。

需要注意的是,这里的 NTP 请求不是很健壮。UDP 有可能丢失数据包,导致永远收不到响应。没有处理这种情况的逻辑,在这种情况下会导致无限循环。这可以修复,但会增加 setup() 的复杂性。

之后,我们使用地理定位服务来获取有关我们位置的信息,然后设置天气同步时钟,以便在第一次调用 loop() 时获取天气。

接下来,我们转储城市信息(仅用于调试),然后根据 UTC 时间和偏移量设置调整后的时间。

最后,我们设置我们的秒计数器时间戳。

接下来是 loop() 函数。

void loop() {
    wps.update();
    ntp.update();
    if(ntp.request_received()) {
        Serial.println("NTP signal received");
        current_time = utc_offset+ntp.request_result();
    }
    uint32_t ms = millis();
    if (ms - update_ts >= 1000) {
        update_ts = ms;
        ++current_time;
        draw::wait_all_async(lcd);
        draw::filled_rectangle(clock_bmp, 
                              clock_size.bounds(), 
                              color_t::white);
        draw_clock(clock_bmp, current_time, (ssize16)clock_size);
        draw::bitmap_async(lcd, 
                          clock_rect, 
                          clock_bmp, 
                          clock_bmp.bounds());
        if (0 == --clock_sync_count) {
            clock_sync_count = clock_sync_seconds;
            ntp.begin_request(ntp_ip);
        }
        if(0==--weather_sync_count) {
            weather_sync_count = weather_sync_seconds;
            open_weather::fetch(latitude,longitude,&weather_info);
            Serial.println("Fetched weather");
            Serial.println(weather_info.main);
            Serial.println(weather_info.icon);
            draw::wait_all_async(lcd);
            draw_weather_icon(weather_icon_bmp,weather_info,weather_icon_size);
            draw::bitmap(lcd, 
                          weather_icon_rect, 
                          weather_icon_bmp, 
                          weather_icon_bmp.bounds());
            weather_temp_bmp.fill(weather_temp_bmp.bounds(),color_t::white);
            #ifdef M5STACK
            const float indoor_temp = mpu.temp();
            #else
            const float indoor_temp = NAN;
            #endif
            draw_temps(weather_temp_bmp,weather_info,indoor_temp);
            draw::bitmap(lcd, 
                          weather_temp_rect, 
                          weather_temp_bmp, 
                          weather_temp_bmp.bounds());
        }
    }
}

我们首先驱动 WPS 和 NTP 服务。如果我们收到了 NTP 响应,我们就更新当前时间。

接下来是每秒执行一次的标准模式,在本例中是使用 update_ts 作为时间戳。

现在我们将当前时间增加一秒。由于我们实际上没有板载真实时钟,我们使用这个来在更新之间保持时间。在实践中效果很好,只要 if 块内的执行时间不超过 1 秒。

下一行等待所有挂起的异步操作完成。这是因为我们可能仍在后台通过 SPI DMA 传输时钟位图——实际上我们没有,但理论上我们可能会。我们即将修改用于此目的的内存,因此我们必须等待其完成。

现在我们填充时钟位图的背景,然后根据当前时间将其绘制到其中。

完成此操作后,我们将使用 ESP32 的 SPI DMA 功能异步发送时钟位图。GFX 通过 `_async` 调用隐藏了所有复杂性,使其变得简单。我们只需小心确保在位图内存仍在传输时不要再次写入它。

总之,我们现在计算 NTP 时钟同步之间的剩余秒数,如果它达到零,我们就发送 NTP 请求。

之后,我们对天气同步计数执行相同的操作,一旦达到零,我们就收集和更新天气信息。请注意,我们再次等待挂起的 lcd 操作完成,因为我们正在使用与可能正在执行的 DMA 传输共享内存的位图。

下一步

作为一个示例,此项目在某种程度上是极简的,可以轻松地使其更加健壮,并在屏幕上显示更丰富的信息。我将其留给您,尊贵的读者,作为一个练习。祝您搭建愉快!

历史

  • 2022年6月6日 - 初次提交
© . All rights reserved.