1. 项目概述与核心思路
大家好,我是Marco,一个在意大利马雷马自然公园工作的环境向导。我的日常工作离不开奥姆布罗内河,这条河在夏季可能近乎干涸,但到了秋冬季节,流量会激增至每小时数千立方米,这对我们组织的独木舟观光活动构成了不小的挑战。为了能更主动地应对水位变化,而不是被动等待官方通知,我决定自己动手,利用手头的ESP32开发板,打造一个实时水位监测与预警系统。
这个项目的核心目标很明确:自动、实时地从托斯卡纳大区优秀的水文信息服务网站抓取特定水文站的水位数据,并将这些数据直观地展示出来,甚至在出现异常时主动通知我。听起来像是需要一台电脑和复杂的软件?其实不然,我用的只是一块比信用卡还小的ESP32开发板。它内置了Wi-Fi,性能也足够强大,完全可以独立完成从网络请求、数据解析到本地显示和逻辑控制的全套工作。这不仅仅是把网页数据“拿”下来那么简单,而是要让一个嵌入式设备理解网页内容,并基于数据做出“智能”反应。下面,我就来详细拆解我是如何一步步实现这个“ESP32网页数据抓取与实时水位监测系统”的。
2. 系统架构设计与硬件选型
在动手写代码之前,理清整个系统的架构和选择合适的硬件是成功的第一步。我的设计思路是让系统形成一个从“云端”到“本地”再到“执行”的完整闭环。
2.1 整体工作流程
整个系统的工作流程可以概括为以下几个步骤,它们会在ESP32中循环执行:
- 网络连接与数据抓取:ESP32通过Wi-Fi连接到互联网,并向目标水文数据服务网页发起HTTP请求,获取完整的网页HTML源代码。
- 数据解析与提取:在ESP32内部,运行一段解析程序,从庞大的HTML代码中精准定位并提取出我们关心的水位数值。这是整个项目的技术核心。
- 数据处理与映射:将提取出的原始字符串数据转换为浮点数,并根据预设的阈值范围,将其映射为控制LED亮度或闪烁频率的参数,以及LCD屏幕上显示的友好文本。
- 多通道输出与预警:处理后的数据会同时驱动多个输出设备:
- LCD显示屏:实时显示水位站名称、当前水位值、单位等文本信息。
- LED地图指示灯:在一块实体地图上,对应不同水文站的位置安装LED。水位越高,LED亮度越强;水位变化越快,LED闪烁频率越高。这是一种非常直观的“地理可视化”预警。
- 邮件预警:当水位超过设定的安全阈值时,ESP32会自动调用邮件发送功能,向我的邮箱发送一封包含详细水位信息的警报邮件。
2.2 硬件组件清单与选型理由
选择这些组件,主要基于成本、易用性和项目需求考量:
- 主控板:Wemos D1 R32 (基于ESP32)。我选择这款板子有几个原因:首先,它采用了Arduino UNO的经典引脚布局和母座排针,对于习惯使用Arduino生态的我来说,接线和使用扩展板非常方便。其次,ESP32本身双核处理器的强大性能,让我可以更从容地处理网络请求和数据解析这类任务,甚至可以考虑将网络任务和显示/控制任务分配到不同核心。最后,其内置的Wi-Fi模块是项目能成立的基础。
- 显示单元:I2C接口的16x2字符LCD屏。选择带I2C接口的版本至关重要。传统的1602 LCD需要连接至少6根线(数据线4根+控制线2根),而I2C版本只需要4根线(VCC, GND, SDA, SCL),通过一个转接板与ESP32通信,极大地节省了宝贵的IO口,也简化了布线。蓝色背光是我手头现有的,绿色背光在视觉上可能更舒适。
- 预警指示单元:5mm红色LED与定制地图。选择红色LED是因为它在警示场景中辨识度最高。核心创意在于将LED安装在代表奥姆布罗内河流域的实体地图上,每个LED对应一个上游的水文监测站。这样,我一眼就能看出是哪个区域的水位在上涨,实现了数据与地理位置的强关联。
- 辅助材料:软木板、热熔胶、导线等。软木板用来固定地图和所有电子元件,方便布局和修改。热熔胶则是快速固定的好帮手,虽然不够“专业”,但对于原型制作来说非常高效。
注意:在选购ESP32开发板时,需要注意其引脚定义。不同型号的ESP32板子(如NodeMCU、DevKitC、Wemos D1 R32)的GPIO编号可能对应不同的物理引脚。我的代码中引脚定义是基于Wemos D1 R32的,如果你使用其他板子,需要根据官方引脚图进行修改。
3. 核心代码实现:从网页抓取到数据解析
这是项目的“大脑”部分。让一个微控制器去理解网页内容,听起来复杂,但拆解后会发现逻辑非常清晰。
3.1 网络请求与原始数据获取
一切始于获取网页的原始数据。我使用了ESP32 Arduino核心库中内置的HTTPClient库,它让发起一个HTTP GET请求变得非常简单。
// 示例代码片段 (位于 http.ino 文件) #include <HTTPClient.h> #include <WiFi.h> String targetURL = “http://www.sir.toscana.it/.../dati_in_ tempo_reale.html“; // 目标水文数据网页 void fetchWebData() { if (WiFi.status() == WL_CONNECTED) { HTTPClient http; http.begin(targetURL); // 指定目标网址 int httpCode = http.GET(); // 发起GET请求 if (httpCode == HTTP_CODE_OK) { // 请求成功 String payload = http.getString(); // 获取整个网页的HTML内容 // 此时,`payload` 变量中包含了网页的全部源代码,可能有几十KB processPayload(payload); // 调用函数处理这些原始数据 } else { Serial.printf(“HTTP请求失败,错误码: %d\n”, httpCode); } http.end(); // 释放资源 } }这里的关键点是http.getString(),它把服务器返回的所有内容(即我们在浏览器里“查看网页源代码”看到的东西)都存进了一个字符串变量payload中。这个字符串里包含了大量的HTML标签、JavaScript代码、样式表以及我们真正需要的数据。
3.2 数据清洗与关键信息定位
拿到的payload是未经处理的“矿石”,我们需要从中提炼出“金属”。首先需要进行一些初步清洗,去除干扰项。例如,网页中可能用 代表空格,用"包裹数据,这些在解析时会造成麻烦。
// 清洗数据:移除引号和HTML空格实体 payload.replace(“\””, “”); // 移除双引号 payload.replace(“ ”, “ “); // 将 替换为普通空格接下来是最关键的一步:定位。通过分析目标网页的源代码,我发现水文数据是以一种类似逗号分隔值(CSV)的格式嵌入在JavaScript变量或特定HTML结构中的。例如,可能有一段代码像这样:var stationData = [“TOS12345”, “Ombrone”, “1.75”, “m”, ...];。
我需要找到代表特定水文站(比如站码为TOS12345)的数据块。我使用indexOf()函数来搜索这个站码在payload字符串中的起始位置。
String stationCode = “TOS12345”; int ind = payload.indexOf(stationCode); // 在payload中搜索站码,返回其首次出现的位置索引 if (ind == -1) { Serial.println(“未找到指定水文站数据!”); return; } // 此时 `ind` 变量存储了站码字符串在 `payload` 中的起始位置3.3 精确解析:自定义字符串分割函数
找到数据块的起点后,我发现我需要的数据是该数据块中由逗号分隔的第N个值。Arduino的String类虽然有split函数,但在这里使用不够灵活。我从Stack Overflow借鉴并修改了一个高效的字符串分割函数getValue_ind。
这个函数的精妙之处在于,它可以从指定的起始位置ind开始,向后查找第N个分隔符(这里是逗号),并提取出两个分隔符之间的内容。
// 核心解析函数 (位于主 .ino 文件) String getValue_ind(String data, char separator, int index) { int found = 0; int strIndex[] = {0, -1}; int maxIndex = data.length() - 1; // 注意:循环从 `ind` 开始,而不是0,这是我们定位后的数据块起点 for (int i = ind; i <= maxIndex && found <= index; i++) { if (data.charAt(i) == separator || i == maxIndex) { found++; strIndex[0] = strIndex[1] + 1; // 子串开始位置 strIndex[1] = (i == maxIndex) ? i + 1 : i; // 子串结束位置 } } // 如果找到了足够的分隔符,就返回对应的子字符串 return found > index ? data.substring(strIndex[0], strIndex[1]) : “”; }在实际调用时,我这样使用它:
// 假设我知道水位值是当前数据块中的第8个字段(从0开始计数) String waterLevelStr = getValue_ind(payload, ‘,’, 8); // waterLevelStr 现在可能是 “1.75” float waterLevel = waterLevelStr.toFloat(); // 转换为浮点数用于计算通过ind(定位)和getValue_ind(精确提取)的组合,我成功地从混乱的HTML中“挖”出了干净的水位数据。这个过程就像是在一长串杂乱无章的句子中,先找到“第XX章”这个标题,然后数逗号,找到这一章的第N句话。
4. 数据处理与可视化输出策略
获取到原始的水位数值(例如1.75米)只是第一步。如何让这个数字变得有意义,并能驱动硬件做出反应,是接下来要解决的问题。这涉及到数据映射、多任务处理和驱动外设。
4.1 数据映射:从物理值到控制信号
从网站获取的水位值有一个正常的波动范围(比如0.5米到4米),但我需要用它来控制LED的亮度(PWM值范围0-255)和闪烁间隔(毫秒)。这时就需要用到map()函数。但标准的map()只处理整数,而水位是浮点数。
解决方案:自定义浮点数映射函数我写了一个简单的floatMap函数来解决这个问题。
float floatMap(float x, float in_min, float in_max, float out_min, float out_max) { // 将输入值x从输入范围[in_min, in_max]线性映射到输出范围[out_min, out_max] return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min; }应用实例:控制LED亮度假设水位正常范围是0.5米到3.0米,LED亮度从30(微亮)到255(最亮)。
float level = 1.75; // 获取到的水位值 float brightness = floatMap(level, 0.5, 3.0, 30.0, 255.0); // 确保亮度值在有效范围内 brightness = constrain(brightness, 0, 255); analogWrite(LED_PIN, (int)brightness); // 输出PWM信号控制LED这样,当水位从1米涨到2米时,LED的亮度会平滑地增加,提供连续的视觉反馈。
处理低水位值问题:当水位很低(接近0.5米)时,计算出的亮度可能只有10左右,LED几乎不亮,失去了指示意义。我在这里加入了一个“最小亮度”的约束,确保即使水位很低,LED也能发出可见光,表明设备在正常工作。
4.2 多任务处理:利用ESP32的双核优势
系统需要同时做几件事:定时抓取数据(每15分钟一次)、实时更新LCD显示、根据水位变化率控制LED闪烁、监听是否需要发送邮件。如果所有任务都在一个循环里顺序执行,可能会导致网络请求时显示卡顿。
ESP32拥有两个核心(Core 0 和 Core 1),这给了我们更多灵活性。Arduino框架默认运行在Core 1上。对于周期性、耗时的网络任务,我可以使用Task将其分配到另一个核心,或者使用定时器中断来触发。
在我的项目中,我采用了相对简单但有效的“时间片”和非阻塞设计:
- 主循环 (Core 1):快速执行LCD刷新、LED状态更新、检查邮件发送条件等轻量级任务。
- 网络任务:通过一个全局的时间戳变量来控制。每次主循环检查当前时间与上次抓取时间的差值,如果超过15分钟(900000毫秒),则执行一次
fetchWebData()和processPayload()。由于这些函数执行时(可能耗时几秒),loop()会被阻塞,所以我把抓取间隔设得比较长(15分钟),对水位监测来说这个频率足够了。 - LED闪烁:使用
millis()函数实现非阻塞的定时闪烁,而不是用delay()。这样可以确保在LED闪烁期间,其他任务(如刷新LCD)不会被挂起。
unsigned long previousDataFetchTime = 0; const long dataFetchInterval = 900000; // 15分钟,单位毫秒 void loop() { unsigned long currentMillis = millis(); // 检查是否到达抓取数据的时间 if (currentMillis - previousDataFetchTime >= dataFetchInterval) { previousDataFetchTime = currentMillis; fetchWebData(); // 这个函数会阻塞,但因为它每15分钟才执行一次,影响不大 } updateLCD(); // 非阻塞,快速更新显示 updateLEDs(); // 非阻塞,根据计算好的亮度和闪烁模式更新LED状态 checkAndSendAlert(); // 检查水位阈值,非阻塞 }4.3 驱动外设:LCD与LED
LCD显示:使用经典的LiquidCrystal_I2C库,初始化后,显示就变得非常简单。
#include <LiquidCrystal_I2C.h> LiquidCrystal_I2C lcd(0x27, 16, 2); // 地址0x27,16列2行 void setup() { lcd.init(); lcd.backlight(); } void updateLCD(float level, float changeRate) { lcd.clear(); lcd.setCursor(0,0); lcd.print(“Level: “); lcd.print(level, 2); // 显示两位小数 lcd.print(“ m”); lcd.setCursor(0,1); lcd.print(“Change: “); lcd.print(changeRate, 1); lcd.print(“ cm/h”); }LED控制:对于亮度控制,使用analogWrite(pin, brightness)。对于闪烁,则需要记录状态切换的时间。
int ledState = LOW; unsigned long previousBlinkMillis = 0; int blinkInterval = 1000; // 初始闪烁间隔1秒,会根据水位变化率调整 void updateLEDs() { unsigned long currentMillis = millis(); if (currentMillis - previousBlinkMillis >= blinkInterval) { previousBlinkMillis = currentMillis; ledState = !ledState; // 翻转状态 digitalWrite(LED_PIN, ledState ? HIGH : LOW); // 如果是亮度控制,这里用 analogWrite } // 根据新的水位变化率,动态计算新的 blinkInterval blinkInterval = calculateBlinkInterval(waterLevelChangeRate); }5. 邮件预警功能实现
当水位超过我设定的安全阈值时,仅仅本地灯光报警可能不够,我需要它能够远程通知我。ESP32可以通过SMTP协议发送邮件。我使用了ESP32MailClient库来实现这个功能。
实现步骤:
- 库与配置:首先在Arduino IDE中安装
ESP32MailClient库。然后需要准备邮箱的SMTP服务器信息(如smtp.gmail.com)、端口(465或587)、你的邮箱账号和密码(对于Gmail,可能需要使用“应用专用密码”而非普通密码)。 - 构建邮件内容:将水位数据、时间、站点信息等组合成HTML格式的邮件正文,这样邮件看起来更美观。
- 触发发送:在主循环中检查水位值,一旦超过阈值,且避免短时间内重复发送,就触发邮件发送函数。
#include <ESP32MailClient.h> SMTPData smtpData; void sendAlertEmail(float currentLevel) { smtpData.setLogin(“smtp.gmail.com”, 465, “your_email@gmail.com”, “your_app_specific_password”); smtpData.setSender(“ESP32 Alert”, “your_email@gmail.com”); smtpData.setPriority(“High”); smtpData.setSubject(“⚠️ 奥姆布罗内河水位警报!”); // 构建HTML邮件正文 String htmlMsg = “<h2>水位监测系统警报</h2>”; htmlMsg += “<p><strong>水文站:</strong> TOS12345 (Ombrone上游)</p>”; htmlMsg += “<p><strong>当前水位:</strong> <span style=‘color:red;’>”; htmlMsg += String(currentLevel, 2); htmlMsg += “ 米</span></p>”; htmlMsg += “<p><strong>时间:</strong>” + getDateTimeString() + “</p>”; // 需要实现一个获取时间的函数 htmlMsg += “<hr><p>请及时关注并采取必要措施。</p>”; smtpData.setMessage(htmlMsg, true); // true 表示内容是HTML格式 smtpData.addRecipient(“your_recipient@email.com”); if (!MailClient.sendMail(smtpData)) { Serial.println(“邮件发送失败: “ + MailClient.smtpErrorReason()); } else { Serial.println(“警报邮件已发送!”); } }重要安全提示:切勿将真实的邮箱密码硬编码在代码中!尤其是在分享代码时。建议使用外部配置文件(但需注意安全),或使用更安全的认证方式(如OAuth2,但实现较复杂)。对于个人项目,使用“应用专用密码”并定期更换是一个折中的办法。
6. 实体地图制作与系统集成
硬件部分除了电路连接,最大的亮点就是那张实体地图。它的制作让整个项目从抽象的“数据监控”变成了具象的“地理态势感知”。
6.1 地图获取与处理
- 获取矢量地图数据:我使用了 BBBike.org 这个出色的服务。它允许你框选地球上任意区域,然后导出该区域的OpenStreetMap数据为多种矢量格式(如Shapefile)。我导出了奥姆布罗内河流域的地图。
- 数据处理与简化:将导出的Shapefile导入免费的GIS软件QGIS。在QGIS中,我可以过滤掉不需要的图层(如建筑、地名),只保留河流、水文站位置等关键要素。通过“属性表”可以查看和编辑每个要素的数据。为了激光切割或手工制作的方便,我进一步简化了河流的线条。
- 地图导出与美化:在QGIS的“打印布局”中,我将处理好的地图布局在一张A3大小的页面上,然后导出为SVG格式。SVG是矢量图形,可以无限放大而不失真。我将SVG文件导入Adobe Illustrator(也可用免费软件如Inkscape)进行最后的美化:调整线条粗细、颜色,添加图例,并最重要的是,在每一个水文站的确切位置上做好标记点,这个点就是后面要钻孔安装LED的位置。
- 打印与制作:将最终的地图文件用质量较好的纸张打印出来。在粘贴到软木板上之前,我使用刻刀或钻孔工具,在所有标记的水文站位置预先打好孔,孔径略小于5mm LED的直径,以便LED能紧密卡住。
6.2 电路组装与布局
电路连接本身并不复杂,遵循“电源正极 -> 限流电阻 -> LED阳极 -> LED阴极 -> ESP32 GPIO引脚”的基本规则。我使用了220欧姆的电阻为每个LED限流。
引脚连接参考(基于Wemos D1 R32):
- LCD I2C:SDA -> GPIO21, SCL -> GPIO22, VCC -> 5V/3.3V, GND -> GND。
- LED 1-6:分别连接到代码中定义的GPIO引脚(如14, 27, 16, 17, 25, 26),每个LED串联一个220Ω电阻后接至3.3V电源。
- 电源:为整个系统提供一个稳定的5V电源(可通过USB或外部电源模块),ESP32的Vin引脚接受5V输入,其板载稳压器会为自身和外围设备提供3.3V。
布局上,我将ESP32开发板、LCD屏和线路都固定在软木板的背面,保持正面地图的整洁。LED则从背面穿过预先打好的孔,使其灯头刚好露在地图正面相应水文站的位置上。所有连接用热熔胶固定,防止松动。虽然焊接和布线看起来有些凌乱(我自嘲为“鸽子级焊接水平”),但只要电气连接正确、牢固,功能正常就是成功的。
7. 项目调试、优化与常见问题
在实际制作和编码过程中,我遇到了不少坑,也总结出一些优化经验。
7.1 调试技巧与串口监控
串口调试是生命线:在代码的关键节点添加Serial.print()语句,输出变量值、函数执行状态和错误信息。这对于调试网络连接、数据解析过程至关重要。
void fetchWebData() { Serial.println(“[INFO] 开始获取网页数据...”); HTTPClient http; http.begin(targetURL); int httpCode = http.GET(); Serial.printf(“[INFO] HTTP状态码: %d\n”, httpCode); if (httpCode == HTTP_CODE_OK) { String payload = http.getString(); Serial.println(“[INFO] 数据获取成功,长度: “ + String(payload.length())); // 可以打印前500个字符看看结构 // Serial.println(payload.substring(0, 500)); processPayload(payload); } else { Serial.println(“[ERROR] 数据获取失败!”); } http.end(); }7.2 常见问题与解决方案
问题:Wi-Fi连接不稳定,经常断线。
- 排查:检查Wi-Fi信号强度(
WiFi.RSSI()),确保ESP32距离路由器不是太远或有太多阻隔。 - 解决:在代码中加入重连机制。在
loop()开始时检查WiFi.status(),如果断开,则尝试重新连接。增加延迟和重试次数。
void checkWiFi() { if (WiFi.status() != WL_CONNECTED) { Serial.println(“WiFi断开,尝试重连...”); WiFi.disconnect(); WiFi.reconnect(); delay(5000); // 等待5秒 } }- 排查:检查Wi-Fi信号强度(
问题:解析数据时,
getValue_ind函数返回空字符串。- 排查:首先,检查
ind的值是否大于-1,确保找到了站码。其次,检查你指定的分隔符索引(第几个逗号)是否正确。网页结构可能发生变化。 - 解决:将
payload中从ind开始的一段内容(比如500个字符)打印到串口,人工检查其结构,确认分隔符和所需数据的位置。编写更健壮的代码,比如如果解析失败,则使用上一次的成功数据。
- 排查:首先,检查
问题:程序运行一段时间后崩溃或重启。
- 排查:可能是内存泄漏或堆栈溢出。ESP32虽然内存不小,但频繁的字符串操作(尤其是
String类)容易产生内存碎片。 - 解决:
- 尽量使用C风格的字符数组(
char[])和标准库函数(如strstr(),strtok())来处理字符串,它们更节省内存。 - 减少全局
String变量的使用,在函数内部使用局部变量,并在函数结束时及时释放。 - 使用
ESP.getFreeHeap()监控剩余内存,排查内存下降点。 - 检查是否在中断服务程序(ISR)中执行了耗时的操作或调用了不安全的函数。
- 尽量使用C风格的字符数组(
- 排查:可能是内存泄漏或堆栈溢出。ESP32虽然内存不小,但频繁的字符串操作(尤其是
问题:LED亮度变化不线性,低水位时太暗。
- 解决:这就是我之前提到的“低值拉伸”问题。不要直接使用
map后的值,可以加一个判断或使用非线性映射函数(如指数曲线)来提升低值区的亮度。
float adjustedBrightness = brightness; if (brightness < 50) { adjustedBrightness = 50; // 设置一个最小亮度 } // 或者使用简单的指数调整(示例) // adjustedBrightness = pow(brightness / 255.0, 0.6) * 255.0;- 解决:这就是我之前提到的“低值拉伸”问题。不要直接使用
7.3 代码优化与分区方案
我的代码最初将所有功能放在一个.ino文件里,非常臃肿。后来我利用了Arduino IDE的“标签页”功能,将不同功能的代码分到不同的.ino文件中(如http.ino,leds.ino,mail.ino),这极大地提高了代码的可读性和可维护性。
另外,在向ESP32上传代码时,我选择了“Partition Scheme: No OTA”。这是因为我的代码和库文件体积较大,选择这个分区方案可以最大化可用的程序存储空间,避免编译时出现“空间不足”的错误。
8. 项目总结与扩展思考
回顾这个项目,它成功地将一个看似需要PC完成的任务——网页数据抓取与解析——移植到了一块小小的微控制器上。核心的挑战在于如何让资源受限的嵌入式设备高效、准确地处理非结构化的网络数据。我采用的“定位关键词+计数分割符”的解析方法,虽然依赖于目标网页特定的数据格式,但思路具有通用性。只要目标数据在网页源码中有规律可循,就可以通过调整搜索关键词和分隔符索引来适配。
这个项目的价值远不止于监测水位。它提供了一个通用的ESP32网络数据抓取与处理框架。你可以很容易地修改它来监控其他公开数据,例如:
- 天气信息:从气象网站抓取温度、湿度、降雨概率。
- 公共交通:获取下一班公交或火车的到达时间。
- 金融市场:追踪股票或加密货币的实时价格。
- 能源消耗:从智能电表的公开API获取实时用电数据。
在硬件层面,也可以进行扩展:
- 增加传感器:接入本地传感器(如温湿度传感器DHT22、水位传感器),实现网络数据与本地数据的融合分析。
- 更换显示方式:使用OLED显示屏或TFT彩屏来显示更丰富的信息和图表。
- 云端集成:将处理后的数据通过MQTT协议发送到Home Assistant、Node-RED或云平台(如Blynk、ThingsBoard),实现更复杂的逻辑和远程控制。
- 降低功耗:如果使用电池供电,可以启用ESP32的深度睡眠模式,仅在需要抓取数据时唤醒,极大延长续航时间。
最后,我想分享一点最深的体会:在嵌入式项目中,“足够好”往往比“完美”更重要。我的代码可能不是最优的,我的焊接也很粗糙,地图是用热熔胶粘的。但这个系统已经稳定运行了相当长的时间,可靠地为我提供着水位预警。它完美地解决了我的实际问题。不要因为担心自己不是专家或代码不够优雅而不敢动手。从点亮第一个LED开始,从成功连接一次Wi-Fi开始,每一步的突破都会带来巨大的成就感。这个项目就是我作为爱好者学习之旅的见证,希望它也能给你带来启发和动手的勇气。