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获取时间的流程是这样的:
- 客户端(ESP8266)发送UDP数据报请求时间
- NTP服务器收到报文
- 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函数用于将一段内存区域的所有字节设置为特定的值,它的三个参数分别表示:
- 要填充的块的内存指针
- 要设置的值
- 要设置的字节数
所以这里所做的就是先将缓冲区全部置0,然后开始编辑。
缓冲区的长度是48位,这是因为NTP数据包就是48位的,这是协议的规定。
编辑是根据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()开始测量。
温湿度传感器的使用也非常简单,我就简要介绍一下各个函数:
-
dht.readHumidity();表示读取湿度,返回一个浮点数
-
dht.readTemperature();表示读取温度,返回一个浮点数
-
isnan,这个函数接受一个双精度浮点数,如果参数是NaN,返回true,否则返回false
bool isnan(double x);
isnan如上所示。我们通常使用它来检查从传感器获取的数据是否有效,从而可以在代码中进行相应的错误处理。
有人可能会疑惑,它凭什么确定数据是否有效呢?是这样的,传感器接受到信号到转化为我们可以看到的浮点数这个过程中,会涉及一些数学运算,而有些非法的数学运算(例如0作除数)或者溢出等等就会触发这个NaN,于是可以确定这个数据是无效的。
未完待续
接下来继续讲述这一部分,再加上一些arduino的小技巧之类的。。。我还会继续加工外壳,其实外壳的加工难度有点超出预料了。