STM32+ESP8266+MQTT接入OneNet:温湿度上报与继电器控制
2026/9/13 6:33:18 网站建设 项目流程

简介:基于STM32F103与ESP8266 WIFI模块,通过MQTT协议接入中移OneNet云平台的完整嵌入式项目代码,包含继电器控制与温湿度采集功能,适合正在学习物联网通信、STM32外设驱动及云平台对接的开发者参考。工程采用KEIL开发,在STM32F103C8T6上验证通过,其他F103型号只需修改芯片型号与Flash容量即可移植。代码实现了主动上报本地温湿度与继电器状态,并接收平台下发的控制指令执行动作,覆盖串口AT指令、MQTT报文解析、GPIO控制等关键环节。源码中串口2连接ESP8266,串口1可用于调试输出,主循环状态机与函数模块划分清晰,便于按需裁剪或移植。双向通信流程完整,可帮助理解设备端定时上报与平台指令响应的实现方法。压缩包共189个文件,以C源码和H头文件为主,附带uvprojx工程文件、HEX烧录文件及编译中间文件,整体仅6.31MB,解压后可直接用KEIL打开查看结构。已有1859人学习下载,对有实际设备接入需求的物联网初学者和竞赛项目开发者有直接参考价值。

1. 为什么是 STM32 + ESP8266 + MQTT 接入 OneNet 这套方案

在做一个基于 STM32 的鱼缸控制器时,最花时间的不是焊板子,而是让继电器开合和温湿度数据可靠到达云端。采用 ESP8266 做 WiFi 接入,STM32 采集 DHT22,再通过 MQTT 协议与中移 OneNet 物云平台交换数据,是这类物联网项目最常见也最容易跑通的组合。难点在于 ESP8266 上电后不一定能立刻联网,MQTT 的鉴权参数又比 HTTP 容易配错:产品 ID、设备名、设备 key 三个值写错一个,设备就一直离线。下面把 OneNet 平台的配置和 STM32 端的收发代码一起拆开讲,新手可以照着接线,老手重点看命令下发和掉线重连部分。

2. OneNet 平台侧准备:产品、设备、APIKey 与数据流

2.1 创建产品时选对协议,后面接入少踩一半坑

登录 OneNet(中移物联网开放平台),在控制台选择“多协议接入”。创建产品时,协议类型选 MQTT,而不是 HTTP。操作系统随意,联网方式选 WiFi。产品名称建议跟项目一致,比如stm32-relay-dht22。创建后你会拿到产品 ID,后面所有设备鉴权都用它。

选 MQTT 而不是 HTTP 的理由:HTTP 轮询没法做服务端主动下发,继电器远程控制需要平台向设备推命令;MQTT 的 PUBLISH/SUBSCRIBE 天然适合温湿度周期性上报加命令下行。OneNet 的设备接入方式很多,HTTP、MQTT、CoAP、NB-IoT 都支持,但对 ESP8266 这类 WiFi 模组来说,MQTT 是最省流量的方案,一条温湿度报文带上 JSON 结构也就 100 字节左右。

提示:OneNet 的老版本区分“设备云”和“OneNET Studio”,本文说的是新版 Studio 的 MQTT 接入方式。如果你打开的是旧版控制台,界面入口不同,但 APIKey 和产品 ID 的用途一致。

2.2 生成 APIKey 并在数据流模板里定义温湿度

在“产品详情”页面找到“APIKey”管理,点击生成。APIKey 是平台调用 API 和部分 MQTT 连接校验用的密钥。设备接入 MQTT 时,用户名用产品 ID,密码用 APIKey 或设备 key,具体取决于你选的鉴权模式。建议先把所有 key 存到一个文本文件里,因为后面要多次复制。

在“数据流模板”里新增两个数据流:temperaturehumidity。别用中文名,STM32 端发布的 topic 和平台数据流必须完全一致。如果还想上报继电器状态,再加一个relay_state

数据流名称类型单位用途
temperatureint/float°CDHT22 温度
humidityint/float%RHDHT22 湿度
relay_stateint继电器开合状态

创建完产品后,在“设备”页面添加一个真实设备。设备名称要记好,例如dev_relay_01。设备创建成功后,平台会给出一个设备 key 或者让你自建。这个设备 key 在 MQTT 的 password 字段里面用。

2.3 用 Python 脚本先验证平台连通性,再动 STM32

在写 STM32 代码之前,先用电脑上的 MQTT 客户端把平台流程走通。用 paho-mqtt 脚本发布一条温湿度数据到 OneNet,确认鉴权参数和 topic 格式没问题。这样可以避免把硬件问题和平台配置问题混在一起。

import paho.mqtt.client as mqtt import json import time PRODUCT_ID = "123456" DEVICE_NAME = "dev_relay_01" DEVICE_KEY = "your_device_key_here" client = mqtt.Client(client_id=DEVICE_NAME, protocol=mqtt.MQTTv311) client.username_pw_set(PRODUCT_ID, DEVICE_KEY) client.connect("mqtt.heclouds.com", 1883, 60) # OneNet MQTT 数据上报 topic topic = f"$sys/{PRODUCT_ID}/{DEVICE_NAME}/dp/post/json" payload = { "datastreams": [ {"id": "temperature", "datapoints": [{"value": 26.5}]}, {"id": "humidity", "datapoints": [{"value": 60.2}]} ] } client.publish(topic, json.dumps(payload), qos=0) time.sleep(1) client.disconnect()

逻辑说明:paho 连接时 client_id 用设备名,用户名用产品 ID,密码用设备 key。OneNet 的 MQTT 端口是 1883,域名以你的产品接入信息页显示为准,通常是mqtt.heclouds.com或带s的加密域名,开发阶段先用不带 TLS 的那一个。topic 必须走$sys/{产品ID}/{设备名}/dp/post/json,这是 OneNet 定义的 datapoint 上报路径。payload 采用 JSON 结构,一个 datastreams 数组可以同时上报多个数据流,正好对应前面的 temperature 和 humidity。

参数说明:qos 这里用 0 就行,温湿度数据允许偶发丢失;qos=1 会多一次确认报文,适合继电器状态这类需要确认的数据。time.sleep(1) 是等发布完成,断线太快会丢包。跑通这段脚本后,前往 OneNet 的“设备详情”页查看数据流,能看到刚上报的 26.5 和 60.2,平台也会自动画出折线图,不需要另外写图表代码。如果没看到数据,检查脚本里的三个标识符是不是复制多了空格或换行。

3. STM32 与 ESP8266 的硬件连接和 MQTT 数据上行

3.1 ESP8266 与 STM32 连接原理图:电平、供电和引脚选择

ESP8266 常用的是 ESP-01 或 NodeMCU。如果直接把 ESP8266 的 TX 接到 STM32 的 RX,两者逻辑电平都是 3.3V,不需要转换,但很多板载 USB 转串口模块会引入 5V 干扰。建议给 ESP8266 单独供电,避免从 STM32 的 3.3V 引脚取太大电流,WiFi 发射瞬间电流能到 300mA,会拉低单片机供电。常见连接关系如下:

STM32 引脚ESP8266 引脚说明
PA9 / USART1_TXRXSTM32 发送 AT 指令给 ESP8266
PA10 / USART1_RXTXESP8266 回显和 MQTT 下行数据
3.3VVCC、EN/CH_PD供电,EN 必须拉高
GNDGND共地

除表格中的引脚,GPIO0 在正常运行时要保持悬空或通过 10k 电阻上拉到 3.3V,不能接 GND,否则上电后进入烧录模式。温湿度传感器 DHT22 数据脚接 STM32 PB12,继电器 IN 接 PB13。DHT22 也是 3.3V 供电,数据线上要加 4.7k 上拉电阻到 3.3V。有些继电器模块带光耦隔离,需要 VCC 和 GND 独立供电,继电器电源不要和单片机共用,避免电机、水泵启停时把单片机复位。

3.2 用 AT 指令初始化 ESP8266 并连 WiFi,注意回显超时

STM32 通过串口向 ESP8266 发送 AT 指令。常见做法是把 ESP8266 固件烧成 AT 固件,然后 STM32 只做 AT 指令收发。另一种方式是给 ESP8266 刷 NodeMCU 或 Arduino 固件,让 ESP8266 自己跑 MQTT,STM32 只负责采集数据,通过串口把温湿度发给 ESP8266。AT 固件方案更适合入门,两个芯片职责清楚,STM32 主控逻辑改动小,不用维护两套 SDK。

先做最小联网测试,用串口助手或 STM32 的调试串口逐条发送:

AT+CWMODE=1 AT+CWJAP="你的WiFiSSID","你的WiFi密码" AT+MQTTUSERCFG=0,1,"产品ID","设备名","设备key",0,0,"" AT+MQTTCONN=0,"mqtt.heclouds.com",1883,1 AT+MQTTSUB=0,"$sys/产品ID/设备名/cmd/request/#",1 AT+MQTTPUB=0,"$sys/产品ID/设备名/dp/post/json","{\"datastreams\":[{\"id\":\"temperature\",\"datapoints\":[{\"value\":26.5}]}]}",0

逻辑说明:第一行把 ESP8266 设为 Station 模式,第二行连接 WiFi。第三行配置 MQTT 用户参数,0 表示连接 id 为 0 的 MQTT 连接,这里三个标识符的对应关系和前面 Python 脚本一致。第四行建立 MQTT 连接,最后一个 1 表示开启 keepalive。第五行订阅平台下发给设备的命令 topic。第六行发布温湿度数据。

参数说明:AT+MQTTUSERCFG中第二个参数 1 表示使能 MQTT,第三到第五个参数分别是产品 ID、设备名、设备 key,第六和第七个参数是 MQTT 版本号,这里用 0 表示默认,最后一个空字符串是证书。AT+MQTTCONN的最后一个 1 代表自动重连,这个对继电器远程控制很重要,WiFi 断了之后 ESP8266 能自动恢复 MQTT 连接。

注意:不同 ESP8266 AT 固件版本的指令带=0的连接编号,旧版本可能叫AT+MQTTCONN不带编号。如果发送返回 ERROR,先执行AT+RST并用AT+GMR查看固件版本。很多“连不上 WiFi”的问题不是密码错,而是模块固件太老不支持 MQTT AT 指令,需要先升级 AT 固件。

3.3 STM32 端读取 DHT22 并用串口 DMA 发送 MQTT 报文

STM32 端的代码结构不复杂:一个 10ms 定时器做系统心跳,一个 1s 定时器触发 DHT22 采集,采集完成后把温度湿度拼成 JSON,再通过串口 DMA 发送给 ESP8266。用 DMA 发送的原因:ESP8266 在发送 MQTT PUBLISH 包时需要连续写入,CPU 逐字节等待会卡住其他中断。

typedef struct { uint8_t humi_high; uint8_t humi_low; uint8_t temp_high; uint8_t temp_low; } dht22_data_t; typedef struct { int16_t temperature; int16_t humidity; } env_data_t; void send_env_data_to_onenet(const env_data_t *env) { char payload[160]; int len = snprintf(payload, sizeof(payload), "AT+MQTTPUB=0,\"$sys/%s/%s/dp/post/json\"," "{\"datastreams\":[" "{\"id\":\"temperature\",\"datapoints\":[{\"value\":%d.%d}]}," "{\"id\":\"humidity\",\"datapoints\":[{\"value\":%d.%d}]}]}\r\n", PRODUCT_ID, DEVICE_NAME, env->temperature / 10, env->temperature % 10, env->humidity / 10, env->humidity % 10); HAL_UART_Transmit_DMA(&huart1, (uint8_t *)payload, len); }

逻辑说明:snprintf把 AT+MQTTPUB 指令拼成完整字符串,温度值用整数和小数分开输出,比如 26.5 度就发value:26.5。由于 DHT22 返回整数位和小数位分别存储,用env->temperature / 10% 10拆分。最后用HAL_UART_Transmit_DMA把整条 AT 指令交给串口外设发送,CPU 不用等它发完。

参数说明:PRODUCT_IDDEVICE_NAME必须在编译时定义为宏,宏值和平台创建的产品设备一致。payload 数组长度 160,如果以后要加更多数据流,记得改大,snprintf会截断,截断后 AT 指令不完整,ESP8266 返回 ERROR。

DHT22 的读取时序对中断很敏感,建议在读取期间关闭更高优先级中断,或用定时器中断直接读。很多网友用 DHT11 做实验,发现温度值跳变,多半是 GPIO 上下拉时间不对,而不是代码逻辑错误。

3.4 STM32 串口解析 ESP8266 返回和平台下行数据

ESP8266 执行 AT 指令后返回OKERROR,同时订阅的命令 topic 有消息时会主动推送一行数据,形如:

+MQTTSUBRECV:0,"$sys/123456/dev_relay_01/cmd/request/xxx",54,{"cmd":"relay","state":1}

STM32 串口接收中断需要把它们区分开。收到OK可以置一个标志位,收到ERROR则重发上一条指令;收到+MQTTSUBRECV则要按逗号拆分出 topic 和 payload。最简单的方法是先用环形 buffer 存串口数据,然后在主循环里用strstr+MQTTSUBRECV关键字。

void parse_esp8266_line(char *line) { if (strstr(line, "MQTTSUBRECV") == NULL) return; char *payload = strchr(line, '{'); if (payload == NULL) return; cJSON *root = cJSON_Parse(payload); cJSON *cmd = cJSON_GetObjectItem(root, "cmd"); cJSON *state = cJSON_GetObjectItem(root, "state"); if (cJSON_IsString(cmd) && strcmp(cmd->valuestring, "relay") == 0 && cJSON_IsNumber(state)) { HAL_GPIO_WritePin(RELAY_GPIO_Port, RELAY_Pin, state->valueint ? GPIO_PIN_SET : GPIO_PIN_RESET); /* 上报继电器状态 */ } cJSON_Delete(root); }

逻辑说明:串口收到整行后,先查找MQTTSUBRECV标记,再找到第一个{作为 JSON 起点,用 cJSON 库解析。平台下发的cmd字段决定动作类型,state字段控制继电器。如果后续要支持多个继电器,可以在 payload 里再加id字段。

参数说明:state->valueint是 cJSON 对数字的常用表示,OneNet 平台下发 JSON 时无论数字还是布尔都会被转成对应字段。如果你想让继电器默认开,可以在初始化时给RELAY_Pin一个初始状态,避免 ESP8266 还没连上时继电器误动作。如果你用 PlatformIO 构建 STM32 项目,记得把 cJSON 源码加入 lib 目录,否则链接阶段会报未定义符号。

4. MQTT 下行命令控制继电器与协议细节

4.1 OneNet 命令下发是“主题订阅+平台缓存”,不是直接写

MQTT 协议详解的要点就三个:topic 是发布订阅的路径,payload 是不透明的字符串,QoS 控制消息送达级别。OneNet 基于标准 MQTT 3.1.1,所以 STM32 端即使不用 AT 固件,自己用 paho-embedded-c 这一类库也能实现,但用 ESP8266 AT 固件的 MQTT 指令后,协议栈由模组承担,主控不需要实现报文编解码。

在 OneNet 上给继电器发命令,常见两种方式:方式一是通过平台页面“设备详情”里的“下发命令”按钮,填写自定义 JSON;方式二是通过 HTTP API 调用,比如在 Node-RED 里用 HTTP Request 节点请求 OneNet 的 API。无论哪种方式,最终都是向设备订阅的那个cmd/request/主题写数据。

OneNet 的 MQTT 命令下发主题一般格式是:

$sys/{产品ID}/{设备名}/cmd/request/{msgId}

设备端订阅时,用通配符#订阅整个前缀即可。STM32 收到后回复一个响应主题$sys/{产品ID}/{设备名}/cmd/response/{msgId},如果不回复,平台可能显示命令超时。这个 msgId 不是固定值,所以设备端解析 payload 时不要依赖 topic 里的 msgId。

注意:OneNet 对 qos=0 的下发命令,如果设备刚好离线,平台可能直接把命令丢弃。继电器控制需要确认到达,建议在平台 API 或设备端把命令下发 qos 设为 1。MQTT qos=1 会触发 PUBACK,设备端即使没处理,也能知道平台发过。

4.2 网页端控制面板的“指令格式”和 API 调用参数

在 OneNet 控制台添加一个“应用”或“自动化”按钮,把按钮的动作设置为“下发命令”,命令内容:

{"cmd":"relay","state":1}

这里 state 用 1 表示开,0 表示关。如果再要一个“临时开 10 秒”功能,可以写成:

{"cmd":"relay","state":1,"duration":10}

STM32 解析 duration 后,开继电器并启动一个软定时器,10 秒后自动关。这个逻辑在鱼缸换水、水泵控制场景很实用。

如果你要通过自己的网页控制,需要调用 OneNet 的 HTTP API 向设备发送命令。这里给出 curl 示例:

curl -X POST \ -H "Authorization: ${API_KEY}" \ -H "Content-Type: application/json" \ "https://iot-api.heclouds.com/device/{device_id}/cmd" \ -d '{"cmd":"relay","state":1}'

逻辑说明:Authorization头是你在平台生成的 APIKey,device_id 不是设备名,是设备详情页里的数字 ID。如果 APIKey 没有调用权限,HTTP 会返回 401。在 Node-RED 里可以用同样方式实现本地规则,比如温度超过 28 度自动开风扇,再转发到 OneNet 命令。

参数说明:device_idPRODUCT_ID不同,不要搞混。如果你用 Vue3 写控制页面,APIKey 要放在后端服务里,不要直接写成前端常量,否则别人拿到 key 就能控制你的设备。

4.3 STM32 端的命令处理逻辑和继电器状态回传

命令处理的关键不是收到后马上翻转继电器,而是要先做“防误触”。平台或者网络抖动可能下发重复命令,所以要在 STM32 端做一个去重:

#define CMD_IGNORE_INTERVAL_MS 500 static uint32_t last_cmd_time = 0; void handle_relay_cmd(int state, uint32_t now_ms) { if (now_ms - last_cmd_time < CMD_IGNORE_INTERVAL_MS) return; last_cmd_time = now_ms; HAL_GPIO_WritePin(RELAY_GPIO_Port, RELAY_Pin, state ? GPIO_PIN_SET : GPIO_PIN_RESET); /* 回传继电器状态给 OneNet */ char payload[128]; snprintf(payload, sizeof(payload), "AT+MQTTPUB=0,\"$sys/%s/%s/dp/post/json\"," "{\"datastreams\":[{\"id\":\"relay_state\",\"datapoints\":[{\"value\":%d}]}]}\r\n", PRODUCT_ID, DEVICE_NAME, state); HAL_UART_Transmit_DMA(&huart1, (uint8_t *)payload, strlen(payload)); }

逻辑说明:now_ms由系统的 1ms tick 提供,两次命令间隔小于 500ms 直接忽略。继电器执行后,马上上报relay_state,这样手机端页面能立刻看到开关状态。如果不回传,你在 OneNet 图表里只能看到温湿度,继电器状态永远空着。

参数说明:CMD_IGNORE_INTERVAL_MS要大于串口传输一条 AT 指令的时间。如果 WiFi 慢,500ms 可能不够,可以调到 1 秒。但别太长,否则用户连续点击按钮会有失灵感。

4.4 常见误用:把 AT 指令放在中断里发,导致命令丢行

很多人会把HAL_UART_Transmit_DMA放在串口接收中断里,直接对收到的一行数据进行回复。这在高频命令下发时容易出问题:因为 AT 指令需要以\r\n结尾,如果前一条还在发送,后一条就进入 DMA 队列,ESP8266 会把两条指令粘连,返回ERROR

正确做法是设置一个发送状态机,只有在上一条指令收到OKERROR后才发送下一条。在 main loop 里维护一个发送队列:

typedef struct { char data[256]; uint8_t length; } esp_cmd_t; #define CMD_QUEUE_SIZE 8 static esp_cmd_t cmd_queue[CMD_QUEUE_SIZE]; static uint8_t cmd_head = 0, cmd_tail = 0; void esp_send_cmd(const char *cmd) { if ((cmd_head + 1) % CMD_QUEUE_SIZE == cmd_tail) return; /* 队列满 */ memcpy(cmd_queue[cmd_head].data, cmd, strlen(cmd) + 1); cmd_queue[cmd_head].length = strlen(cmd); cmd_head = (cmd_head + 1) % CMD_QUEUE_SIZE; esp_send_from_queue(); }

逻辑说明:所有要发给 ESP8266 的 AT 指令先进环形队列,串口 DMA 发送完成中断会触发esp_send_from_queue(),把下一条取出发送。温湿度上报、继电器回传、设备初始化都调用esp_send_cmd,就不会出现指令粘连。

这个队列长度 8,对继电器控制足够。如果以后要发 OTA 升级或批量上报,需要加大缓冲区,或者用空闲 DMA 中断来判断串口发完。STM32 HAL 的HAL_UART_TxCpltCallback可以作为发送完成钩子。调试时优先看串口返回:

串口返回含义操作
OK上一条指令执行成功发送队列下一条
ERROR指令格式错误或执行失败重试,超过 3 次复位
+MQTTSUBRECV收到平台命令解析 JSON
+MQTTDISCONMQTT 连接断开等待重连

5. 排查、可视化与工程化落地

5.1 用 MQTTX 和 Node-RED 验证 OneNet 命令链路

开头用 Python 脚本测了数据上报,下行链路建议用 MQTTX 从电脑模拟设备订阅命令。使用 MQTTX 连接 OneNet 时,client ID 填设备名,用户名填产品 ID,密码填设备 key,服务器地址填mqtt.heclouds.com:1883。连接后订阅$sys/{产品ID}/{设备名}/cmd/request/#,然后在 OneNet 控制台点击“下发命令”,MQTTX 里能看到命令文本,就说明平台到 MQTT 链路没问题。

如果要在本地做更复杂的联动,用 Node-RED 很合适。常见做法是新增一个 MQTT In 节点订阅同一主题,再接一个 Function 节点判断温度,最后通过 OneNet HTTP API 下发继电器命令。这样不依赖平台自带的规则引擎,调试时能看到每条消息的时间戳和 payload。

5.2 三个必调的工程参数:keepalive、DMA 缓冲、看门狗

生产部署时,把 ESP8266 的 MQTT keepalive 从默认的 60 秒改成 30 秒,OneNet 对心跳超时判定较严格,太长的 keepalive 会让平台误判设备离线。DMA 接收缓冲建议 512 字节,OneNet 下发的命令如果带时间戳,很容易超过 256 字节。STM32 的 IWDG 看门狗超时设置成 1 秒,每个主循环喂一次,防止 ESP8266 AT 指令卡死导致系统挂起。

5.3 掉线自动恢复的完整状态机

最后给出一个适合移植到 RTOS 的连接状态机:

typedef enum { ESP_STATE_RESET, ESP_STATE_SET_WIFI, ESP_STATE_MQTT_CONN, ESP_STATE_READY, ESP_STATE_WAIT_RECONNECT } esp_state_t;

状态机从 RESET 开始,每个状态只做一件事,收到OK才迁移到下一状态;收到ERROR则回到本状态重试;连续 3 次失败跳到 RESET,重新AT+RST。WAIT_RECONNECT 状态延时 5 秒后重新走 WiFi 配置流程。这样可以应对路由器重启、WiFi 密码变更、OneNet 服务端断开连接等场景。

验证方法:断开路由器电源 30 秒,再恢复,观察 STM32 串口日志,应该看到AT+CWJAP重连成功,然后AT+MQTTCONN返回 OK,主板继电器状态不丢失。如果你用的是 FreeRTOS,把每个状态单独做成一个 task,用消息队列传递串口行数据,比在主循环 while 里等串口更可靠。

调试阶段把 OneNet 的 TLS 加密关掉,端口用 1883,开发完成后再考虑加密和证书。确保 device id、产品 ID、APIKey 全部写在独立头文件中,不要散落在业务代码里。把心跳时间、重试次数、队列深度单独做成宏,现场调参时只需改一个文件。后期想把继电器接入智能音箱做语音控制,只要在云端应用层增加一个 HTTP 接口,依旧走这条cmd/request主题,STM32 端代码完全不用动。

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

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

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

立即咨询