WiFi 远程水泵监控器





5.00/5 (10投票s)
探索一个基于 Arduino 的物联网 Web 服务器和 UDP 组播器,用于监控远程水泵
引言
水泵监控器是一个宏大的项目,探索了 Arduino Mega 2560+WiFi R3 的一些高级功能,重点在于其集成的 ESP8266 模块。我们研究了创建异步模板 Web 服务器、嵌入式 Flash 文件系统(读写)以及 UDP 组播、组播 DNS 发布,将 ATmega2560 从设备化以提高 ESP8266 的性能,并使用 Mega 进行一些基本的数字 I/O。最终结果是一个几乎无需配置的 IoT 设备,可以远程监控水泵,并提供几种不同的访问方式。
我的岳父母住在一栋漂亮的房子里,坐落在森林深处。他们从一条小溪取水,但水泵有时需要一些照看。这主要是由于泥浆和碎片给它带来了问题。它也不能持续运行,因为如果它虹吸了太多的水,小溪水位就会下降,最终你会吸入泥浆。
多年前,我的岳父建造了一个简单的 12vdc 逻辑板,它使用水位传感器来确定水泵的工作状态。状态显示在一系列 110vac 灯上。与其在水箱中安装一个全新的系统,我们决定连接现有系统中的灯的电路,所以我们所做的就是将带 110vac 线圈的继电器连接到灯的电路上,并使用它们上的开关将 Arduino 的引脚 (2-4) 设置为高电平,以指示相应的灯亮起。接线非常简单,以至于很无聊。真正有趣的是驱动它的软件。我们将在此介绍。
更新:添加了 WPS 支持
必备组件
您将需要以下内容
- 一块 Arduino Mega 2560+WiFi R3 板或其克隆板
- 一份 Arduino IDE 副本
- IDE 的 ESP8266 板管理器
- ESPAsyncWebServer 库
- ESPAsyncTCP 库
- ESP8266 文件系统上传工具
您可以在此处找到 Arduino IDE。
要添加板管理器,您需要转到文件|首选项,然后在框中添加以下 URL:http://arduino.esp8266.com/stable/package_esp8266com_index.json
您可以在此处下载 ESPAsyncWebServer 库。下载后,解压缩,您应该会得到一个名为 ESPAsyncWebServer-master 的文件夹。将其解压缩并重命名为 ESPAsyncWebServer。最后,在您的应用程序文件夹(Windows 下的Program Files (x86) 或 Linux 下的您的 (~) 主目录)下的libraries中,将您解压缩并重命名的文件夹复制到此处。
您可以在此处下载 ESPAsyncTCP 库。与之前类似,解压缩下载的内容并提取文件夹。将文件夹重命名为 ESPAsyncTCP,然后将其复制到上面的libraries文件夹中。
ESP8266 文件系统上传工具可以此处下载。解压缩 ESP8266FS 文件夹,然后将其放入应用程序目录下的tools文件夹中。您的应用程序目录将在 Windows 下的Program Files (x86) 某个位置,或在 Linux 下的 (~) 主目录下。您需要重新启动 Arduino IDE。
概念化这个混乱的局面
问题
我们有一个水泵,它位于森林中,从一条小溪抽水。水泵有几种状态,例如“正在抽水”、“请求供水”或“准备就绪”。有 WiFi 中继器可以将信号延伸到水泵。我们希望这些状态能近乎实时地广播,或者能够被其他网络设备访问,这样我们就无需前往水泵房获取水泵的状态。我们不希望进行任何配置,除非绝对必要——我们希望它尽可能无需配置。
计划
我们希望这个设备没有用户界面——没有按钮,没有屏幕,什么都没有。它位于森林深处的泵房里。最多只能通过 USB 托管的 COM 端口输出。配置应该是完全自动的。
我们将使用 WiFi 每 quarter 秒发送一个包含水泵状态的单字节组播 UDP 数据包。我们将在另一端运行软件进行转发。我们将使用 ATmega2560 CPU 处理与水泵的 I/O,而 WiFi 模块的 XDS 160Micro CPU 将处理其他所有事务。两个 CPU 将通过板上的第 4 个串行 UART 进行通信。
UDP WiFi 数据包将由运行在该网络上的任何计算机上的控制台应用程序或 Windows GUI 应用程序接收。这些应用程序各自以自己的方式报告状态,前者通过将其写入控制台,后者通过在系统托盘中放置一个图标。
此外,该设备还将公开一个 HTTP 服务器,无需安装软件即可用于监控状态。此外,暴露的站点将允许您更改其使用的 SSID 和网络密码,以防您因即将更改路由器上的设置而需要这样做。该设备会将 SSID 和密码存储在非易失性 Flash 存储中。
为了方便查找 HTTP 服务器,我们将在一个临时域 pump.local 下发布该设备,以便可以通过http://pump.local访问。请注意,服务器不支持 HTTPS。我们将使用存储在 Flash 内存中的简单模板页面来提供内容。
该域将使用多播 DNS (mDNS) 发布,这样客户端计算机就不需要知道 IP 地址。
最重要的是,我们将支持 WPS,因此您需要做的就是提供电源,然后按下路由器的 WPS 按钮即可让设备工作。
异步性
ESP8266 的大多数 Web 服务器库都不是异步的,但由于我们需要在等待 HTTP 请求的同时发送 UDP 数据包,因此我们必须异步等待。这就是我们之前安装该库的原因。使用它,我们可以设置页面处理程序,然后在 loop()
函数中处理我们的 UDP 事宜。
水泵 I/O
我们使用 Arduino 板上的几个数字 I/O 引脚来检查水泵状态。要访问数字 I/O 引脚,我们必须从 ATmega2560 CPU 进行操作,但我们的主代码运行在另一个 CPU 上!这时串行端口就派上用场了。由于两个 CPU 通过串行连接,我们有一个简单的串行协议,它会查找来自串行端口的字节 0xFF/255,然后作为响应将水泵状态写回串行端口。为了便于调试和状态消息,其他所有内容都会被简单地转发到通过 USB 暴露的主串行端口。
内部存储
我们使用一些额外的 Flash 内存来存储 HTML 模板以及 SSID 和网络密码设置。SPIFFS 文件系统和库提供了读写我们启用所有这些功能所需文件的工具。
配置
我们不希望用户进行大量配置设置,因此您只需要 WPS。第一次使用设备时,您必须按下路由器的 WPS 按钮。一旦连接到网络,您可以通过http://pump.local/settings更改设置。我们不希望管理网络上的 IP 地址,因此设备会默认使用 DHCP。此外,它通过多播 DNS (mDNS) 暴露一个域,以便可以通过已知名称 (http://pump.local) 访问嵌入式 Web 服务器。为了让设备通过 UDP 推送状态,我们会进行组播以触达网络上正在监听的任何人,而不是特定的计算机。
灵活性
该软件包括两个客户端应用程序来访问水泵状态。一个是 Windows 应用程序,它驻留在系统托盘中;另一个是控制台应用程序,它进行日志记录,并且只要安装了 Mono,就可以在 Linux 和 Windows 上运行。当然,也可以通过 HTTP 访问状态。
编写这个混乱的程序
这里的内容很多,所以我将把它分成几部分。
ATmega2560
要刷写此芯片,您必须将板载 DIP 开关组设置为 1-4 位开启,5-8 位关闭。您还必须在工具下的开发板设置中选择“Arduino Mega 或 Mega 2560”。
此 CPU 的唯一职责是转发来自端口 4 到端口 1 的所有串行通信,除非收到值为 0xFF/255 的字节。如果收到,则是一个特殊情况,将查询水泵状态引脚,并相应地通过串行端口返回状态字节。此代码非常简单。它主要遍历 I/O 引脚 2-4 并检查它们是否为高电平。请注意,我们先检查 3 再检查 2。这些引脚的优先级被故意颠倒,因为这是现有水泵逻辑的工作方式。
void setup() {
// initialize the pins
pinMode(2,INPUT);
pinMode(3,INPUT);
pinMode(4,INPUT);
// initialize the serials
Serial.begin(115200);
Serial3.begin(115200);
}
void loop() {
// wait until there's data
while(0==Serial3.available());
// read a byte
int b = (byte)Serial3.read();
// if it's an escape return
// the status
if(255==b) { // read
if(digitalRead(2))
Serial3.write((byte)1);
else if(digitalRead(4))
Serial3.write((byte)3);
else if(digitalRead(3))
Serial3.write((byte)2);
else
Serial3.write((byte)0);
}
else // otherwise just forward
Serial.write((byte)b);
return;
}
ESP8266 (带 XDS 160Micro CPU)
要刷写此芯片,您必须将板载 DIP 开关组设置为 1-4 位关闭,5-7 位开启,8 位关闭。您还必须在 Arduino IDE 的工具下的开发板设置中选择“Generic ESP82666 Module”。
这段代码要复杂得多,因为它负责这个设备的全部魔力。它运行 Web 服务器,公开一个域,处理文件系统以服务文件和存储设置,并与另一个 CPU 通信以获取水泵信息。
我应该指出,我将这段代码改编自一些使用 ESPAsyncWebServer 的示例代码。该代码由 Rui Santos 创建并拥有版权。版权声明在下面的 .ino 文件中。应该注意的是,当前代码与原始代码几乎没有相似之处。我不得不修改它几乎所有的方面,但它仍然为我提供了一个使用此 Web 服务器库的良好起点。
这里有很多代码。在设置中,我们做了几件事
- 挂载 SPIFFS 文件系统/初始化库。
- 读取 settings 文件中的 SSID 和网络密码,每个文件占一行。
- 初始化并连接到 WiFi - 如果无法连接,则定期轮询 WPS。
- 将大量状态转储到主串行端口(通过 ATmega2560 转发)。
- 将设备主机名 (pipe.local) 发布到多播 DNS (mDNS)。
- 初始化 UDP。
- 为站点上的页面设置 HTTP 请求处理程序。
- 启动 HTTP 服务器。
下面我将介绍其中一个处理程序。特别是,它是处理 POST /settings 的处理程序。
这实际上会打开 settings 文件并根据发布的值重写它。重写文件的操作会更新内部 Flash 内存,这样在设备断电后仍然保留。完成此操作后,它会断开连接。下次调用 loop()
时,它将尝试使用新凭据重新连接。不幸的是,如果凭据无效,此时唯一的修复方法是上传新的设置文件到设备。这意味着需要切换 DIP 开关,并重新刷写文件,而我们还没有介绍这些。
/*
Rui Santos
Complete project details at https://RandomNerdTutorials.com
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
*/
// modified extensively by honey the codewitch
// Import required libraries
#include <ESP8266WiFi.h>
#include <ESPAsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <ESP8266mDNS.h>
#include <WiFiUdp.h>
#include <FS.h>
#ifndef STASSID
// BEGIN configuration properties
#define STASSID "ssid"
#define STAPSK "password"
#define STAHOSTNAME "pump"
#define STAMULTICASTIP IPAddress(239, 0, 0, 10)
#define STAPORT 11000
// END configuration properties
#endif
String process(const String& str);
void saveWiFiConfig(const String& newssid, const String& newpassword);
const char* ssid = STASSID;
const char* password = STAPSK;
char cfgssid[256];
char cfgpassword[256];
WiFiUDP Udp;
// Create AsyncWebServer object on port 80
AsyncWebServer server(80);
void setup() {
// Serial port for debugging purposes
Serial.begin(115200);
// Initialize SPIFFS
if (!SPIFFS.begin()) {
Serial.println("An Error has occurred while mounting SPIFFS");
return;
}
//
// read the settings
//
File file = SPIFFS.open("/settings", "r");
if (!file) {
Serial.println("Failed to open settings file for reading");
return;
}
if (file.available()) {
int l = file.readBytesUntil('\n', cfgssid, 255);
cfgssid[l] = 0;
ssid = cfgssid;
Serial.print("Read SSID: ");
Serial.println(ssid);
if (file.available()) {
l = file.readBytesUntil('\n', cfgpassword, 255);
cfgpassword[l] = 0;
password = cfgpassword;
Serial.println("Read Password: <omitted>");
}
}
//
// initialize the WiFi and connect
//
WiFi.mode(WIFI_STA);
bool done = false;
while (!done) {
// Connect to Wi-Fi
WiFi.begin(ssid, password);
Serial.print("Connecting to WiFi.");
// try this for 10 seconds, then check for WPS
for (int i = 0; i < 20 && WL_CONNECTED != WiFi.status(); ++i) {
Serial.print(".");
delay(500);
}
Serial.println("");
// If we're not connected, wait for a WPS signal
if (WL_CONNECTED != WiFi.status()) {
Serial.println("Connection failed. Entering auto-config mode");
Serial.println("Press the WPS button on your router");
bool ret = WiFi.beginWPSConfig();
if (ret) {
String newSSID = WiFi.SSID();
if (0 < newSSID.length()) {
Serial.println("Auto-configuration successful. Saving.");
saveWiFiConfig(newSSID, WiFi.psk());
strcpy(cfgssid,newSSID.c_str());
strcpy(cfgpassword,WiFi.psk().c_str());
Serial.println("Restarting...");
ESP.restart();
} else {
ret = false;
}
}
} else
done = true;
// if we didn't get connected, loop
}
Serial.println("");
Serial.print("Connected to ");
Serial.println(ssid);
Serial.print("Host name: ");
Serial.print(STAHOSTNAME);
Serial.println(".local");
Serial.print("IP address: ");
Serial.println(WiFi.localIP());
//
// start the multicast DNS publishing
//
if (MDNS.begin(STAHOSTNAME)) {
Serial.println("MDNS responder started");
}
//
// initialize the UDP
//
Udp.begin(STAPORT);
//
// create the HTTP handlers
//
// respond to GET requests on URL /
server.on("/", HTTP_GET, [](AsyncWebServerRequest * request) {
Serial.print("Processing request...");
request->send(SPIFFS, "/index.html", String(), false, process);
Serial.println("Done!");
});
// respond to GET requests on URL /settings
server.on("/settings", HTTP_GET, [](AsyncWebServerRequest * request) {
Serial.print("Processing request...");
request->send(SPIFFS, "/settings.html", String(), false, process);
Serial.println("Done!");
});
// respond to POST requests on URL /settings
server.on("/settings", HTTP_POST, [](AsyncWebServerRequest * request) {
// here we get the ssid and password from the args
const char* newssid = request->arg("ssid").c_str();
const char* newpassword = request->arg("password").c_str();
saveWiFiConfig(request->arg("ssid"), request->arg("password"));
// update the ssid and password
// with the new ones
strcpy(cfgssid, newssid);
Serial.print("SSID: ");
Serial.println(cfgssid);
strcpy(cfgpassword, newpassword);
Serial.print("Password: ");
Serial.println(cfgpassword);
ssid = cfgssid;
password = cfgpassword;
// now disconnect so
// we can reconnect
// with the new
// credentials
WiFi.disconnect();
// turns out unless we do this
// it won't ever reconnect
ESP.restart();
});
//
// start the www server
//
server.begin();
Serial.println("Web server started.");
}
在 loop()
方法中,我们做了一些事情,但不如 setup()
那么复杂。
- 如果已断开连接,请尝试重新连接。
- 向串行端口写入一个转义字符 (0xFF/255),然后等待返回一个状态字节。
- 将状态字节直接读入数据包缓冲区,然后将数据包发送到组播组 IP。
- 更新 mDNS 响应器。
- 延迟 quarter 秒。
void loop() {
// reconnect to the WiFi if we
// got disconnected
if (WL_CONNECTED != WiFi.status()) {
// Connect to Wi-Fi
WiFi.begin(ssid, password);
Serial.print("Connecting to WiFi");
while (WiFi.status() != WL_CONNECTED) {
Serial.print(".");
delay(500);
}
}
// here we use the serial escape 0xFF/255
// to request the status of the pump from
// the ATmega2560
// next we build a UDP multicast packet
// from that, with a lone status byte
// for a payload.
char ba[1];
Serial.write(255);
while (!Serial.available());
ba[0] = (byte)Serial.read();
Udp.beginPacketMulticast(STAMULTICASTIP, STAPORT, WiFi.localIP());
Udp.write(ba, 1);
Udp.endPacket();
// update the DNS information
MDNS.update();
// we only want to do this every
// quarter second
delay(250);
}
现在我们来看用于处理页面模板的方法。如果它在文件中看到 %PUMP_STATUS%
,它将向串行端口发送一个转义字符 (0xFF/255) 并读取一个状态字节回来。根据该值,我们将其转换为一个友好的名称并返回。否则,我们将根据请求返回 SSID 或网络密码。对于其他任何内容,我们返回一个空字符串。
// this method replaces %TEMPLATE%
// values in an otherwise static
// file. There are a few different
// aliases
String process(const String& str) {
if (str == "PUMP_STATUS") {
Serial.write(255);
while (!Serial.available());
byte s = (byte)Serial.read();
char* sz;
switch (s)
{
case 0:
sz = "No power";
break;
case 1:
sz = "Ready";
break;
case 2:
sz = "Requesting water";
break;
case 3:
sz = "Pumping";
break;
default:
sz = "Error";
break;
}
return String(sz);
} else if(str=="SSID")
return String(ssid);
else if(str=="PASSWORD")
return String(password);
return String();
}
说到模板,我们应该看一下用于渲染内容的模板。我们将从主着陆页/状态页开始。这很简单。您可以看到我们覆盖的 %PUMP_STATUS%
别名,并且您可能已经注意到我们告诉页面每秒刷新一次。这不是理想的。理想情况下,我们会使用像 jsonp 这样的东西,并且只刷新页面中的一个元素,但考虑到这是在我特定场景中最不常用的访问设备的方式,这样做并不值得付出努力。我们需要持续更新才能在所有情况下都可用。自动刷新可以完成工作,尽管有些笨拙。
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
<meta http-equiv="refresh" content="1" />
<title>Pump Monitor</title>
</head>
<body>
<p>Status: %PUMP_STATUS%</p>
</body>
</html>
接下来是设置页面模板。这个页面没有安全措施。安全措施在于房子所在的 20 英亩森林。如果有人真的想坐在那里和熊一起尝试破解 WPA2,他们坦率地说需要找点事做。如果您的处境不同,您可以设置密码并使其可以更改。我没有在这个项目中费心,因为它只会惹恼客户(我的岳父母)。
<!DOCTYPE html>
<html>
<head>
<title>WiFi Settings</title>
</head>
<body>
<form action="" method="POST">
<p>SSID:</p><input name="ssid" type="text" value="%SSID%" /><br />
<p>Password:</p><input name="password"
type="password" value="%PASSWORD%" /><br />
<input type="submit" value="Save" />
</form>
</body>
</html>
您现在可能会想如何将这些文件放到设备上。您需要做的就是在 pumpMonitorEsp sketch 文件夹下创建一个 data 文件夹,然后将 index.html、settings.html 和 settings 放在里面。然后您需要进入工具并选择ESP Sketch Data Upload。在执行此操作之前,请确保您已在 settings 文件中输入了您的 SSID 和密码!另外,请记住按照前面的说明设置您的 ESP8266 编程。如果串行监视器已打开,它会干扰上传,因此请关闭它。
客户端软件
Pumpmon CLI 工具
pumpmon 工具会持续报告发生的水泵状态变化。每次更改都有一个适合日志记录的时间戳。尽管每 quarter 秒就会发生一次状态更改,但它只期望每秒接收一个数据包。我发现这是避免状态报告中断的最佳比例。每次收到状态数据包时,都会设置一个一秒计时器。如果该计时器触发,状态将更改为“正在连接”。这实际上充当了超时。C# 代码相对简短。
const int TimeoutMS = 1000;
const int Port = 11000;
public static void Main(string[] args)
{
Console.Error.WriteLine("Press any key to exit.");
var dt = DateTime.Now;
// build a timestamp
var ts = "[" + dt.ToShortDateString() + " " + dt.ToShortTimeString() + "] ";
// report the initial status as connecting
Console.WriteLine(ts + "Connecting");
// we're going to do this in the background:
ThreadPool.QueueUserWorkItem((state) => {
var status = -1; // connecting
UdpClient uc = new UdpClient(Port);
// we need to join to make sure we recieve the packets
uc.JoinMulticastGroup(new IPAddress(new byte[] { 239, 0, 0, 10 }));
// this timer routine gets called if a packet hasn't
// been seen for at least one second:
var timer = new Timer((state2) => {
if (-1 != status)
{
dt = DateTime.Now;
// build a timestamp
ts = "[" + dt.ToShortDateString() + " " + dt.ToShortTimeString() + "] ";
Console.WriteLine(ts + "Connecting");
}
status = -1;
}, null, TimeoutMS, Timeout.Infinite);
// keep the thread alive and looking for packets:
while (true)
{
var remoteEP = new IPEndPoint(IPAddress.Any, Port);
var data = uc.Receive(ref remoteEP);
dt = DateTime.Now;
// build a timestamp
ts = "[" + dt.ToShortDateString() + " " + dt.ToShortTimeString() + "] ";
// translate the status code to text
// and reset the timer:
if (1 == data.Length)
{
switch (data[0])
{
case 0:
timer.Change(TimeoutMS, Timeout.Infinite);
if (0 != status)
Console.WriteLine(ts + "No power");
status = 0;
break;
case 1:
timer.Change(TimeoutMS, Timeout.Infinite);
if (1 != status)
Console.WriteLine(ts + "Ready");
status = 1;
break;
case 2:
timer.Change(TimeoutMS, Timeout.Infinite);
if (2 != status)
Console.WriteLine(ts + "Requesting water");
status = 2;
break;
case 3:
timer.Change(TimeoutMS, Timeout.Infinite);
if (3 != status)
Console.WriteLine(ts + "Pumping");
status = 3;
break;
}
}
}
});
// wait for a keypress to exit
Console.ReadKey();
}
PumpMonitor Windows 应用程序
此应用程序驻留在 Windows 系统托盘中,并根据水泵的状态显示不同的彩色图标。它也像 pumpmon 一样进行日志记录。事实上,除了 GUI 粘合之外,该应用程序几乎与 pumpmon 相同,所以我在这里不再赘述。唯一奇怪之处在于动态创建图标,这些图标是根据即时绘制的位图图像创建的。我这样做的原因是我岳父是色盲,我不想冒着制作他看不清的图标的风险,所以我制作了他签名的彩色圆圈,仅此而已。
历史
- 2020 年 11 月 4 日 - 初始提交
- 2020 年 11 月 5 日 - 添加了 WPS 支持