IoT 智能时钟(使用 Mega 2560+WiFi R3)






4.85/5 (7投票s)
一个先进的网络连接时钟,
引言
有时,设备的用途远不止表面看上去那么简单。我们以时钟为例,探索物联网设备的一些高级技术,即使是这个不起眼的、讽刺地命名为 Mega 的设备。我们将基于一些教程组装一个时钟,然后编写一些“魔法”代码来赋予它魔力。我还会为你提供一些库,这些库可以简化你自己的应用程序中的自动网络配置部分。
必备组件
你需要一个 Arduino Mega 2560+WiFi R3。
你需要一个 DS1307 或类似的实时时钟。
你需要一个 DHT11 温湿度传感器。
你需要一个带日立接口的 16x2 LCD 显示屏。
你需要常用的电线和面包板。
你需要 Arduino IDE。你必须进入 文件|首选项 并将以下内容添加到 附加开发板管理器网址 文本框中:
如果已存在 URL,请用逗号分隔。
现在请点击此处获取最新的 ESP8266FS zip 文件。
如果你在 Linux 上,你需要解压 zip 文件中的 ESP8266FS 文件夹,并找到你的 Arduino 应用程序目录。它应该在你的主目录下。我的目录是 ~/arduino-1.8.13。不要与 ~/Arduino 混淆。在该目录下有一个 tools 文件夹,你就是要把你的 ESP8266FS 文件夹放到这里。
我从没在 Windows 上做过,但操作方法如下:你需要 zip 文件中的 ESP8266FS 文件夹。找到你的程序目录。它可能类似于 C:\Program Files (x86)\arduino-1.8.13。里面有一个 tools 文件夹。这就是 ESP8266FS 文件夹需要放置的位置。
无论哪种方式,你都需要重新启动 Arduino IDE。如果现在有了 工具|ESP8266 Sketch 数据上传 选项,就表示安装成功了。
你还需要安装 ESP8266AutoConfLibs.zip 中的所有库。解压后,你可以进入 项目|加载库|添加 .ZIP 库... 并选择每个 zip 文件进行安装。
概念化这个混乱的局面
连接和配置
这是项目中最复杂的部分,也是我创建此项目和文章的主要原因,所以我们将特别花一些时间在这一小节上。
我的物联网设备通常会自动配置其 WiFi 连接并支持 WPS。我的意思是,我的设备除了开机外,不需要进行任何操作。一旦路由器上的 WPS 按钮被按下,它们就会连接。这对于需要网络连接的设备来说相对简单,因为我们可以暂停应用程序的其余部分,直到连接和配置完成,但是对于像时钟这样的应用程序,它需要持续运行,同时理想地在后台寻找互联网连接或 WPS 信号,该怎么办呢?使用默认的 ESP8266WiFi 库,这几乎是不可能的。
以下是我们连接网络的步骤:
- 从配置中读取 SSID 和密码。
- 如果可用,使用它们尝试连接到该网络,否则跳过此步骤。
- 如果 #2 超时或失败,或者我们跳过了 #2,尝试扫描 WPS 信号,如果找到则连接。
- 如果我们超时/未能找到 WPS 信号,则返回 #2。
- 否则,将新的 SSID 和密码保存到配置中并继续。
正如我所说,在阻塞直到进程完成的前台,这相对简单。当它必须在后台完成时,则要复杂得多。ESP8266WiFi 库的 `beginWPSconfig()` 是同步的,所以我挣扎了一段时间,才制作了一个像 `begin()` 一样的异步版本。我的库叫做 ESP8266AsyncWiFi,前面提到的方法是我唯一改变的行为。当我第一次尝试这样做时,我试图不分叉 WiFi 库,但结果是随机崩溃,因为我无法调用一些使其工作的私有方法。
然而,麻烦并没有就此结束。为了在不使用 `delay()` 的情况下使所有超时都起作用,并管理上述步骤之间的不同转换,我构建了一个从 `loop()` 中调用的状态机。状态机在连接和 WPS 协商过程导航时从一个状态移动到下一个状态。涉及两个超时——一个用于连接,一个用于 WPS 搜索。基本上,我们只是在尝试连接和尝试寻找 WPS 信号之间来回切换,但我们以一种不阻塞的方式进行。它并不美观——状态机很少美观,但至少它没有比它需要的更复杂。
我将所有的自动配置功能都变成了几个库,并将其包含在此分发包中。你可以通过进入 项目|加载库|添加 .ZIP 库... 来使用 Arduino IDE 安装每个 zip 文件。
- ESP8266AsyncWifi 是随附的两个异步自动配置库(见下文)所必需的。
- ESP8266AsyncAutoConf 是一个后台自动 WiFi 配置库,使用原始闪存存储网络凭据。
- ESP8266AsyncAutoConfFS 是一个后台自动 WiFi 配置库,使用 SPIFFS 闪存文件系统存储网络凭据。如果你已经打算使用文件系统来提供网页等功能,这很有用。
- ESP8266AutoConf 是一个前台自动 WiFi 配置库,使用原始闪存存储网络凭据。
- ESP8266AutoConfFS 是一个前台自动 WiFi 配置库,使用 SPIFFS 闪存文件系统存储网络凭据。
如何为你的项目选择合适的 AutoConf 库
- 如果你的设备不需要网络即可运行,你应该使用上面的 #2 或 #3。
- 如果你的设备需要网络才能运行,你应该使用上面的 #4 或 #5。
- 如果你的设备需要文件系统,请使用上面的 #3 或 #5。
- 如果你的设备不需要文件系统,请使用上面的 #2 或 #4。
这只是一个使用它们的通用指南。我们将为我们的项目选择 #3,因为我们不需要网络连接即可运行,并且我们需要一个文件系统。这样,我们的时钟可以在后台自动搜索 WPS 信号或可用的 WiFi 连接,同时提供一个小型网站和网络服务。
时钟硬件
我基本上是从几个示例项目拼凑起来的硬件,这里、这里和这里。我希望你能按照它们,并将它们组合到一个原型板上。请记住,在 Mega 上连接时钟时,你将要将其设置为第二个 I2C 接口(SDA20
/SCL21
),而不是第一组。你还需要将 DHT 传感器的 S 线插入 A0
,并且相应的代码需要更新以将代码中的引脚更改为 A0
。如果你的时钟不是 DS1307,你需要相应地调整你的代码和接线。
一旦你接好线并用一些废弃代码进行测试,我们就可以开始核心部分了。这是一些用于测试的废弃代码:
#include <LiquidCrystal.h>
#include <dht.h>
#include <RTClib.h>
RTC_DS1307 RTC;
dht DHT;
float _temperature;
float _humidity;
#define DHT11_PIN A0
// initialize the library by providing the nuber of pins to it
LiquidCrystal LCD(8, 9, 4, 5, 6, 7);
void setup() {
Serial.begin(115200);
LCD.begin(16, 2);
pinMode(DHT11_PIN, INPUT);
if (! RTC.begin()) {
Serial.println(F("Couldn't find RTC"));
while (true);
}
RTC.adjust(DateTime(__DATE__, __TIME__));
}
void loop()
{
int chk = DHT.read11(DHT11_PIN);
float f = DHT.temperature;
if (-278 < f) {
_temperature = f;
_humidity = DHT.humidity;
}
DateTime now = RTC.now();
char sz[16];
memset(sz,' ',16);
sprintf(sz, "%d:%02d:%02d",
now.hour(),
now.minute(),
now.second());
int c = strlen(sz);
if(c<16)
sz[c]=' ';
LCD.setCursor(0,0);
LCD.print(sz);
LCD.setCursor(0, 1);
memset(sz,' ',16);
if (1==(millis() / 1000L) % 2) {
sprintf(sz,"Temp: %dC/%dF",(int)_temperature,(int)((int)_temperature * 1.8) + 32);
int c = strlen(sz);
if(c<16)
sz[c]=' ';
} else {
sprintf(sz,"Humidity: %d",(int)_humidity);
int c = strlen(sz);
if(c<16)
sz[c]=' ';
}
LCD.print(sz);
}
如果一切正常,这应该在显示屏上显示时间、温度和湿度。
ATMEGA2560
这个处理器将负责管理 LCD 输出,并与 WiFi 模块上的 XDS 160Micro 处理器通信时钟和传感器读数。我们将使用该处理器来完成几乎所有繁重的工作,因为它功能更强大,并且内置了 WiFi,而无需通过串口访问。我们确实会使用一个串口,但只是为了来回获取传感器数据和时钟信息。大部分情况下,除了更新时钟显示外,它只是在串口上监听传入的 255 值。如果它收到该值,它会接下来读取一个操作码,然后是取决于操作码的命令字节。任何其他值都将转发到通过 USB 暴露的串口。
XDS 160Micro
该处理器将负责协商 WiFi 网络、运行 Web 服务器、与 NTP 服务器通信并在必要时设置时钟。每当它需要传感器或时钟信息时,它必须通过串行线查询 ATMega2560。它通过发送字节 255,然后是 0,然后接收所有时钟和传感器数据来完成此操作。如果它发送 255,然后是 1,然后是一个表示 Unix 时间的 32 位数字,它将设置时钟。它在 http://clock.local 运行一个 Web 服务器,该服务器将显示时间、温度和湿度。它在 http://clock.local/time.json 运行一个 JSON 服务。
编写这个混乱的程序
ESPAsyncAutoConf 功能
此功能可自动扫描 WiFi 和 WPS,并管理设备中存储的 SSID 和密码。
如何使用它
在我们深入探讨它是如何工作的之前,让我们先看看如何使用它
#include <ESP8266AsyncAutoConfFS.h> // header chosen from #3 above
在我们的 `setup()` 方法中
ESPAsyncAutoConf.begin();
如果你不使用异步版本,你将调用 `ESPAutoConf.begin()`。
在 `loop()` 方法中
ESPAsyncAutoConf.update();
// your loop code here.
与上面类似,如果你不使用异步版本,你将改为引用 `ESPAutoConf`。
你可以像平常一样查看是否连接到网络
if(WL_CONNECTED == WiFi.status()) Serial.println("Connected!");
对于同步版本,在调用 `update()` 之后,你将始终处于连接状态。对于异步版本,在调用 `update()` 之后,它们很可能不会连接。
记住,在使用异步库时,在执行网络操作之前,务必检查是否已连接!
关于使用它就到这里。让我们深入了解它是如何制作的。
构建它
我已经在此处介绍了这些库的同步版本的机制。自该文章发布以来,库中的代码有所更新,但概念没有改变。本文将重点介绍异步配置过程。
这些异步库的大部分核心代码都在 `update()` 中,它通常从 `loop()` 中调用。
#include "ESP8266AsyncAutoConfFS.h"
_ESPAsyncAutoConf ESPAsyncAutoConf;
void _ESPAsyncAutoConf::begin() {
SPIFFS.begin();
int i = 0;
// Read the settings
_cfgssid[0] = 0;
_cfgpassword[0] = 0;
File file = SPIFFS.open("/wifi_settings", "r");
if (file) {
if (file.available()) {
int l = file.readBytesUntil('\n', _cfgssid, 255);
_cfgssid[l] = 0;
if (file.available()) {
l = file.readBytesUntil('\n', _cfgpassword, 255);
_cfgpassword[l] = 0;
}
}
file.close();
}
// Initialize the WiFi
WiFi.mode(WIFI_STA);
_wifi_timestamp = 0;
_wifi_conn_state = 0;
}
void _ESPAsyncAutoConf::update() {
// connect, reconnect or discover the WiFi
switch (_wifi_conn_state) {
case 0: // connect
if (WL_CONNECTED != WiFi.status())
{
if (0 < strlen(_cfgssid)) {
Serial.print(F("Connecting to "));
Serial.println(_cfgssid);
if (WiFi.begin(_cfgssid, _cfgpassword)) {
// set the state to connect
// in progress and reset
// the timestamp
_wifi_conn_state = 1;
_wifi_timestamp = 0;
}
} else {
// set the state to begin
// WPS and reset the
// timestamp
_wifi_conn_state = 2;
_wifi_timestamp = 0;
}
}
break;
case 1: // connect in progress
if (WL_CONNECTED != WiFi.status()) {
if (!_wifi_timestamp)
_wifi_timestamp = millis();
else if (20000 <= (millis() - _wifi_timestamp)) {
Serial.println(F("Connect attempt timed out"));
// set the state to begin
// WPS and reset the
// timestamp
_wifi_conn_state = 2;
_wifi_timestamp = 0;
}
} else {
Serial.print(F("Connected to "));
// store the WiFi configuration
Serial.println(WiFi.SSID());
strcpy(_cfgssid,WiFi.SSID().c_str());
strcpy(_cfgpassword,WiFi.psk().c_str());
File file = SPIFFS.open("/wifi_settings", "w");
if (file) {
file.print(_cfgssid);
file.print("\n");
file.print(_cfgpassword);
file.print("\n");
file.close();
}
// set the state to connected
_wifi_conn_state = 4;
_wifi_timestamp = 0;
}
break;
case 2: // begin wps
Serial.println(F("Begin WPS search"));
if (WL_CONNECTED != WiFi.status()) {
WiFi.beginWPSConfig();
// set the state to WPS in
// progress
_wifi_conn_state = 3;
_wifi_timestamp = 0;
}
break;
case 3: // wps in progress
if (WL_CONNECTED != WiFi.status()) {
if (!_wifi_timestamp)
_wifi_timestamp = millis();
else if (30000 <= (millis() - _wifi_timestamp)) {
Serial.println(F("WPS search timed out"));
// set the state to connecting
_wifi_conn_state = 0;
_wifi_timestamp=0;
}
} else {
// eventually goes to 4:
_wifi_conn_state = 1;
_wifi_timestamp = 0;
}
break;
case 4:
if (WL_CONNECTED != WiFi.status()) {
// set the state to connecting
_wifi_conn_state = 0;
_wifi_timestamp = 0;
}
break;
}
}
你可能会注意到这是一个状态机。我们之前在概念化部分已经介绍过它的作用。逻辑有点混乱,但为了处理所有情况,这是必要的。我喜欢状态机的概念,但在实践中却不那么喜欢,因为它们可能难以阅读。然而,有时它们正是适合这项工作的工具。
这个程序的整个想法是将连接、扫描 WPS、连接、扫描 WPS 等过程分解成一个协程——一个“可重新启动的方法”——这就是状态机的用武之地。每次调用 `loop()` 时,我们都会从上次离开的地方继续,因为我们正在用 `_wifi_conn_state` 跟踪状态。它并不是最不言自明的代码,但如果你仔细观察,你就能理解它。
ATmega2560
这是 ATmega2560 CPU 的处理代码,我们之前已经介绍过,所以让我们直接看代码吧。
#include <dht.h>
#include <RTClib.h>
#include <LiquidCrystal.h>
// make sure S is on analog 0
#define DHT11_PIN A0
// these unions make it
// easy to convert
// numbers to bytes
typedef union {
float fp;
byte bin[4];
} binaryFloat;
typedef union {
uint32_t ui;
byte bin[4];
} binaryUInt;
float _temperature;
float _humidity;
dht DHT;
RTC_DS1307 RTC;
LiquidCrystal LCD(8, 9, 4, 5, 6, 7);
void setup() {
// initialize everything
Serial.begin(115200);
Serial3.begin(115200);
pinMode(DHT11_PIN,INPUT);
if (! RTC.begin()) {
Serial.println(F("Couldn't find the clock hardware"));
while (1);
}
if (! RTC.isrunning())
Serial.println(F("The clock is not running!"));
LCD.begin(16, 2);
}
void loop() {
// update the temp and humidity
// note that sometimes the DHT11
// will return -999s for the
// values. We check for that.
int chk = DHT.read11(DHT11_PIN);
float tmp = DHT.temperature;
if (-278 < tmp) {
_temperature = tmp;
_humidity = DHT.humidity;
}
DateTime now = RTC.now();
// format our time and other
// info to send to the LCD
char sz[16];
memset(sz,' ',16);
sprintf(sz, "%d:%02d:%02d",
now.hour(),
now.minute(),
now.second());
int c = strlen(sz);
if(c<16)
sz[c]=' ';
LCD.setCursor(0,0);
LCD.print(sz);
LCD.setCursor(0, 1);
memset(sz,' ',16);
if (1==(millis() / 2000L) % 2) {
sprintf(sz,"Temp: %dC/%dF",(int)_temperature,(int)((int)_temperature * 1.8) + 32);
int c = strlen(sz);
if(c<16)
sz[c]=' ';
} else {
sprintf(sz,"Humidity: %d",(int)_humidity);
int c = strlen(sz);
if(c<16)
sz[c]=' ';
}
LCD.print(sz);
// now wait for incoming serial data
if (Serial3.available()) {
byte b = Serial3.read();
// if it's not our escape byte
// of 255, just forward it
if (255 != b)
{
Serial.write(b);
return;
}
b = Serial3.read();
switch (b) {
case 0: // get unixtime, temp and humidity
// read one int32 and two floats from the
// serial line
binaryUInt data;
data.ui = RTC.now().unixtime();
Serial3.write(data.bin, 4);
binaryFloat data2;
data2.fp = _temperature;
Serial3.write(data2.bin, 4);
data2.fp = _humidity;
Serial3.write(data2.bin, 4);
break;
case 1: // set clock
// read an int32 and set the
// clock
Serial3.readBytes((char*)data.bin,4);
RTC.adjust(DateTime(data.ui));
break;
}
}
}
请记住将你的 DIP 开关设置为 1-4 ON 和 5-8 OFF。在烧录上述代码之前,还要从板卡菜单中选择“Arduino Mega or Mega 2560”。
XDS Micro160
这是 XDS Micro160 的代码。如你所见,这要复杂得多。这主要是由于时钟的所有功能。
#include <ESP8266AsyncAutoConfFS.h>
#include <ESPAsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <ESP8266mDNS.h>
#include <WiFiUdp.h>
// make it easier to convert
// between numbers and bytes:
typedef union {
float fp;
byte bin[4];
} binaryFloat;
typedef union {
uint32_t ui;
byte bin[4];
} binaryUInt;
// the host name for the webserver
#define HOSTNAME "clock"
// local port to listen for UDP packets
unsigned int localPort = 2390;
// time.nist.gov NTP server address
IPAddress timeServerIP;
const char* ntpServerName = "time.nist.gov";
// NTP time stamp is in the first 48 bytes of the message
const int NTP_PACKET_SIZE = 48;
//buffer to hold incoming and outgoing packets
byte packetBuffer[ NTP_PACKET_SIZE];
// A UDP instance to let us send and receive packets over UDP
WiFiUDP udp;
unsigned long _ntp_timestamp;
unsigned long _mdns_timestamp;
bool _net_begin;
AsyncWebServer server(80);
void sendNTPpacket(IPAddress& address);
String process(const String& arg);
void setup() {
_ntp_timestamp = 0;
_mdns_timestamp = 0;
_net_begin = false;
Serial.begin(115200);
ESPAsyncAutoConf.begin();
// web handlers
server.on("/", HTTP_GET, [](AsyncWebServerRequest * request) {
request->send(SPIFFS, F("/index.html"), String(), false, process);
});
server.on("/time.json", HTTP_GET, [](AsyncWebServerRequest * request) {
request->send(SPIFFS, F("/time.json"), String(), false, process);
});
}
void loop() {
if (WL_CONNECTED != WiFi.status()) {
_mdns_timestamp = 0;
_net_begin = false;
}
// give the clock a chance to connect
// to the network:
ESPAsyncAutoConf.update();
// check if we're connected
if (WL_CONNECTED == WiFi.status()) {
// the first time we connect,
// start the services
if (!_net_begin) {
_net_begin = true;
MDNS.begin(F(HOSTNAME));
server.begin();
udp.begin(localPort);
Serial.print(F("Started http://"));
Serial.print(F(HOSTNAME));
Serial.println(F(".local"));
}
// now send an NTP packet every 5 minutes:
if (!_ntp_timestamp)
_ntp_timestamp = millis();
else if (300000 <= millis() - _ntp_timestamp) {
WiFi.hostByName(ntpServerName, timeServerIP);
sendNTPpacket(timeServerIP); // send an NTP packet to a time server
_ntp_timestamp = 0;
}
// update the MDNS responder every second
if (!_mdns_timestamp)
_mdns_timestamp = millis();
else if (1000 <= millis() - _mdns_timestamp) {
MDNS.update();
_mdns_timestamp = 0;
}
// if we got a packet from NTP, read it
if (0 < udp.parsePacket()) {
udp.read(packetBuffer, NTP_PACKET_SIZE); // read the packet into the buffer
//the timestamp starts at byte 40 of the received packet and is four bytes,
// or two words, long. First, esxtract the two words:
unsigned long highWord = word(packetBuffer[40], packetBuffer[41]);
unsigned long lowWord = word(packetBuffer[42], packetBuffer[43]);
// combine the four bytes (two words) into a long integer
// this is NTP time (seconds since Jan 1 1900):
unsigned long secsSince1900 = highWord << 16 | lowWord;
// Unix time starts on Jan 1 1970. In seconds, that's 2208988800:
const unsigned long seventyYears = 2208988800UL;
// subtract seventy years:
unsigned long epoch = secsSince1900 - seventyYears;
// use the data to set the clock
Serial.write((byte)255);
Serial.write((byte)1);
binaryUInt data;
data.ui = epoch;
Serial.write((char*)data.bin, 4);
}
}
}
String process(const String& arg)
{
// replace template parameters in
// the web page with actual values
Serial.write((byte)255);
Serial.write((byte)0);
binaryUInt data;
Serial.read((char*)data.bin, 4);
unsigned long epoch = data.ui;
binaryFloat dataf;
Serial.read((char*)dataf.bin, 4);
float tmp = dataf.fp;
Serial.read((char*)dataf.bin, 4);
float hum = dataf.fp;
if (arg == "TIMESTAMP") {
char sz[256];
// print the hour, minute and second:
sprintf(sz, "%d:%02d:%02d", (epoch % 86400L) / 3600,
(epoch % 3600) / 60,
epoch % 60
);
return String(sz);
} else if (arg == "TEMPERATURE") {
char sz[256];
sprintf(sz, "%f",tmp);
return String(sz);
} else if(arg=="HUMIDITY") {
char sz[256];
sprintf(sz, "%f",hum);
return String(sz);
}
return String();
}
// send an NTP request to the time server at the given address
void sendNTPpacket(IPAddress& address) {
Serial.println("sending NTP packet...");
// set all bytes in the buffer to 0
memset(packetBuffer, 0, NTP_PACKET_SIZE);
// Initialize values needed to form NTP request
// (see URL above for details on the packets)
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;
// all NTP fields have been given values, now
// you can send a packet requesting a timestamp:
udp.beginPacket(address, 123); //NTP requests are to port 123
udp.write(packetBuffer, NTP_PACKET_SIZE);
udp.endPacket();
}
请记住将你的 DIP 开关设置为 1-4 OFF,5-7 ON,8 OFF。在烧录代码之前,从板卡菜单中选择“Generic ESP8266”。烧录代码后,请确保也将数据目录上传到你的闪存中。烧录完成后,将 DIP 开关设置回 1-4 ON,5-8 OFF。
请注意,我们上面的“网络服务”只是一个模板化的 JSON 文件
{
"time": "%TIMESTAMP%",
"temperature": %TEMPERATURE%,
"humidity": %HUMIDITY%
}
这些以 % 分隔的值通过上面提到的 `process()` 例程解析。这是一种实现动态网络服务的无耻方式,但它有效,并使我们可怜的小 CPU 不必进行大量的 JSON 字符串拼接。
这些通过以下 HTML 和 JavaScript 获取。请原谅我的混乱。
<!DOCTYPE html>
<html>
<head>
<title>Clock</title>
</head>
<body>
<span>Time:</span><span id="TIME">Loading</span><br />
<span>Temp:</span><span id="TEMPERATURE">Loading</span><br />
<span>Humidity:</span><span id="HUMIDITY">Loading</span>
<script>
function askTime() {
var xmlhttp = new XMLHttpRequest();
xmlhttp.onreadystatechange = function() {
if (this.readyState == 4 && (this.status==0 || this.status == 200)) {
var obj = JSON.parse(this.responseText);
document.getElementById("TIME").innerHTML = obj['time'];
var far = (obj['temperature'] * 1.8) + 32;
document.getElementById("TEMPERATURE").innerHTML =
Math.round(obj['temperature']) +
'C/'+
Math.round(far)+
'F';
document.getElementById("HUMIDITY").innerHTML =
Math.round(obj['humidity']);
}
};
xmlhttp.open("GET", "time.json", true);
xmlhttp.send();
setTimeout(function() {askTime();},1000);
}
askTime();
</script>
</body>
</html>
没什么可看的,这只是经典的基于 JSON 的 AJAX 技术,剥离了所有花哨的 JS 框架,直接接触底层。
历史
- 2020 年 11 月 10 日 - 初次提交