esp8266时钟+天气+提醒(七)代码篇四

esp8266时钟+天气+提醒(七)代码篇四

按理说接下来应该讲讲外壳的加工制作了,但可能是因为水平过于有限,一直没能完成。所以,,再来一个代码篇。

本篇为前面几篇作补充,主要介绍了

  • NTP原理及UDP的使用
  • ESP8266时钟配置
  • 温湿度传感器DHT11的使用

十、NTP与UDP

在第四章第五节(参加本专栏第二篇)中已经粗略介绍了NTP,本章将细致介绍NTP,以及其背后的UDP。前面用大篇幅时间和待办的获取,而这两个都基于HTTP(依托TCP实现的)。

一般来说,TCP应用更广泛一些,但UDP也很有介绍的必要。

TCP和UDP都是运输层的两种协议,它们的差异决定了上层应用获取信息方式上的差异。

1. TCP与UDP

TCP 是一种面向连接的协议,这意味着在数据开始传输之前,两台计算机之间需要建立一个连接。可以将其类比为打电话:你需要先拨通对方的号码并确保对方接听,然后才能开始通话。

UDP 是一种无连接协议,它不需要在通信前建立连接,更像是寄信。你把信件放进邮筒,信件能否到达并不保证,也不会有人告诉你信件是否送达。

它们的特性不同导致它们的应用场景不同,TCP 常用于要求高可靠性的应用,如网页浏览、文件传输、电子邮件等,而 UDP 通常用于流媒体传输(如视频会议、在线游戏)和一些需要快速传输的场合。

运输层为上层提供支持,熟悉计算机网络的朋友们知道,运输层的上层是应用层。应用层负责依靠下层提供的信息实现某种特定的功能。

NTP(网络时间协议,Network Time Protocol)就是一种基于UDP的应用层协议。

2. NTP

NTP是一个用来确保计算机和其他设备上的时间准确同步的系统,我们电脑和手机能够联网对时,就是依赖这个。至于当电脑断网时,其时钟依然准确(也有可能不准,再连一下网就好了),这是因为电脑内部自带一个实时时钟/定时器,它可以保证在断网的情况下,时钟继续向前。

ESP8266也是如此,联网对时,断网自动计时就是这个原因。

NTP获取时间的流程是这样的:

  1. 客户端(ESP8266)发送UDP数据报请求时间
  2. NTP服务器收到报文
  3. NTP服务器返回一个Unix时间戳

3. 时钟设置

很多主流单片机都具备内置时钟源,但ESP8266没有,它只有一个定时器,所以ESP8266的时钟解决方案就是使用NTP

3.1 网络时间同步

在setup函数中,有

  Udp.begin(localPort);
  setSyncProvider(getNtpTime);
  setSyncInterval(300);  //每300秒同步一次时间

setSyncProvider函数设置时间同步的提供者,其参数是一个函数。这个函数(我们起名为getNtpTime)需要返回一个time_t类型的时间戳,这样才能正确地完成时间同步。此外,网络错误也应在这个函数中处理。

setSyncInterval函数用于设定同步间隔,其参数是。我们这里设定为300秒同步一次时间,不过其准确度存疑。注意间隔设置要合理,太小会导致不必要的资源损耗。

此外,在上面还有一行  Udp.begin(localPort);

这里解释一下:在程序开始处,我们定义了

WiFiUDP Udp;
unsigned int localPort = 8888;  // 用于侦听UDP数据包的本地端口

这个WiFiUDP用于监听我们的UDP数据报。所以我们使用Udp的begin方法进行初始化,并且传入一个参数指定监听用的端口。

3.2 时间获取
3.2.1 NTP服务器

接下来是时间获取函数,就是上面提到的getNtpTime。

time_t getNtpTime() {
  IPAddress ntpServerIP;  // NTP服务器的地址

  while (Udp.parsePacket() > 0)
    ;  // 丢弃以前接收的任何数据包
  Serial.println("Transmit NTP Request");
  // 从池中获取随机服务器
  WiFi.hostByName(ntpServerName, ntpServerIP);
  Serial.print(ntpServerName);
  Serial.print(": ");
  Serial.println(ntpServerIP);
  sendNTPpacket(ntpServerIP);
  uint32_t beginWait = millis();
  while (millis() - beginWait < 1500) {
    int size = Udp.parsePacket();
    if (size >= NTP_PACKET_SIZE) {
      Serial.println("Receive NTP Response");
      isNTPConnected = true;
      Udp.read(packetBuffer, NTP_PACKET_SIZE);  // 将数据包读取到缓冲区
      unsigned long secsSince1900;
      // 将从位置40开始的四个字节转换为长整型,只取前32位整数部分
      secsSince1900 = (unsigned long)packetBuffer[40] << 24;
      secsSince1900 |= (unsigned long)packetBuffer[41] << 16;
      secsSince1900 |= (unsigned long)packetBuffer[42] << 8;
      secsSince1900 |= (unsigned long)packetBuffer[43];
      Serial.println(secsSince1900);
      Serial.println(secsSince1900 - 2208988800UL + timeZone * SECS_PER_HOUR);
      return secsSince1900 - 2208988800UL + timeZone * SECS_PER_HOUR;
    }
  }
  Serial.println("No NTP Response :-(");  //无NTP响应
  isNTPConnected = false;
  return 0;  //如果未得到时间则返回0
}

在看到第一行的IPAddress ntpServerIP时,很多人都会疑惑,这是什么东西,NTP服务器的地址应该是常量吧,为什么在这里声明一个变量(一个对象)呢。

实际上,在开始的时候,我们已经确定了使用阿里云NTP:

//网络校时的相关配置
static const char ntpServerName[] = "ntp1.aliyun.com";  //NTP服务器,使用阿里云

上面这行代码在我们程序的开始处。

实际上,在这里应用了DNS,如下:

  // 从池中获取随机服务器
  WiFi.hostByName(ntpServerName, ntpServerIP);
  Serial.print(ntpServerName);
  Serial.print(": ");
  Serial.println(ntpServerIP);
  sendNTPpacket(ntpServerIP);

我们使用了WiFi.nostByName函数。这个函数的原型是

int WiFi.hostByName(const char* hostname, IPAddress& ip);

它接收两个参数,分别是主机(域名)和一个用于存放解析结果的地址。它也是有返回值的,返回1表示解析成功,返回0表示失败。此外使用这个函数的前提是网络已连接。

所以IPAddress ntpServerIP的意义就不言自明了,它用来存放解析出来的IP地址。

3.2.2 NTP池

上面这样的操作可能会令人困惑,为什么不直接把IP地址记录下来,而要使用域名再去解析。域名的意义在于方便人们记忆,但在这里岂不是很多此一举。

这就要说到NTP池了。

大家都要向NTP服务器请求时间,有这么多的设备,服务器就会承受巨大的压力。

解决方案是这样的:一个NTP池去为所有设备提供服务,池子里有多台不同的NTP服务器。当设备向DNS请求NTP服务器的IP地址时,DNS会根据某种策略(如地理位置、负载均衡等)返回池中的一台服务器的地址。

所以,ntp1.aliyun.com说是一台NTP服务器并不准确,它是一个NTP池

可见,请求NTP时,NTP服务器的IP地址并不固定,因此它不能是一个常量,必须每次都通过域名解析获得。至于域名自然可以是一个常量。

所以上面的代码片段有一个注释:“从池中获取随机服务器”。

当然了,有些NTP服务器可能是固定IP的(很确定是哪台服务器)。

3.2.3 NTP请求

发送NTP请求的函数如下:

// 向给定地址的时间服务器发送NTP请求
void sendNTPpacket(IPAddress& address) {
  memset(packetBuffer, 0, NTP_PACKET_SIZE);
  packetBuffer[0] = 0b11100011;  // LI, Version, Mode
  packetBuffer[1] = 0;           // Stratum, or type of clock
  packetBuffer[2] = 6;           // Polling Interval
  packetBuffer[3] = 0xEC;        // Peer Clock Precision
  // 8 bytes of zero for Root Delay & Root Dispersion
  packetBuffer[12] = 49;
  packetBuffer[13] = 0x4E;
  packetBuffer[14] = 49;
  packetBuffer[15] = 52;
  Udp.beginPacket(address, 123);  //NTP需要使用的UDP端口号为123
  Udp.write(packetBuffer, NTP_PACKET_SIZE);
  Udp.endPacket();
}

首先清理了缓冲区packetBuffer,而这个缓冲区是我们在程序开始处定义好的,如下:

const int NTP_PACKET_SIZE = 48;      // NTP时间在消息的前48个字节里
byte packetBuffer[NTP_PACKET_SIZE];  // 输入输出包的缓冲区

memset函数用于将一段内存区域的所有字节设置为特定的值,它的三个参数分别表示:

  1. 要填充的块的内存指针
  2. 要设置的值
  3. 要设置的字节数

所以这里所做的就是先将缓冲区全部置0,然后开始编辑。

缓冲区的长度是48位,这是因为NTP数据包就是48位的,这是协议的规定。

编辑是根据NTP的数据包进行的,下面提供一个参考

数据包捕获!网络时间协议(NTP)详解

特别指出一点,注意到上面我们对4~11位是没有配置的,这八位是根延迟根离差,分别表示到参考时钟源的的总往返延迟和时钟采样分散程度,合理配置这两位可以降低延迟带来的误差和某种原因导致的错误等。

但不设置这两位也已能达到我们的标准了,毫秒级的精度已经足够了。

此外,12~15位是参考ID,标识特定引用源的32位位串。不过我并不确定这四位的设置规范,这里这样设置可能只是用于某种特定的表识,但这并非必须的。

其他的基本不需要更改,按照上面的配置即可。

数据包配置完成后,开始向指定的IP地址的123端口(NTP默认使用123)发送UDP数据包,然后将初始化并设置好的 NTP 数据包写入 UDP 数据包。最终完成并发出去。如下:

  Udp.beginPacket(address, 123);  //NTP需要使用的UDP端口号为123
  Udp.write(packetBuffer, NTP_PACKET_SIZE);
  Udp.endPacket();

看起来很奇怪,但这就是UDP。

3.2.4 UDP解包

解包部分的代码是这样的(截取自函数getNtpTime):

  uint32_t beginWait = millis();
  while (millis() - beginWait < 1500) {
    int size = Udp.parsePacket();
    if (size >= NTP_PACKET_SIZE) {
      Serial.println("Receive NTP Response");
      isNTPConnected = true;
      Udp.read(packetBuffer, NTP_PACKET_SIZE);  // 将数据包读取到缓冲区
      unsigned long secsSince1900;
      // 将从位置40开始的四个字节转换为长整型,只取前32位整数部分
      secsSince1900 = (unsigned long)packetBuffer[40] << 24;
      secsSince1900 |= (unsigned long)packetBuffer[41] << 16;
      secsSince1900 |= (unsigned long)packetBuffer[42] << 8;
      secsSince1900 |= (unsigned long)packetBuffer[43];
      Serial.println(secsSince1900);
      Serial.println(secsSince1900 - 2208988800UL + timeZone * SECS_PER_HOUR);
      return secsSince1900 - 2208988800UL + timeZone * SECS_PER_HOUR;
    }
  }
  Serial.println("No NTP Response :-(");  //无NTP响应
  isNTPConnected = false;
  return 0;  //如果未得到时间则返回0

在发送之后,我们要接受并处理数据。

首先记录当前的时间beginWait,并进入一个循环,循环的持续时间不超过1500毫秒(1.5秒)。

这里用到了我们前面提到过的计时函数。

如果超过1.5秒,说明等了这么久还没有收到返回的数据。所以循环外面是这样的:

  Serial.println("No NTP Response :-(");  //无NTP响应
  isNTPConnected = false;
  return 0;  //如果未得到时间则返回0

如果在1.5秒内收到了数据,那么还未跳出循环,这个函数就返回了。因此不会执行循环下面的代码,也就是上面这三行。

接下来我们看看是怎样处理数据,也即解包的。

先尝试解析UDP数据包,如果接收到的数据包大小不小于NTP协议所需的数据包大小(前面说到的48字节),则继续处理(大括号未闭合):

int size = Udp.parsePacket();
if (size >= NTP_PACKET_SIZE) {
    Serial.println("Receive NTP Response");
    isNTPConnected = true;

解包函数是parsePacket,前面这个Udp就是3.1节中提到的一个WiFiUDP对象。

接下来将数据读取到缓冲区,就可以进行处理了:

Udp.read(packetBuffer, NTP_PACKET_SIZE); 
3.2.5 时间戳处理

数据包的第40~43个字节包含了从1900年1月1日0时0分0秒(NTP时代)到当前时间的秒数,这是一个32位无符号整数,表示自NTP时代开始以来的秒数。

时间戳处理的代码大家可以认为是固定格式(尽管我们没有处理那些控制位,例如根延迟等等):

unsigned long secsSince1900;
// 将从位置40开始的四个字节转换为长整型,只取前32位整数部分
secsSince1900 = (unsigned long)packetBuffer[40] << 24;
secsSince1900 |= (unsigned long)packetBuffer[41] << 16;
secsSince1900 |= (unsigned long)packetBuffer[42] << 8;
secsSince1900 |= (unsigned long)packetBuffer[43];
Serial.println(secsSince1900);
Serial.println(secsSince1900 - 2208988800UL + timeZone * SECS_PER_HOUR);
return secsSince1900 - 2208988800UL + timeZone * SECS_PER_HOUR;

首先将40~43字节的内容提取出来,并转换为一个无符号长整型secsSince1900,这就是自1900年1月1日以来的秒数。

而2208988800UL表示从1900年1月1日到1970年1月1日的秒数(这个大家都很熟悉了,相对地,它被称为UNIX时代)。

这两个作差,再加上时差,就获得当前的时间了。

timeZone就是时区,在程序开始的时候我们做的定义:

int timeZone = 8;

表示东八区(北京时间)。

而另一个SECS_PER_HOUR是什么呢?看字面意思,它应当是每小时的秒数,但它在哪里定义的呢?

实际上,我们使用了TimeLib这个库,这个每小时的秒数是在这里定义的,如下所示: 

#define SECS_PER_HOUR ((time_t)(3600UL))

所以如果程序能正常运行,不必感到惊讶。

3.3 数据存取

在经过网络时间同步后,ESP8266的内部变量得到更新,这些被更新的变量就对应着时间。

now()可以直接获取当前时间戳,hour()、minute()、second()、day()、month()、year()、weekday()等等可以获得更具体的时间单位。

以下是一个示例(截取显示函数的一部分):

void oledClockDisplay() {
  int years, months, days, hours, minutes, seconds, weekdays;
  years = year();
  months = month();
  days = day();
  hours = hour();
  minutes = minute();
  seconds = second();
  weekdays = weekday();
  Serial.printf("%d/%d/%d %d:%d:%d Weekday:%d\n", years, months, days, hours, minutes, seconds, weekdays);
}

如上所示,我们就可以访问具体的时间单位,并且在串口将它们打印出来。

下面是格式化并且显示在屏幕上的代码片段:

String currentTime = "";
  if (hours < 10)
    currentTime += 0;
  currentTime += hours;
  currentTime += ":";
  if (minutes < 10)
    currentTime += 0;
  currentTime += minutes;
  currentTime += ":";
  if (seconds < 10)
    currentTime += 0;
  currentTime += seconds;
  String currentDay = "";
  currentDay += years;
  currentDay += "/";
  if (months < 10)
    currentDay += 0;
  currentDay += months;
  currentDay += "/";
  if (days < 10)
    currentDay += 0;
  currentDay += days;

  u8g2.setFont(u8g2_font_logisoso24_tr);
  u8g2.setCursor(0, 44);
  u8g2.print(currentTime);
  u8g2.setCursor(0, 61);
  u8g2.setFont(u8g2_font_unifont_t_chinese2);
  u8g2.print(currentDay);

拼接字符串,比较简单就不多赘述了。

十一、温湿度传感器

在准备篇中我们说到,会使用温湿度传感器DHT11来测量室内温湿度,接下来就实现这个功能。

1. 库安装

像之前一样,我们需要安装DHT11所用到的库:

 安装图上的这一个,注意点击全部安装,这样保证所有依赖都被安装。

2. 接线

DHT11有三个引脚,S表示信号线,用来传输数据,-表示接地,中间那个自然就是接VCC了。有些四脚的DHT11可能会多一个NC,这个是无连接的意思,不必为这个引脚接线。

当然,不同品牌的可能上面的标识不一样,例如有些可能用OUT或者DATA来表示信号线,GND表示接地等等。

总之建议大家认真阅读说明书。

网上随便找了一个四引脚的图,从左到右一般是VCC、S、NC、GND,大部分四引脚的布线都是如此。

3. 示例程序

这里为大家提供一个示例程序:

#include <ESP8266WiFi.h>
#include <Adafruit_Sensor.h>
#include <DHT.h>
#include <DHT_U.h>

// 设置DHT11传感器类型
#define DHTTYPE    DHT11
#define DHTPIN     D4     // DHT11 数据引脚连接到ESP8266的D4引脚

DHT dht(DHTPIN, DHTTYPE);

void setup() {
  Serial.begin(115200);
  dht.begin();
  Serial.println("DHT11传感器初始化完成");
}

void loop() {
  // 读取温度和湿度
  float humidity = dht.readHumidity();
  float temperature = dht.readTemperature();

  // 检查是否读取失败并给出警告
  if (isnan(humidity) || isnan(temperature)) {
    Serial.println("无法从DHT11读取数据");
    return;
  }

  // 输出结果到串口
  Serial.print("湿度: ");
  Serial.print(humidity);
  Serial.print(" %\t");
  Serial.print("温度: ");
  Serial.print(temperature);
  Serial.println(" *C");

  // 等待2秒钟再读取下一次数据
  delay(2000);
}

连接正确的话,我们可以在串口监视器看到类似下图:

配置方法很简单,如下所示:

// 设置DHT11传感器类型
#define DHTTYPE    DHT11
#define DHTPIN     D4     // DHT11 数据引脚连接到ESP8266的D4引脚

DHT dht(DHTPIN, DHTTYPE);

void setup() {
  Serial.begin(115200);
  dht.begin();
  Serial.println("DHT11传感器初始化完成");
}

首先选定传感器类型DHT11和数据引脚,然后初始化一个对象dht,之后在setup函数中使用dht.bdgin()开始测量。

温湿度传感器的使用也非常简单,我就简要介绍一下各个函数:

  1. dht.readHumidity();表示读取湿度,返回一个浮点数

  2. dht.readTemperature();表示读取温度,返回一个浮点数

  3. isnan,这个函数接受一个双精度浮点数,如果参数是NaN,返回true,否则返回false

bool isnan(double x);

isnan如上所示。我们通常使用它来检查从传感器获取的数据是否有效,从而可以在代码中进行相应的错误处理。

有人可能会疑惑,它凭什么确定数据是否有效呢?是这样的,传感器接受到信号到转化为我们可以看到的浮点数这个过程中,会涉及一些数学运算,而有些非法的数学运算(例如0作除数)或者溢出等等就会触发这个NaN,于是可以确定这个数据是无效的。

未完待续

接下来继续讲述这一部分,再加上一些arduino的小技巧之类的。。。我还会继续加工外壳,其实外壳的加工难度有点超出预料了。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值