1. 项目缘起:为什么我们需要一个便携式空气质量监测仪?
空气质量,这个词听起来有点宏大,但落到我们每个人的日常生活中,其实就是你每天呼吸的那一口空气。我住在城市里,平时通勤、周末去公园,甚至在家里开窗通风,都免不了会想:现在的空气到底怎么样?PM2.5高不高?有没有什么有害气体?市面上的专业监测设备要么太贵,要么体积庞大不便携,要么数据封闭不透明。作为一个喜欢动手折腾的硬件爱好者,我萌生了自己做一个的想法。
这个项目的核心目标很明确:打造一个完全开源、成本可控、便于携带且能实时查看数据的个人空气质量监测仪。我选择了Beetle ESP32C3作为主控,因为它集成了Wi-Fi和蓝牙,尺寸极小,功耗控制得也不错,非常适合嵌入式物联网项目。外壳则通过3D打印实现,可以自由设计造型,兼顾美观和功能性。监测的指标我聚焦在最常见、也最关乎健康的几项:PM2.5/PM10颗粒物浓度、温湿度、以及总挥发性有机物(TVOC)和二氧化碳(CO2)当量浓度。这样一个小设备,可以放在书桌、床头、车内,或者随身携带,通过手机网页就能随时查看数据,甚至还能设置阈值告警。
对于硬件爱好者来说,这是一个绝佳的练手项目,涵盖了单片机开发、传感器应用、3D建模与打印、物联网通信和简单的Web界面开发。对于普通用户,它提供了一个透明、可信的数据来源,帮助你更科学地管理生活环境。接下来,我就把从硬件选型、电路设计、固件开发到外壳制作的完整过程,以及我踩过的那些坑,毫无保留地分享出来。
2. 核心硬件选型与设计思路
做一个项目,硬件是骨架。选型不是拍脑袋,每个元器件的选择背后都有其权衡和理由。我的核心思路是:在满足功能、精度和稳定性的前提下,尽可能追求小型化、低功耗和易用性。
2.1 主控芯片:为什么是Beetle ESP32C3?
主控的选择决定了项目的可能性。市面上常见的ESP32开发板很多,比如NodeMCU、Wemos D1 mini等。我最终选择DFRobot的Beetle ESP32C3,主要基于以下几点考量:
- 极致紧凑的尺寸:它的核心板尺寸只有25*20.5mm,比一枚硬币还小。这对于追求便携性的设备来说是决定性优势,为后续设计小巧的外壳提供了基础。
- 合理的性能与功耗平衡:ESP32-C3芯片基于RISC-V架构,主频160MHz,内存400KB SRAM,性能足以流畅运行传感器数据采集、Wi-Fi连接和Web服务。同时,它支持深度睡眠模式,在电池供电场景下至关重要。
- 丰富的通信接口:板载Wi-Fi 4和蓝牙5.0,满足数据无线传输的所有需求。特别是Wi-Fi,可以让我们直接通过浏览器访问设备,无需额外APP。
- 良好的开发环境:完美支持Arduino IDE和ESP-IDF,社区资源丰富,遇到问题容易找到解决方案。
- 内置USB-C接口与充电管理:板子直接集成了USB-C口和锂电池充电管理芯片(IP5306),这意味着你可以直接用一块3.7V的锂电池供电,并通过USB口充电,极大简化了电源系统设计。
注意:市面上有一些更便宜的ESP32-C3模组,但需要自行焊接天线、设计USB转串口和电源电路,对于想快速验证原型的新手来说,Beetle ESP32C3这种高度集成的板子是更优选择,省去了大量底层硬件调试工作。
2.2 传感器阵列:如何精准“感知”空气?
空气质量监测,传感器是眼睛和鼻子。我选择了三款主流传感器来覆盖核心指标。
2.2.1 颗粒物传感器:PMS5003
监测PM2.5和PM10,我选用的是攀藤科技的PMS5003。这是一款激光散射原理的传感器,性价比和可靠性在DIY圈子里有口皆碑。
- 为什么选它?相比更便宜的GP2Y1010AU0F(灰尘传感器),PMS5003直接通过串口输出经过算法处理的PM1.0、PM2.5、PM10的浓度值(单位μg/m³),数据更准确、稳定,无需自己进行复杂的电压-浓度换算和校准。相比更高端的SDS011,它体积更小,功耗也更低。
- 关键参数与接口:工作电压5V,采用UART(TX/RX)串口通信。只需要连接VCC、GND、TX、RX四根线即可。它的数据帧格式固定,解析起来非常方便。
- 实操心得:PMS5003内部有风扇,启动时会有“嗡”的一声。在代码中,通常需要等待30秒左右让传感器稳定,再读取数据。它的发热量不小,在外壳设计时必须考虑散热孔。
2.2.2 气体与温湿度传感器:SGP40 + SHT40
为了监测VOC(挥发性有机物,来自装修材料、清洁剂等)和室内空气质量指数(IAQ),以及精确的温湿度,我选择了Sensirion的SGP40VOC传感器和SHT40温湿度传感器的组合套件。也有将两者集成在一起的模块(如SEN55),但分开选择更灵活,成本也稍低。
- SGP40:这是一款基于MOX(金属氧化物)技术的数字VOC传感器,通过I2C接口通信。它输出的是一个“VOC指数”原始信号,需要结合温度和湿度数据(这就是为什么需要SHT40),通过官方的算法库才能转换为有意义的VOC浓度和CO2当量浓度值。官方提供的
libsensirion_gas_index_algorithm库让这个过程变得简单。 - SHT40:Sensirion新一代的温湿度传感器,精度高(±1.8%RH, ±0.2°C),响应快,同样采用I2C接口。它的一个重要角色是为SGP40提供精确的环境补偿数据。
- 选型对比:为什么不选常见的BME280或DHT22?BME280虽然也测温湿度,但它的气压功能在此项目中非必需,且其VOC/气体检测能力远不如专用的SGP40。DHT22的精度和响应速度则不如SHT40。SGP30是SGP40的前代产品,算法和性能略有不及。
2.2.3 电源方案:锂电池与充放电管理
便携意味着必须使用电池。我选择了一块常见的3.7V 1000mAh锂电池。整个系统的电源架构如下:
- 充电:Beetle ESP32C3板载的IP5306芯片负责通过USB-C口为锂电池充电。这是最省事的方式。
- 升压:系统中有需要5V供电的器件(PMS5003)。因此,我需要一个DC-DC升压模块,将锂电池的3.7V(范围约3.0V-4.2V)稳定升压到5V。我选用了一款微型MT3608升压模块,其效率高,静态功耗低。
- 供电路径:锂电池正负极直接连接到Beetle ESP32C3的“BAT+”和“BAT-”焊盘。同时,锂电池正负极也接入MT3608升压模块的输入端。MT3608输出的5V,供给PMS5003的VCC引脚。
- 电平转换注意:PMS5003的串口是5V逻辑电平,而Beetle ESP32C3的GPIO是3.3V逻辑。虽然很多ESP32引脚耐5V,但为稳妥起见,我建议在PMS5003的TX(连接ESP32的RX)线上加一个简单的电平降压电路(如两个电阻分压),或者使用一款双向逻辑电平转换模块。
2.3 电路连接总图
将所有部分连接起来,下面是详细的接线表:
| 元件 | 引脚/接口 | 连接到 Beetle ESP32C3 | 说明 |
|---|---|---|---|
| 锂电池 | 正极 | BAT+ | 供电与充电 |
| 负极 | BAT- | ||
| MT3608升压模块 | IN+ | 锂电池正极 | 为PMS5003提供5V电源 |
| IN- | 锂电池负极 | ||
| OUT+ (5V) | PMS5003 VCC | ||
| OUT- (GND) | PMS5003 GND | ||
| PMS5003 | VCC | MT3608 OUT+ | 5V供电 |
| GND | MT3608 OUT- | 共地 | |
| TX | GPIO5 (通过电平转换) | ESP32接收颗粒物数据 | |
| RX | GPIO4 | ESP32发送指令(可悬空,通常不需要) | |
| SGP40 | VCC | 3.3V | |
| GND | GND | ||
| SDA | GPIO6 | I2C数据线 | |
| SCL | GPIO7 | I2C时钟线 | |
| SHT40 | VCC | 3.3V | 可与SGP40共用电源线 |
| GND | GND | ||
| SDA | GPIO6 | 与SGP40并联 | |
| SCL | GPIO7 | 与SGP40并联 |
提示:I2C总线(SDA, SCL)上可以挂载多个设备,每个设备有唯一的地址(SHT40地址0x44,SGP40地址0x59),所以它们可以并联在同一组引脚上。务必在SDA和SCL线上各接一个4.7kΩ的上拉电阻到3.3V,这是I2C总线稳定工作的关键。
3. 固件开发:从数据采集到Web服务
硬件连接好后,就需要赋予它灵魂——程序。我使用Arduino IDE进行开发,因为其库生态丰富,上手快。
3.1 开发环境搭建与库安装
首先,在Arduino IDE中添加ESP32开发板支持。在“首选项”的“附加开发板管理器网址”中输入:https://espressif.github.io/arduino-esp32/package_esp32_index.json然后在开发板管理器中搜索安装“ESP32”(由Espressif Systems提供)。安装后,在开发板中选择“DFRobot Beetle ESP32-C3”。
接下来,通过库管理器安装必要的库:
Adafruit SHT4x Library:用于驱动SHT40。Sensirion I2C SGP40:用于驱动SGP40。Sensirion Gas Index Algorithm:这是将SGP40原始信号转换为VOC/CO2指数的核心算法库。PMS Library:推荐使用miguel5612开发的PMS库,方便解析PMS5003数据。WebServer和WiFi:ESP32内置库,用于创建Web服务器和连接Wi-Fi。ArduinoJson:用于生成Web页面显示的JSON数据(可选,但推荐)。
3.2 核心代码逻辑剖析
程序的主逻辑是一个状态机,循环执行数据采集、处理和响应网络请求。
3.2.1 初始化与Wi-Fi连接
#include <WiFi.h> #include <WebServer.h> #include <Adafruit_SHT4x.h> #include <SensirionI2CSgp40.h> #include <VOCGasIndexAlgorithm.h> #include <PMS.h> // 网络凭证 const char* ssid = “你的Wi-Fi名称”; const char* password = “你的Wi-Fi密码”; // 传感器对象 Adafruit_SHT4x sht4 = Adafruit_SHT4x(); SensirionI2CSgp40 sgp40; VOCGasIndexAlgorithm voc_algorithm; PMS pms(Serial1); // 使用Serial1连接PMS5003 PMS::DATA data; // Web服务器对象 WebServer server(80); void setup() { Serial.begin(115200); Serial1.begin(9600, SERIAL_8N1, 5, 4); // RX=GPIO5, TX=GPIO4 // 初始化SHT40 if (!sht4.begin()) { Serial.println(“SHT40未找到!”); while (1); } sht4.setPrecision(SHT4X_HIGH_PRECISION); sht4.setHeater(SHT4X_NO_HEATER); // 初始化SGP40 sgp40.begin(Wire); uint16_t serialNumber[3]; sgp40.getSerialNumber(serialNumber); // 可选的序列号读取,用于验证连接 // 连接Wi-Fi WiFi.begin(ssid, password); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print(“.”); } Serial.println(“\nWi-Fi已连接,IP地址: ”); Serial.println(WiFi.localIP()); // 设置Web服务器路由 server.on(“/”, handleRoot); // 访问根目录显示主页面 server.on(“/data”, handleData); // 获取JSON格式的传感器数据 server.begin(); }3.2.2 数据采集与处理函数
这是项目的核心。我们需要定时(例如每10秒)读取所有传感器,并进行处理。
float temperature, humidity; float voc_index, co2_equivalent; uint16_t pm25, pm10; void readSensors() { // 1. 读取SHT40(温湿度) sensors_event_t humidity_event, temp_event; sht4.getEvent(&humidity_event, &temp_event); temperature = temp_event.temperature; humidity = humidity_event.relative_humidity; // 2. 读取SGP40 VOC原始信号,并利用温湿度补偿计算VOC指数和CO2当量 uint16_t sraw_voc; uint16_t default_compensation = 0x8000; // 初始补偿 // 将温湿度转换为SGP40要求的格式 uint16_t compensation_ticks = (uint16_t)((humidity * 65535) / 100); compensation_ticks <<= 16; compensation_ticks |= (uint16_t)((temperature + 45) * 65535 / 175); sgp40.measureRawSignal(compensation_ticks, sraw_voc); voc_index = voc_algorithm.process(sraw_voc); // CO2当量值是基于VOC指数的一个估算,使用算法库中的另一函数 // 注意:SGP40本身不直接测CO2,这是算法估算值,可用于趋势参考 co2_equivalent = voc_algorithm.get_co2_equivalent(); // 3. 读取PMS5003(颗粒物) if (pms.read(data)) { pm25 = data.PM_AE_UG_2_5; // PM2.5浓度 pm10 = data.PM_AE_UG_10_0; // PM10浓度 } }3.2.3 Web服务器与前端界面
我们创建一个简单的Web服务器,当用户用手机浏览器访问设备IP时,显示一个实时数据的仪表盘。
void handleRoot() { String html = “<!DOCTYPE html><html><head><meta charset=‘utf-8’>”; html += “<meta name=‘viewport’ content=‘width=device-width, initial-scale=1’>”; html += “<title>便携空气质量监测仪</title>”; html += “<style>body {font-family: sans-serif; text-align: center;}</style>”; html += “<script>”; html += “function fetchData() {“; html += “ fetch(‘/data’).then(r=>r.json()).then(data=>{“; html += “ document.getElementById(‘temp’).innerHTML = data.temp + ‘ °C’;“; html += “ document.getElementById(‘humi’).innerHTML = data.humi + ‘ %’;“; html += “ document.getElementById(‘pm25’).innerHTML = data.pm25 + ‘ μg/m³’;“; html += “ document.getElementById(‘pm10’).innerHTML = data.pm10 + ‘ μg/m³’;“; html += “ document.getElementById(‘voc’).innerHTML = data.voc.toFixed(0);“; html += “ document.getElementById(‘co2’).innerHTML = data.co2.toFixed(0) + ‘ ppm’;“; html += “ });}”; html += “setInterval(fetchData, 5000); // 每5秒更新一次“; html += “</script></head><body>”; html += “<h1>🌍 实时空气质量</h1>”; // 注意:实际输出时请移除Emoji html += “<p>温度: <span id=‘temp’>–</span></p>”; html += “<p>湿度: <span id=‘humi’>–</span></p>”; html += “<hr>”; html += “<h2>颗粒物</h2>”; html += “<p>PM2.5: <strong id=‘pm25’>–</strong> μg/m³</p>”; html += “<p>PM10: <strong id=‘pm10’>–</strong> μg/m³</p>”; html += “<hr>”; html += “<h2>气体指数</h2>”; html += “<p>VOC指数: <span id=‘voc’>–</span></p>”; // VOC指数无单位 html += “<p>CO₂当量: <span id=‘co2’>–</span> ppm</p>”; html += “<p><small>最后更新: <span id=‘time’>–</span></small></p>”; html += “<script>fetchData(); setInterval(()=>{“; html += “document.getElementById(‘time’).innerHTML=new Date().toLocaleTimeString();},1000);</script>”; html += “</body></html>”; server.send(200, “text/html”, html); } void handleData() { readSensors(); // 处理前先读取一次最新数据 String json = “{“; json += “\”temp\”:” + String(temperature, 1) + “,”; json += “\”humi\”:” + String(humidity, 1) + “,”; json += “\”pm25\”:” + String(pm25) + “,”; json += “\”pm10\”:” + String(pm10) + “,”; json += “\”voc\”:” + String(voc_index) + “,”; json += “\”co2\”:” + String(co2_equivalent); json += “}”; server.send(200, “application/json”, json); } void loop() { server.handleClient(); // 处理客户端请求 // 可以在这里添加低功耗睡眠逻辑,例如每10秒唤醒一次进行测量 // delay(10000); }3.3 功耗优化与深度睡眠
对于便携设备,功耗至关重要。ESP32-C3的深度睡眠模式可以大幅降低待机电流。
基本思路:设备每5分钟唤醒一次,读取所有传感器数据,然后开启Wi-Fi将数据上传到某个物联网平台(如ThingsBoard、Blynk或自建服务器),上传完成后再次进入深度睡眠。但这样我们就无法实时通过Web访问了。
折中方案(本项目采用):设备持续运行,但通过代码优化降低功耗。例如:
- 在
loop()中,如果没有网络请求,让ESP32-C3进入light sleep模式。 - 降低CPU频率。
- 关闭不用的外设(如蓝牙)。
- 为PMS5003设计一个由GPIO控制的电源开关,只在需要测量时通电(因其功耗较大)。
一个简单的light sleep示例:
#include “driver/rtc_io.h” void enterLightSleep(int seconds) { esp_sleep_enable_timer_wakeup(seconds * 1000000); // 微秒 esp_light_sleep_start(); } void loop() { if (server.hasClient()) { // 有客户端连接,正常处理 server.handleClient(); } else { // 无请求时,进入轻度睡眠1秒 enterLightSleep(1); } // 每10秒主动读取一次传感器数据更新全局变量 static unsigned long lastRead = 0; if (millis() - lastRead > 10000) { readSensors(); lastRead = millis(); } }4. 3D建模与打印:打造专属外壳
硬件和软件都跑通了,接下来就是给它一个“家”。一个好的外壳不仅要美观,更要考虑散热、天线信号、按键/接口开口、安装柱位等工程细节。
4.1 设计考量与工具选择
我使用Fusion 360进行建模,它对个人用户免费,功能强大且学习资源丰富。设计时重点考虑以下几点:
- 紧凑性:所有元器件(Beetle ESP32C3、电池、升压模块、传感器)需要合理布局,尽量减少整体体积。我采用了上下盖的卡扣式设计。
- 散热:PMS5003和ESP32芯片在工作时都会发热。必须在对应位置设计足够的通风孔。特别是PMS5003的进气口和出气口不能被遮挡。
- 信号:Wi-Fi天线区域(通常在Beetle板子的特定一侧)应尽量避免被金属或大面积塑料遮挡,外壳此处可做薄或开窗。
- 可维护性:采用卡扣或螺丝固定,方便拆装更换电池或维修。我设计了四个自攻螺丝柱。
- 传感器开口:为PMS5003的激光腔开一个进气窗;为SGP40和SHT40开一些小孔让空气流通。
- 接口开口:预留USB-C口和PMS5003的串口(可选,用于调试)的开孔。
- 打印友好性:避免大面积的悬空结构,需要加支撑的地方要考虑到。设计合适的脱模斜度。
4.2 关键结构设计步骤
- 导入参考模型:可以从DFRobot官网或其他社区找到Beetle ESP32C3、PMS5003、电池的STEP或STL文件,导入Fusion 360作为参考,确保开孔和安装位置精准。
- 创建底板:根据所有元器件的平面尺寸,绘制一个矩形底板。在对应位置创建安装柱。安装柱中间需要留出螺丝孔(如M2自攻螺丝),柱子高度要略高于元器件本身。
- 设计侧壁与卡扣:向上拉伸侧壁。在侧壁内侧设计卡扣钩,在上盖对应位置设计卡扣槽。卡扣需要一定的弹性,通常设计0.3-0.5mm的干涉量。
- 开孔与通风设计:
- 使用“偏移平面”工具在PMS5003进气口对应位置创建一个工作面,然后用“网格”工具绘制蜂窝状或栅格状的通风孔并拉伸切割。
- 在顶盖上方为SGP40/SHT40开一些小圆孔阵列。
- 在侧面或底面为USB-C口开一个矩形槽。
- 创建上盖:设计一个与下盖匹配的上盖,同样包含卡扣结构、通风孔和必要的加强筋。
- 导出为STL:分别将上盖和下盖导出为STL文件,准备进行切片。
4.3 切片与打印实战
我使用PrusaSlicer进行切片,打印机是Creality Ender-3。
- 材料:PLA,因为它易于打印,强度足够,且没有异味。避免使用ABS,因为其打印过程有气味,且可能干扰气体传感器。
- 层高:0.2mm,在打印速度和表面光洁度间取得平衡。
- 填充密度:15%-20%。外壳不需要太高的填充,节省材料和时间。
- 支撑:对于卡扣的悬空部分和通风孔内部的桥接,需要生成支撑。我选择“仅在接触构建板处生成支撑”,以减少后期处理。
- 打印方向:将下盖的底部(安装柱朝上)平放在打印床上,这样安装柱的顶部需要支撑,但整体强度最好。上盖则内侧朝下放置。
- 实操心得:
- 第一层附着是关键:确保热床平整,喷头高度合适,第一层线宽均匀贴合。
- 通风孔可能堵住:如果通风孔的直径小于喷嘴直径(通常0.4mm),切片软件可能会忽略它。建议通风孔设计为至少1mm宽。
- 卡扣的干涉量需要测试:第一次打印可以只打印一个带有卡扣的测试块,验证松紧度,再调整模型。太紧可能掰断,太松会脱落。
- 打印后处理:小心移除支撑,用锉刀或砂纸打磨毛刺,特别是USB-C口和传感器开口处,确保没有塑料碎屑阻碍插拔或空气流通。
5. 组装、校准与最终测试
所有部件准备就绪,进入最后的组装调试阶段。
5.1 焊接与内部组装
- 焊接连接线:根据之前的接线表,将杜邦线或硅胶导线焊接到Beetle ESP32C3、传感器和升压模块上。强烈建议给电源线(VCC、GND)使用不同颜色(如红、黑),避免接反。I2C和串口线也最好用颜色区分。
- 固定元器件:使用M2*6mm的自攻螺丝,将Beetle ESP32C3、升压模块固定在下壳的安装柱上。PMS5003可以用螺丝固定,也可以用强力双面胶粘贴。电池可以放在空余位置,用尼龙扎带或泡棉胶固定。
- 理线与测试:将所有线材整理好,用扎带固定,避免杂乱。在合上外壳前,先上电测试!通过串口监视器查看传感器数据是否正常输出,手机能否连接到设备IP并显示网页。
5.2 传感器校准与数据解读
- PMS5003:这款传感器出厂已校准,通常无需用户校准。但要注意,它测量的是“空气动力学当量直径”的颗粒物浓度。在极端干燥或潮湿环境下,读数可能会有轻微偏差。确保进气口通畅,远离风扇、空调直吹。
- SHT40:精度很高,一般也无需校准。首次读数如果与环境温湿度计差异很大,检查是否接触不良或代码中精度设置是否正确。
- SGP40:这是校准的重点。VOC传感器需要一段时间的“磨合”和“背景校准”。
- 首次运行:将设备放在通风良好的室外或已知空气良好的环境中,通电运行至少24小时。这个过程让传感器基线稳定下来。
- 背景校准:Sensirion的算法库本身具备自动背景校准功能,它会在设备长时间运行时,自动学习环境背景VOC水平,并以此为基础计算VOC指数。因此,让设备持续通电运行是获得准确VOC趋势的关键。短时间开关机,其指数可能不准确。
- 数据解读:VOC指数是一个无量纲的值,通常范围在1-500。值越低代表空气质量越好。CO2当量值是基于VOC和温湿度估算的,单位为ppm。它可以作为室内二氧化碳浓度趋势的一个参考,但不能替代真正的NDIR CO2传感器进行精确测量。
5.3 功能扩展与优化想法
基础版本完成后,你可以根据兴趣进行扩展:
- 数据上传与可视化:将数据定期上传到ThingsBoard、Blynk或Home Assistant,制作历史数据图表和仪表盘。
- 阈值告警:在固件中加入逻辑,当PM2.5超过某个值(如75μg/m³)或VOC指数过高时,让板载的RGB LED闪烁红灯,或在Web页面上弹出警告。
- 低功耗优化进阶:使用硬件定时器(RTC)唤醒,实现真正的深度睡眠,使待机电流降至100μA以下,极大延长电池续航。
- 增加显示屏:使用一块小型OLED屏幕(如0.96寸),直接显示实时数据,脱离手机也能查看。
- 外壳美化:对3D模型进行倒角、添加纹理,或者使用多色打印,让设备更具个性。
这个项目从构思到实现,花费了我几个周末的时间,期间遇到了电源干扰、I2C地址冲突、Web服务器内存泄漏等问题,但逐一解决后的成就感是无与伦比的。现在,这个我自己制作的小设备就放在我的电脑旁,默默守护着我呼吸的每一口空气。它给出的数据,让我对室内环境有了更直观的认识,也促使我养成了定时开窗通风的习惯。如果你也对硬件DIY和物联网感兴趣,不妨动手试一试,这个过程本身,就是最好的学习。