ESP32+MQTT+阿里云:自制温湿度光照土壤监测装置全解析
2026/9/12 5:19:25 网站建设 项目流程

简介:面向ESP8266物联网开发与阿里云平台接入的学习者,这套资料以正点原子ESP8266-01S为核心,结合OLED显示屏、温湿度传感器、光敏电阻和土壤湿度传感器,实现多路环境数据采集、本地屏幕显示与云端远程上报。资源压缩包共57MB,包含311个文件,覆盖Keil工程源码(h、c)、编译生成的hex/bin固件、OLED显示所需的BMP图片资源、PDF原理图文档、烧录工具exe及配置文件等,中间文件也一并保留,便于二次编译与排错;文件类型以源码、固件和说明文档为主,结构清晰。已有1885人学习下载。配套B站视频完整演示了从接线、烧录到平台配置的整个过程,资源描述中详细列出了各传感器与ESP8266的引脚连接关系,如温湿度接PB12、光照ADC接PA0等,可对照视频快速复现项目。对于希望低成本搭建环境监测上报系统的开发者,这是一份软硬件兼备的完整参考方案。

1. 一块小屏实时显示、数据同步上云,这套温湿度光照土壤监测装置到底在做什么

你在B站刷到这类视频时会发现一个共性:一块128x64的OLED小屏幕上,温度、湿度、光照强度、土壤湿度轮流跳动,旁边的ESP32开发板安静地连着WiFi,阿里云物联网平台的后台里,这些数据以固定频率刷新成曲线。外行人觉得是“炫”,但做过的人清楚,这背后其实是三件事的拼装:传感器选型、MQTT上云、屏幕显示。买一台成品环境监测仪当然省事,但协议封闭、采样周期不可控、数据出不来,想接自己的告警规则或可视化看板几乎不可能。自己搭一套,总成本不到一百块钱,却能同时把传感器采集、嵌入式网络通信、云平台物模型这三块硬技能串起来。适合正在做物联网毕设的人,也适合想给阳台植物或小温室做数据记录的折腾党。这套方案的核心套路很固定:传感器读数、拼JSON、MQTT上报、屏幕轮播,下面按这条线把每个环节的选型理由和坑位讲清楚。

2. 传感器与物模型:先想清楚温湿度、光照、土壤湿度这三路数据怎么量、怎么传

动手接线之前,先花十分钟把传感器定下来。很多视频里看起来“都能用”,但每种传感器读出来的数值含义完全不同:有的给百分比,有的给模拟量,有的给工程单位。如果一开始不定好量纲,后边在阿里云上配物模型、做可视化时会反复改。这一章先解决“量什么、用什么量、以什么格式传”。

2.1 温湿度:DHT11、DHT22 怎么选,读取间隔按数据手册来

温湿度最常见的两个选项是DHT11和DHT22。DHT11便宜到可以忽略成本,但精度只有±2℃、±5%RH,采样周期标称1秒,实际稳定读数往往要2秒以上。DHT22把温湿度精度提到±0.5℃、±2%RH,价格贵一倍多点,但读出来的数据在曲线上明显平滑。我的建议是:做演示和课程设计,DHT11够用;想把数据当真拿去分析,直接换DHT22,省去后续反复校准的时间。

这两个传感器都是单总线协议,只需占用一个GPIO。读取时有个关键限制:两次读取间隔不能低于数据手册规定的最小采样周期,否则会拿到上一次的缓存值甚至NaN。代码层面要用时间戳控制轮询节奏,而不是在loop()里连续调用读取函数。DHT库内部虽然有自己的节流逻辑,但不同库实现差异很大,靠dht.getMinimumSamplingPeriod()来约束读取频率是最稳的做法。

2.2 光照:BH1750 比光敏电阻更适合做 lx 级上报

光照传感器有两个流派。光敏电阻加一个固定电阻做分压,接到ADC引脚上,通过电压换算成光照强度,成本最低,但非线性严重,而且同一批电阻个体差异大,不同板子之间读数没有可比性。相比之下,BH1750是I2C接口的数字光照传感器,直接输出勒克斯(lx),量程到65535,适应室内到户外直射的大动态场景。它和OLED屏幕走同一条I2C总线,地址是0x23,OLED一般是0x3C,两者不冲突。

BH1750有个别小坑:上电后要先begin()再读,首次读取建议延时200ms;连续高分辨率模式下,每次转换约120ms,读得过快会拿到旧值。对植物补光或室内光照监测来说,100ms级别的延迟完全无感,但如果你同时还要做OLED刷新和MQTT上报,就要把这三个任务的时序错开,避免单次loop()里串行等待导致整周期变长。

2.3 土壤湿度:电容式探头才是能长期插在土里的方案

土壤湿度传感器是这套装置里最容易被低估的环节。市面上大量廉价模块是电阻式:两根金属探针插入土壤,利用土壤含水量变化引起电阻变化来读数。这类探头刚用几天确实灵敏,但插在潮湿土壤里一到两周,探针就会因电解反应逐渐腐蚀,读数漂移得厉害。电容式土壤湿度传感器的工作原理完全不同:它把探头做成一个电容器,土壤作为介质,含水量变化改变介电常数,从而改变电容值。没有裸露电极的电解问题,寿命长得多,价格只贵几块钱。

接线方面,电容式模块一般输出模拟量(AO),也有加比较器输出数字量(DO)的版本。我们只需要AO接ESP32的ADC引脚。值得注意的是,这类模块的ADC读数与湿度的方向不一定直观:很多模块土壤越干输出电压越高,越湿反而越低。代码里的百分比映射要按实际模块数据手册的方向做,或者干脆用“干空气读数映射0%、泡水读数映射100%”的标定方式。

2.4 数据模型:一张表定下单位,JSON 直接对齐阿里云物模型

在写第一行代码之前,先把字段定下来。这步跳过了,后面在阿里云控制台配物模型时还得回来改。下面这张表是这套装置最常见的字段设计,直接对应阿里云物模型的属性定义。

属性标识符中文名数据类型单位数据来源
temperature温度floatDHT11/DHT22
humidity湿度float%RHDHT11/DHT22
light光照强度intlxBH1750
soil土壤湿度int%电容式传感器映射

对应的JSON上报结构在阿里云物模型里长这个样子,后面代码中直接拼接这个结构体即可:

{ "id": "123456", "version": "1.0", "method": "thing.event.property.post", "params": { "temperature": 25.6, "humidity": 60.2, "light": 350, "soil": 45 } }

id每次上报不能重复,可以用毫秒时间戳或自增计数器;params里的键名必须和物模型里的属性标识符完全一致,否则阿里云返回参数错误。浮点数精度只需要一位小数,多了既浪费流量,也会让后端存储的数值显得脏。这里还有一个经常被忽略的点:数据类型的匹配。物模型里定义的是float,上报时就别传字符串"25.6",类型不一致会被平台直接拒收,这是初学者在连云阶段最常看到的报错。

3. 硬件接线与采集代码:三个传感器和OLED挂在同一条 I2C 上

选型定下来之后,接线就简单了。整套装置的核心是ESP32开发板,原因有三个:自带WiFi和蓝牙、ADC引脚多、主频足够同时跑传感器轮询、OLED刷新和MQTT发包。ESP8266也能做,但ADC只有一个且精度一般,土壤湿度、光照如果用模拟量就会紧张。下面的接线和代码以ESP32为例展开。

3.1 引脚分配表:ESP32 的 ADC、I2C 别和下载口抢资源

接线前先看一张引脚分配表。ESP32的I2C默认走GPIO21(SDA)和GPIO22(SCL),BH1750和OLED都挂在这组引脚上,设备地址不同,不需要额外片选。DHT11/22走单总线,接一个普通GPIO即可。土壤湿度模块的AO引脚必须接在ADC1通道上,ESP32的ADC2在WiFi开启时不可用,这是新手接完线发现土壤数据恒为0的最常见原因。

传感器信号引脚ESP32引脚备注
BH1750SDA / SCLGPIO21 / GPIO22与OLED共用I2C
OLED (SSD1306)SDA / SCLGPIO21 / GPIO22地址0x3C
DHT11/22DATAGPIO4第2章可换
电容式土壤湿度AOGPIO34ADC1通道
所有模块VCC / GND3.3V / GND土壤模块可试点5V

注意这里的3.3V供电问题。BH1750和OLED用3.3V没问题,部分电容式土壤模块标称支持3.3V到5V,但3.3V下ADC输出范围会整体下移。如果发现土壤读数在空气中和水中的差值过小,把模块VCC单独接到5V引脚,AO输出仍接3.3V参考的ADC,注意共地即可。DHT在3.3V下工作正常,不需要外部上拉,部分模组自带上拉电阻。

3.2 Arduino 采集骨架:读三路数据,再决定显示还是先上云

代码整体分四块:WiFi连接、传感器采集、OLED绘制、MQTT上报。先看采集和显示这两块的最小骨架,下文再补阿里云连接部分。

#include <Wire.h> #include <DHTesp.h> #include <BH1750.h> #include <U8g2lib.h> #define DHT_PIN 4 #define SOIL_PIN 34 #define SDA_PIN 21 #define SCL_PIN 22 DHTesp dht; BH1750 lightMeter; U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, U8G2_USE_SW_I2C); float temperature = 0, humidity = 0; uint16_t lightLux = 0; uint8_t soilPercent = 0; unsigned long lastSensorRead = 0; void readSensors() { unsigned long nowMs = millis(); if (nowMs - lastSensorRead >= dht.getMinimumSamplingPeriod()) { temperature = dht.getTemperature(); humidity = dht.getHumidity(); lastSensorRead = nowMs; } lightLux = lightMeter.readLightLevel(); int soilRaw = analogRead(SOIL_PIN); // 电容式传感器方向不定,需根据实测标定 soilPercent = map(soilRaw, 3400, 1400, 0, 100); soilPercent = constrain(soilPercent, 0, 100); } void drawScreen() { u8g2.clearBuffer(); u8g2.setFont(u8g2_font_ncenB10_tr); char line[32]; snprintf(line, sizeof(line), "Temp: %.1f C", temperature); u8g2.drawStr(2, 14, line); // 湿度、光照、土壤同理绘制 u8g2.sendBuffer(); } void setup() { Serial.begin(115200); Wire.begin(SDA_PIN, SCL_PIN); dht.setup(DHT_PIN, DHTesp::DHT22); lightMeter.begin(); u8g2.begin(); } void loop() { readSensors(); drawScreen(); delay(100); }

readSensors()里两个关键点。第一,DHT读取用dht.getMinimumSamplingPeriod()做节流,保证两次读取间隔不低于其内部状态机要求。第二,map(soilRaw, 3400, 1400, 0, 100)里的两个源区间值不是随便写的,它们分别对应传感器在干燥空气和泡水状态下的实测ADC输出,每个模块都不一样,必须用自己的板子实测后替换。代码里加了constrain()防止校准不准时百分比越界,这是仪表类显示必须做的保护。

3.3 三个必踩的读数坑:DHT 首次 NaN、土壤上电稳定时间、BH1750 量程

先说DHT的NaN问题。ESP32上电瞬间,DHT还没准备好,第一次读温度几乎必然是NaN,表现为串口打出一行nan。解决方式除了跳过前两次读取外,更稳妥的是用isnan()判断,读到的值无效就不更新显示变量,保持上一次有效值。这个习惯同时适用于很多I2C传感器,能避免NaN一路传到云平台变成脏数据。

土壤湿度的坑在稳定时间。电容式土壤模块是一个RC振荡电路,上电瞬间输出电压要经过几十到几百毫秒才稳定。如果你在setup()里上电后立刻读第一次ADC,拿到的往往是随机的中间值。常见做法是给土壤模块单独用一个GPIO控制电源,读取前先写高电平,延时100ms再读,读完可以断电省电。

BH1750的量程问题不属于读取失败,而是场景误用。它最高量程65535lx,室外正午阳光直射可能接近甚至超出这个值,读出来封顶在65535。放在室内或阳台遮阴处没问题,但如果设备要放户外全天候跑,就得考虑在后面加一层遮光片,或者改用支持更高量程的传感器。另外BH1750在连续高分辨率模式下电流约1mA,断电重开会丢配置,代码里要保证begin()只在setup()执行一次。

4. 上传阿里云:产品、设备、MQTT连接参数与上下行测试

本地采集和显示跑通后,剩下来最耗时间的就是阿里云连接这一关。很多人的设备在本地显示一切正常,一上云就出现“连接被拒绝”“鉴权失败”“上行消息没有数据”,原因大都集中在三元组抄错、MQTT签名串顺序不对、Topic拼错这三个地方。下面按从平台配置到设备端程序的顺序走一遍。

4.1 控制台准备:从创建产品到拿三元组,公共实例和企业版注意点

阿里云物联网平台现在区分公共实例和企业版实例,两者在设备接入流程上基本一致,但接入地址和部分控制台入口不同。个人学习、毕设、B站视频教程里绝大多数用的是公共实例,免费额度够用,产品数、设备数、消息量都有上限,对这套传感器数据上报的场景完全够。

控制台操作分三步。第一步创建产品:产品名称随意,所属品类选“温湿度传感器”或“自定义”,认证方式选“设备密钥”。第二步定义物模型:在产品详情页的“物模型”标签下,添加上文表里的四个属性,注意属性标识符一旦定义,修改会影响已注册设备。第三步注册设备:得到DeviceName(设备名)、ProductKey(产品密钥)、DeviceSecret(设备密钥)三元组。其中DeviceSecret只在创建时完整显示一次,一定要复制保存,丢失后只能删除设备重建。

新用户容易在这几步翻车:地域选错,公共实例接入地址跟随地域变化,华东2(上海)、华南1(深圳)等地域的Broker地址各不相同;产品品类选错导致物模型模板里带了不应有的属性,再改就很麻烦。建议在创建产品前先明确“我只需要温湿度、光照、土壤湿度这四个属性”,然后选自定义品类从头建模。

4.2 手写 MQTT 签名:ClientID/密码的 HmacSHA1 算法在 ESP32 上实现

获取三元组后,设备端要构造MQTT连接的三个关键字段。以公共实例为例,Broker地址一般为${productKey}.iot-as-mqtt.${regionId}.aliyuncs.com,其中regionId是地域代码,上海是cn-shanghai。端口1883。这三个参数在控制台设备详情页的“连接信息”里都有现成展示,可以直接复制。期望避免因人而异出错的方法是:先拿“设备详情”里给出的Mqtt连接参数原样填进MQTTX桌面客户端测试,通了再写进ESP32代码。

ClientID、用户名、密码的生成规则为:

参数取值
ClientID${deviceName}|securemode=3,signmethod=hmacsha1,timestamp=${timestamp}|
用户名${deviceName}
密码对特定内容串做HmacSHA1摘要,密钥为DeviceSecret

其中签名内容串的拼接顺序固定为clientId${deviceName}deviceName${deviceName}productKey${productKey}timestamp${timestamp}。timestamp是当前毫秒时间戳,每次连接都要重新生成。注意ClientID中的设备名、内容串里的clientId字段等都不能缺少,否则平台返回“鉴权失败”。在ESP32上用mbedtls实现一次HmacSHA1,完整函数如下:

#include <mbedtls/md.h> String hmacSha1(String key, String content) { byte hmacResult[20]; mbedtls_md_context_t ctx; mbedtls_md_init(&ctx); mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(MBEDTLS_MD_SHA1), 1); mbedtls_md_hmac_starts(&ctx, (const unsigned char *)key.c_str(), key.length()); mbedtls_md_hmac_update(&ctx, (const unsigned char *)content.c_str(), content.length()); mbedtls_md_hmac_finish(&ctx, hmacResult); mbedtls_md_free(&ctx); String hex = ""; for (int i = 0; i < 20; i++) { if (hmacResult[i] < 16) hex += "0"; hex += String(hmacResult[i], HEX); } return hex; }

mbedtls_md_hmac_finish拿到的原始摘要必须编码成十六进制小写字符串,这一步漏掉或转大写都会导致鉴权失败。ESP32的Arduino核心自带mbedtls,不需要装额外库。相比使用现成的阿里云SDK,手写这套签名的好处是出错时能定位到具体字段,也更利于理解每个库内部做了一层什么封装。

设备端连接时使用PubSubClient库,connect入参中依次填入ClientID、用户名、密码:

WiFiClient wifiClient; PubSubClient mqttClient(wifiClient); String clientId = String(deviceName) + "|securemode=3,signmethod=hmacsha1,timestamp=" + timestamp + "|"; String username = deviceName; String password = hmacSha1(deviceSecret, "clientId" + String(deviceName) + "deviceName" + String(deviceName) + "productKey" + String(productKey) + "timestamp" + timestamp); mqttClient.setServer(mqttHost, 1883); mqttClient.connect(clientId.c_str(), username.c_str(), password.c_str());

setServer里填的域名不要带http://,也不要加端口以外的路径。connect返回false时,用mqttClient.state()打印返回码,4表示用户名或密码错误,5表示未授权,查看距离问题最快。

4.3 属性上报 Topic 与响应回调:confirmed 不上行别慌,看日志

连接成功后,设备属性上报的Topic是固定格式:

/sys/{productKey}/{deviceName}/thing/event/property/post

上报的Payload就是第2章那段JSON,直接publish即可。如果只是单向上报不关心平台是否收到,QoS可以用0;但为了排查方便,建议订阅平台的响应Topic:

/sys/{productKey}/{deviceName}/thing/event/property/post_reply

然后通过setCallback打印返回消息。平台返回"code":200说明上报成功;如果返回类似"code":400"code":460,说明JSON格式或物模型字段不对,这时去控制台的“监控运维 -> 日志服务”里查详细原因,比在串口上盲猜快得多。

一个很常见的现象是:publish返回true,但平台日志里没有上行消息。先确认设备是否真的处于在线状态,控制台显示在线不代表当前连接就一定可用,有些情况下TCP连接断了但平台还没来得及标记离线。此时在设备端setKeepAlive(60)主动缩短心跳周期,同时观察串口是否周期性收到平台断连回调。

4.4 连不上阿里云的排查顺序表

把常见的连接失败和排查方向按顺序列出来,照表查,节省时间。

现象优先检查项处理方式
connect返回4密码签名串或DeviceSecret重新对照完整的签名内容串拼写
connect返回5设备名或ClientID不匹配三元组与ClientID里不能有换行和空格
connect返回-2网络不通或域名拼错先ping域名,或换电脑NTP确认时间正确
连上但publish后无响应Topic拼写或Payload格式对照4.3 Topic,复制平台日志里的错误码
控制台显示在线但收不到数据上报频率超过实例阈值降低上报频率,公共实例有TPS限制
设备反复掉线重连时间戳没更新或会话残留重连时必须重新生成timestamp

时间戳这个坑值得单独强调。因为每次刷新页面生成的新连接,阿里云后台都会校验时间戳的有效窗口。复用了上一次的时间戳去重连,后台判定为重放,直接拒绝。代码里把生成时间戳和重连封装在同一个函数里,别把它放在setup()里只执行一次。

5. OLED 屏幕显示再抠细一点:中文字库、轮播、时间与重连

实话说,上云之后屏幕显示这部分最容易被人忽略,但这也是整个装置天天摆在桌上给人看的部分。一堆英文缩写飘在屏幕上,不如一屏中文“温度、湿度、光照、土壤”来得直观。很多B站教程在这块讲得比较浅,都是简单调库drawStr两行英文。下面几个细节是能直接提升观感和稳定性的做法。

5.1 128×64 画面的分屏布局和三秒轮播

128×64分辨率放下四路数据加标签会很挤。如果全部显示在一屏上,字体会缩到难以阅读。常见做法是分成两屏:第一屏显示温度和湿度,第二屏显示光照和土壤湿度,每三秒切换一次。放一下其中一屏的绘制逻辑,另一屏对称处理。

void drawScreenPage1() { u8g2.clearBuffer(); u8g2.setFont(u8g2_font_ncenB10_tr); u8g2.drawStr(0, 14, "Temp: 25.6 C"); u8g2.drawStr(0, 32, "Humi: 60.2%"); u8g2.drawStr(0, 50, "T: 2025-01-01 12:00"); u8g2.sendBuffer(); }

millis()做节流切换,每3秒切一屏。这样每屏只有两三行内容,字可以做很大,隔两米也能看清数值。注意drawStr的y坐标是文字基线而不是顶部,用12、32这种间距配合字高14px的字体比较稳妥。另外,屏幕刷新和数据读取是两个独立节奏,别在delay()里高频重绘,否则DHT的采样窗口会被挤占。

5.2 UTF-8 中文字体与 u8g2 的编码坑

想在OLED上显示中文,u8g2库里要启用中文字体,最常用的是u8g2_font_unifont_t_chinese,字高16像素,128像素宽一行能放下7到8个汉字。启用方式:

u8g2.setFont(u8g2_font_unifont_t_chinese); u8g2.drawUTF8(0, 16, "温度"); u8g2.drawUTF8(0, 36, "湿度");

这里有三处容易翻车。第一,字符串函数必须用drawUTF8,而不是drawStr,后者只会按字节逐个画,中文会变成乱码。第二,Arduino IDE的编码要保证源文件保存为UTF-8,Windows平台经常默认GBK,字符在编辑界面显示正常,编译后全乱。第三,u8g2_font_unifont_t_chinese是一个大字体,会明显多吃Flash和RAM,ESP32内存充裕无所谓,ESP8266就得考虑用十六点阵压缩字库或只显示英文字符。

5.3 NTP对时与掉线自动重连的配合

屏幕上的时间直接从设备本地时间取,容易越走越偏。ESP32每次重启后时间都会重置,上云的同时顺手做一次NTP对时。阿里云时间服务器ntp1.aliyun.com在国内连通性和稳定性都很好,使用:

configTime(8 * 3600, 0, "ntp1.aliyun.com"); struct tm timeinfo; if (getLocalTime(&timeinfo)) { strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &timeinfo); }

注意configTime的第一个参数是UTC+8的秒数偏移,填8×3600。如果用的是ESP8266,configTime会调用底层的LwIP的SNTP,重启后需要等待几秒到几十秒才能拿到有效时间。在此基础上,把getLocalTime失败时的降级策略设置为“暂时显示--:--”,而不是切断工作主循环。这行时间同时可以拼进MQTT上报的JSON里作为事件时间,比依赖平台服务器时间更灵活。

掉线重连的逻辑要跟时间戳绑定在一起。每次重连前重新生成timestamp,再重新计算密码,然后才调用mqttClient.connect()。重连间隔建议10秒起步,不想阻塞主循环就用:

if (!mqttClient.connected() && millis() - lastReconnectMs > 10000) { lastReconnectMs = millis(); reconnectMqtt(); }

这套装置的最后一个验证动作,是去阿里云控制台看实时数据曲线能否连续更新。屏幕显示是给你自己看的,云端的数据才是这个系统真正沉淀下来的资产。等这两条路都通了之后,再回头看B站那期视频里的演示流程,你会发现自己已经具备了从传感器到云端的完整链路理解,剩下的无非是在这个骨架上加告警、加历史曲线、加更多传感器。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询