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

WiFi 远程水泵监控器

starIconstarIconstarIconstarIconstarIcon

5.00/5 (10投票s)

2020年11月5日

MIT

13分钟阅读

viewsIcon

14025

downloadIcon

280

探索一个基于 Arduino 的物联网 Web 服务器和 UDP 组播器,用于监控远程水泵

引言

免责声明:此过程将覆盖此板载 WiFi 模块的出厂固件。一旦覆盖,将很难(但并非不可能)恢复到出厂状态,并且用于与该模块通信的默认库“WiFiEsp”将不再与其兼容。我还听说,但未经证实,一些廉价制造的 ESP-01s 刷写次数非常有限,因此存在一定风险,但我认为这不一定适用于这些 Arduino 及衍生板。

pumpmon

水泵监控器是一个宏大的项目,探索了 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 服务器库的良好起点。

这里有很多代码。在设置中,我们做了几件事

  1. 挂载 SPIFFS 文件系统/初始化库。
  2. 读取 settings 文件中的 SSID 和网络密码,每个文件占一行。
  3. 初始化并连接到 WiFi - 如果无法连接,则定期轮询 WPS。
  4. 将大量状态转储到主串行端口(通过 ATmega2560 转发)。
  5. 将设备主机名 (pipe.local) 发布到多播 DNS (mDNS)。
  6. 初始化 UDP。
  7. 为站点上的页面设置 HTTP 请求处理程序。
  8. 启动 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() 那么复杂。

  1. 如果已断开连接,请尝试重新连接。
  2. 向串行端口写入一个转义字符 (0xFF/255),然后等待返回一个状态字节。
  3. 将状态字节直接读入数据包缓冲区,然后将数据包发送到组播组 IP。
  4. 更新 mDNS 响应器。
  5. 延迟 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:&nbsp;%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.htmlsettings.htmlsettings 放在里面。然后您需要进入工具并选择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 支持
© . All rights reserved.