说实话,做智能家居项目最烦的不是硬件,也不是界面,而是中间那段“数据链路”。传感器读数只能在自己电脑上串口调试,换到手机或者另一台机器就全瞎了,更别说人不在家时远程看一眼温度、远程开个开关。这次我们做的是完整打通的项目:STM32 采集温湿度,通过 ESP8266 以 MQTT 协议上报华为云 IoT 平台,Qt 写的跨平台桌面控制台再通过平台 API 拉取设备影子和下发控制指令。整个过程覆盖从硬件到云再到客户端的完整链路,适合有一定嵌入式基础、想了解物联网产品化流程的朋友参考。这篇会把每一步拆开讲,包括平台配置、报文构造、API 调用,还有我联调时踩过的几个坑。
1. 先理清链路:为什么是 STM32 采集、华为云中转、Qt 控制台
1.1 这套系统到底解决什么问题
如果只是做一个“能跑的 demo”,其实最简单的方式是 STM32 直接接一个 OLED 屏,把温湿度打上去,完事。但那样的问题是:数据只能留在设备本地,人在客厅都看不到,更别说远程。真正做智能家居,核心需求是三个:远程查看状态、远程下发控制、历史数据留存。
我这次设定的场景就是模拟一个“温室/房间环境监控+风扇控制”的小系统。STM32 通过 DHT22 采集温度和湿度,通过继电器控制一个直流小风扇或者灯珠,模拟开窗、开风扇这类动作。ESP8266 负责联网,把数据以 MQTT 报文发到华为云 IoT 平台。Qt 写的桌面应用运行在笔记本上,虽然叫“桌面控制台”,但因为 Qt 跨平台,Windows 和 Linux 都能编译运行,所以拿到哪儿都能用,这就是“跨平台”的含义。
1.2 三层架构的职责边界
这套系统的链路非常清晰,一共分三层:
| 层级 | 承担角色 | 核心职责 |
|---|---|---|
| 设备端 | STM32 + ESP8266 | 传感器数据采集、继电器控制、MQTT 报文收发 |
| 云平台 | 华为云 IoT 平台 | 设备鉴权接入、产品模型解析、设备影子存储、规则引擎流转 |
| 客户端 | Qt 桌面应用 | 读取设备影子、展示实时状态、下发控制命令 |
STM32 只负责“感知和控制”,不关心数据怎么存储、界面怎么展示;华为云只负责“接入和转发”,不关心设备端是 STM32 还是 Arduino;Qt 只负责“展示和操作”,不关心底层是 Wi-Fi 还是以太网。这样分层的好处是:以后把 DHT22 换成其他传感器,或者把 Qt 换成手机 App,其他两层基本不用动。
1.3 备选方案对比:为什么不用其他方式
我见过很多方案,最容易想到的是局域网直连:ESP8266 开一个 TCP Server,Qt 直接连内网 IP。优点是代码少、不需要云平台,缺点也很致命:出了家门就完全没法访问,而且路由器 IP 一变就得重新配置。再一个是自建 MQTT Broker,比如在云服务器上装 EMQX,可控性强,但需要自己有公网服务器,还要配证书、维护服务、处理设备鉴权,工作量一下子大很多。
华为云 IoT 这类平台的价值在于:设备接入这一层已经帮你做好了。它有产品模型来定义数据类型,有设备影子来缓存最新状态,有规则引擎做数据流转和告警,还有现成的 REST API 给客户端调用。你要写的东西比我自建一套 Broker 少得多,而且平台侧的设备管理、在线状态、消息跟踪这些都是自建方案很难快速复制的。对于智能家居这种“设备数量不算特别多、但要求稳定和易用”的场景,云平台方案是性价比最高的选择。
2. STM32 端:从传感器数据到 MQTT 报文上云
2.1 硬件清单与接线
硬件清单其实很常规,都是比较容易买到的东西:
| 模块 | 型号/规格 | 说明 |
|---|---|---|
| 主控 | STM32F103C8T6 开发板 | 蓝色 pill 板,性价比高 |
| 温湿度传感器 | DHT22 | 单总线协议,精度比 DHT11 高不少 |
| Wi-Fi 模块 | ESP8266-01S | 串口转 Wi-Fi,AT 指令控制 |
| 执行器 | 继电器模块 + 风扇/LED | 用 STM32 GPIO 高低电平控制 |
| 电源 | 5V USB 供电 + 3.3V LDO | 注意 ESP8266 和 STM32 供电分开 |
接线方面有一个特别容易翻车的地方:串口交叉。STM32 的 USART2_TX 是 PA2,要接 ESP8266 的 RX;USART2_RX 是 PA3,要接 ESP8266 的 TX,不能接反。DHT22 数据脚接 PA6,继电器控制脚接 PA7,这两个脚最好避开默认的调试串口,避免上电时打印信息干扰。
2.2 用 ESP8266 打通 TCP 通道:AT 指令配置
ESP8266 在这套系统里只干一件事:建立 TCP 连接,然后把数据原样透传出去。MQTT 报文不是它构造的,是 STM32 通过串口把已经组好的二进制报文丢给它,它负责发到华为云的 MQTT 端口。之所以这样做,是因为市面上的 ESP8266 AT 固件版本参差不齐,有些固件内置了 MQTT AT 指令,有些没有,直接用 TCP 透传最通用,也方便排查问题。
配置步骤分三个阶段,每个阶段都要耐心等待 AT 回显:
| 阶段 | AT 指令 | 预期返回 |
|---|---|---|
| 基础测试 | AT | OK |
| 设为 Station 模式 | AT+CWMODE=1 | OK |
| 连接 Wi-Fi | AT+CWJAP="SSID","PASSWORD" | WIFI CONNECTED然后OK |
| 建立 TCP 连接 | AT+CIPSTART="TCP","iot-mqtts.cn-north-4.myhuaweicloud.com",1883 | CONNECT OK |
| 开启透传 | AT+CIPMODE=1 | OK |
| 开始透传 | AT+CIPSEND | > |
有一个经验值得分享:如果AT+CWJAP一直返回FAIL,先别急着怀疑密码,检查一下模块的供电电流。ESP8266 在 Wi-Fi 连接瞬间电流峰值能到 300mA 以上,如果直接从 STM32 的 3.3V 引脚取电,非常容易掉电重启。我的做法是给 ESP8266 单独用一颗 AMS1117-3.3 从 5V 稳压供电,和 STM32 的 3.3V 域分开,共地就行。
2.3 手写 MQTT 报文:CONNECT 和 PUBLISH 的关键结构
进入透传模式之后,STM32 要向服务器发送的第一包数据就是 MQTT CONNECT 报文。MQTT 3.1.1 的报文结构分成固定头、可变头、载荷三部分,CONNECT 的固定头字节是0x10,后面紧跟剩余长度,这个长度是“可变头 + 载荷”的总字节数,不算固定头那两个字节本身。
我用的报文构造思路大致是这样的(C 语言伪代码):
uint8_t buf[256]; uint8_t p = 0; // 固定头 buf[p++] = 0x10; // CONNECT uint8_t len_pos = p++; // 剩余长度,先占位,最后回填 // 可变头:协议名 MQTT,协议级别 4(3.1.1) buf[p++] = 0x00; buf[p++] = 0x04; buf[p++] = 'M'; buf[p++] = 'Q'; buf[p++] = 'T'; buf[p++] = 'T'; buf[p++] = 0x04; buf[p++] = 0xC2; // 连接标志:Username=1,Password=1,CleanSession=1 buf[p++] = 0x00; buf[p++] = 0x3C; // Keep Alive = 60 秒 // 载荷:依次写入 clientId、username、password,每段前面加两字节长度 // clientId 格式类似:{device_id}_0_0_{timestamp} // username 就是 device_id // password 是平台用 HMAC-SHA256 生成的密钥 // 最后回填剩余长度 buf[len_pos] = p - 2;这里最容易出问题的是密码生成。华为云 IoT 的 MQTT 接入不是简单的“用户名+密码”直连,密码是用设备注册时的密钥对当前时间戳做 HMAC-SHA256,再 Base64 编码后的结果。时间戳一变,密码就变。所以不能把密码写死,必须由程序每次连接前用当前时间戳动态生成,然后拼进 CONNECT 报文里。
连接成功后,上报属性用 PUBLISH 报文,QoS 选 0 就够了:
// 固定头 0x30 表示 PUBLISH QoS0 // Topic: $oc/devices/{device_id}/sys/properties/report // Payload: JSON 字符串对应的 JSON 长这样:
{ "services": [ { "service_id": "sensor", "properties": { "temperature": 26.5, "humidity": 60.3 } } ] }这个 JSON 的结构不是随便写的,它必须和平台上定义的产品模型一一对应,后面我会专门讲平台侧怎么配。
2.4 属性上报之后,怎么保持设备一直在线
设备不是发一条数据就完事了。华为云 IoT 会在 CONNECT 报文里的 Keep Alive 时间过后,检查设备有没有心跳。我这边 Keep Alive 设的是 60 秒,所以程序里每隔 30 秒就主动发一次PINGREQ报文,或者顺便再发一次属性上报。如果长时间不发任何报文,平台会判定设备离线,后面 Qt 那边查在线状态就会看到 device offline。
心跳这块我在代码里是这么处理的:主循环里维护一个last_publish_time变量,每次 DHT22 采集完成并上报成功后更新。如果距离上次上报超过 30 秒,就直接发一包PINGREQ(固定头0xC0,剩余长度0x00)。这样既保证了设备不掉线,也避免了频繁上报浪费流量。
3. 华为云 IoT 平台:产品模型、设备注册与规则引擎配置
3.1 创建产品和定义模型时容易忽略的字段
在平台上创建产品时,最关键的几个选择是:行业类型选“智能家居”,协议选“MQTT”,数据格式选“JSON”。有些选项创建后不能改,比如协议类型,所以一开始就要想清楚,不然只能删掉重建。
产品和模型定义是整个云端配置的核心,因为平台会拿它作为“翻译字典”:设备上报的 JSON 要跟模型属性匹配,客户端下发的命令也要跟模型里的命令定义匹配,任何一个对不上,数据就进不到设备影子。
我定义的服务和属性如下:
| 服务 | 属性/命令 | 类型 | 取值范围/说明 |
|---|---|---|---|
| sensor | temperature | decimal | -40.0 ~ 80.0 |
| sensor | humidity | decimal | 0.0 ~ 100.0 |
| control | set_switch 命令 | int | 0 或 1,0 关 1 开 |
这里有一个很多人踩过的坑:属性类型定义成 int,但设备上报的 JSON 里带了小数点,比如温度 26.5。平台解析的时候会直接丢弃这条上报,甚至报数据格式错误。所以温度湿度这种要定义成 decimal,开关这种用 int,两者不能混。
3.2 设备注册与 MQTT 连接参数生成逻辑
产品创建完,需要在产品下注册一个设备。设备标识符可以自己取名,比如stm32_sensor_01,注册的时候平台会给一个设备密钥。之后在设备详情页面能看到“设备接入地址”,以华北-北京四为例,MQTT 接入地址格式类似iot-mqtts.cn-north-4.myhuaweicloud.com,端口 1883(明文)或 8883(TLS)。
连接参数里三个最关键的东西是:
- clientId:规则是
{device_id}_0_0_{timestamp},最后的 timestamp 必须和生成密码时用的时间戳一致。 - username:就是设备 ID 本身。
- password:用设备密钥对 timestamp 做 HMAC-SHA256,再 Base64 编码。
这个算法平台控制台里有个“连接参数生成工具”可以帮你看一眼格式,但正式设备上不能依赖网页工具,必须在代码里实现。STM32 那边资源有限,所以我的做法是先用 Python 脚本或者平台工具生成一次连接参数,验证整条链路通不通,确认无误后,再把密钥和时间戳生成逻辑固化到硬件端。如果你用 ESP32 这类资源更充足的芯片,也可以直接跑现成的 MQTT 库,让库来生成报文。
3.3 规则引擎联动:让数据既进影子也跑流转
默认情况下,设备上报属性后,平台会更新设备影子。Qt 通过设备影子 API 就能拿到最新值。但光有影子还不够,智能家居场景里经常需要“温度超过阈值自动告警”,这个就靠规则引擎实现。
我在平台里建了一条规则:当上报的数据里temperature > 30时,触发一个动作,把事件转发到对象存储服务,同时给应用侧推送一条告警消息。规则引擎的配置逻辑简单说就是“数据源 + 条件 + 动作”。数据源选设备属性上报,条件写 JSON 路径表达式,动作选择转发目标。这里我建议一开始先做两个动作:高低温告警和历史数据落库。历史数据落库非常有用,后面做趋势分析、月报统计都靠它。
4. Qt 桌面控制台:用 API 拉取设备影子并下发指令
4.1 为什么 Qt 端不走 MQTT 直连
很多人的第一反应是:Qt 端也写一个 MQTT 客户端,订阅设备的 Topic 不就行了?实际做下来会发现这条路走不通。华为云 IoT 平台的 Topic 权限是绑定到设备维度的,设备 A 不能订阅设备 B 的 Topic,这是平台的安全隔离策略。Qt 端如果以“设备身份”接入,它只能收发自己那套$oc/devices/xxx/sys/...主题,收不到 STM32 设备的数据。
所以 Qt 端的正确定位是“应用侧”,也就是通过平台提供的 REST API 来读写数据。最直接的方式就是调用设备影子的查询接口,获取 STM32 最新上报的温湿度和开关状态。实时性上,影子数据本身就是设备上报后实时更新的,Qt 这边用定时器每 2 秒轮询一次,完全够用,而且代码简单得多,不用维护一条长连接。
4.2 获取 Token 与设备影子,两个关键请求
调用华为云 IoT 的 API 之前,需要先通过 IAM 服务获取临时 Token。Token 就是访问凭证,有效期一般比较长,但后台运行超过有效期后会失效,所以代码里要在收到401时重新获取。
获取 Token 的请求用 Qt 的QNetworkAccessManager就能搞定:
QNetworkRequest tokenReq(QUrl("https://iam.myhuaweicloud.com/v3/auth/tokens")); tokenReq.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); QJsonObject passwordObj; QJsonObject userObj; userObj.insert("name", "your_username"); userObj.insert("password", "your_password"); QJsonObject domainObj; domainObj.insert("name", "your_account_name"); userObj.insert("domain", domainObj); passwordObj.insert("user", userObj); QJsonObject identityObj; identityObj.insert("methods", QJsonArray{"password"}); identityObj.insert("password", passwordObj); QJsonObject scopeObj; QJsonObject projectObj; projectObj.insert("name", "cn-north-4"); scopeObj.insert("project", projectObj); QJsonObject authObj; authObj.insert("identity", identityObj); authObj.insert("scope", scopeObj); QJsonObject bodyObj; bodyObj.insert("auth", authObj);注意:Token 不在响应体的 JSON 里,而是在 HTTP 响应头X-Subject-Token字段里,直接用reply->rawHeader("X-Subject-Token")取。
拿到 Token 之后,查询设备影子就是一个很干净的 GET 请求:
QNetworkRequest shadowReq(QUrl( "https://iotda.cn-north-4.myhuaweicloud.com/v5/iot/" + projectId + "/devices/" + deviceId + "/shadow" )); shadowReq.setRawHeader("Authorization", ("Bearer " + token).toUtf8()); QNetworkReply *reply = manager.get(shadowReq);返回的 JSON 结构里,重点看services数组下reported字段:
{ "services": [ { "service_id": "sensor", "reported": { "temperature": 26.5, "humidity": 60.3 } } ] }Qt 端解析的时候用QJsonDocument::fromJson,一层层取出来刷新界面上的 QLabel 就行了。
4.3 下发控制命令,从按钮到设备动作的完整路径
Qt 端控制继电器,不是直接给 STM32 发网络包,而是调用平台的下发命令 API。平台收到后,会根据产品模型里的命令定义,把命令推送到 STM32 订阅的命令主题上,STM32 收到命令后解析并执行 GPIO 控制。
下发命令的接口是 POST:
QNetworkRequest cmdReq(QUrl( "https://iotda.cn-north-4.myhuaweicloud.com/v5/iot/" + projectId + "/devices/" + deviceId + "/commands" )); cmdReq.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); cmdReq.setRawHeader("Authorization", ("Bearer " + token).toUtf8()); QJsonObject paras; paras.insert("status", 1); QJsonObject body; body.insert("service_id", "control"); body.insert("command_name", "set_switch"); body.insert("paras", paras); QNetworkReply *reply = manager.post(cmdReq, QJsonDocument(body).toJson());STM32 那边订阅的是$oc/devices/{device_id}/sys/commands/#这个主题,收到平台下发的命令后,解析出command_name是set_switch,再看paras.status是 0 还是 1,然后控制 PD7 引脚电平翻转。
这里还要注意命令的应答闭环:STM32 执行完命令后,应当向$oc/devices/{device_id}/sys/commands/response/request_id={请求ID}回复一条响应。这样 Qt 端才能通过平台查到“命令已执行成功”的结果,而不是只知道“命令已下发”。
4.4 轮询刷新与线程化:别把界面卡死
Qt 端用QTimer每 2 秒触发一次影子刷新请求,这个操作虽然是异步的,但要注意:不能把网络请求放在主线程里同步等待,否则界面会卡住。QNetworkAccessManager本身就是异步的,所以只需要在请求完成后通过信号槽更新界面,不会阻塞 UI。
我在实际写的时候发现一个体验问题:如果用户快速点击“开”“关”按钮,会导致命令请求堆积,设备那边可能还来不及执行,界面状态已经跳了好几次。后来我在按钮点击处理里加了一个标志位:上一次命令的响应还没回来时,忽略新的点击,等响应回来再恢复。这样虽然看起来“反应慢了一点”,但逻辑上不会乱,对于智能家居控制这种场景,稳定性比响应速度更重要。
5. 联调阶段最常踩的坑:从设备离线到 API 401 的排查链路
5.1 设备一直离线:先怀疑这四个地方
我在联调时遇到的第一大坑就是设备注册后一直离线。排查顺序很重要,别一上来就怀疑硬件。我建议按下面这个顺序查:
| 排查项 | 具体检查点 |
|---|---|
| clientId 格式 | 是不是{device_id}_0_0_{timestamp} |
| 密码时效 | 时间戳是不是最新的,密码有没有过期 |
| 接入地址 | 域名和端口是不是对应区域 |
| AT 透传状态 | ESP8266 是否真的进入>透传模式 |
其中最容易忽略的是 clientId 里的时间戳和密码的时间戳必须一致。我一开始犯过这个错:密码是用网页工具生成的,clientId 里的时间戳是程序运行时的毫秒时间,两个时间差了若干秒,平台至少会因为密码校验失败而拒绝连接。后来我把时间戳固定成一个变量,先拼完 clientId 再用同一个时间戳生成密码,问题就消失了。
还有一个排查工具特别好用:MQTTX 这类桌面 MQTT 客户端。先用 MQTTX 填好同样的 clientId、username、密码连华为云,如果 MQTTX 也连不上,问题基本就在连接参数上,和硬件代码无关,排除起来非常快。等 MQTTX 能连上了,再回头查 STM32 的报文构造。
5.2 报文能发出但平台收不到数据:JSON 和设备模型不匹配
设备已经显示在线,但在平台“设备详情”里看不到任何属性数据。这种情况八成是上报的 JSON 和产品模型不完全一致。比如我一开始把产品模型里温度属性定义为int,但上报 JSON 里温度是26.5,平台解析失败,整条消息被丢掉。
排查方法是打开平台控制台的“消息跟踪”功能,能看到设备每条消息的处理状态。消息跟踪里如果提示parsing failed或者service not found,那就是模型和 JSON 对不上。还有一种情况:service_id写错了,比如模型里服务叫sensor,上报写成Sensor,大小写不一样也会失败。这种问题只看代码很难发现,因为硬件那边发送很成功,TCP 也发出去了,但云平台根本不认这个服务。
5.3 Qt 端拿不到最新数据:影子轮询的刷新时机
设备上报成功后,影子数据理论上立即更新,但 Qt 端如果刷新的时机不对,会看到数据一直不变。我之前遇到的情况是:Qt 界面已经显示了第一次拿到的值,但后续设备上报的新值一直没出现。
后来发现,问题不是影子没更新,而是 QTimer 的槽函数里,网络请求的回调把旧数据覆盖了。因为我用了同一个 QNetworkReply 指针,如果上一次请求还没完成,下一次 QTimer 触发时把 reply 覆盖掉,上一次的响应信号就永远等不到了。这个问题的解法很简单:每次请求前disconnect旧连接,或者每次创建独立的 QNetworkReply,在 lambda 里通过捕获当前 reply 来判断是不是自己这个响应。
5.4 下发命令不见反应:命令格式、在线状态与产品模型三层排查
命令下发了,但继电器纹丝不动,这个现象要把排查分成三层。第一层看命令请求本身,用类似 Postman 的工具直接调接口,如果返回400或404,检查service_id、command_name是不是模型里定义的原名。第二层看设备在线状态,华为云对离线设备下发命令,默认会失败并返回设备离线。第三层看 STM32 端有没有订阅对主题,如果订阅的是$oc/devices/{device_id}/sys/commands/#,但收到的命令里 request_id 参数解析错误,也会导致应答发不出去。
这里我要特别强调命令名称的大小写。华为云模型定义里的命令名是按你填写的原样保存的,如果模型里是set_switch,但命令请求体里写成setSwitch,平台会返回命令不存在。这个坑很好避免:控制台定义模型时,给命令起一个代码里完全一致的名字,别搞驼峰和下划线混用这套。
5.5 长时间运行设备掉线:心跳、重连与看门狗
联调刚通过时一切正常,但跑了几个小时后设备突然离线,这基本就是心跳和重连机制没做好。STM32 端只有一个摄像头式的主循环,如果主循环里因为某个阻塞操作卡住几秒,心跳就可能错过。
我的处理方式是在主循环里加了一个超时看门狗:每次循环检查距离上次心跳发送的时间,如果超过 40 秒还没发出去,就强制重新初始化 ESP8266 连接,重新走一遍 TCP + MQTT CONNECT 流程。这样做虽然粗暴,但非常有效,尤其是在家里 Wi-Fi 不稳定、路由器偶尔重启的情况下,设备能自己恢复,不用人跑过去按复位键。
另外,ESP8266 的 AT 指令回显处理也要注意:透传模式下退出透传需要发+++,但+++前要保证串口空闲一段时间,否则会被当作普通数据发出去。我在代码里发+++前先延时 300ms,然后等待OK,再执行AT+CIPCLOSE关掉旧连接,最后重新拨号连接。这套重连流程实测下来比较可靠。
最后再说几句
整套系统调通之后,我个人的体会是:硬件端手写 MQTT 报文反而是整个项目里最顺利的部分,真正消耗时间的是云平台配置和 API 鉴权这些“看起来简单、文档却要仔细看”的环节。如果你也想复刻这个项目,建议你先把 QMQTTX 验证连接参数这一步做完,再烧 STM32 固件,可以帮你省掉一半的联调时间。
另外,这个项目后续扩展空间很大。硬件端可以再加一路 PM2.5 传感器,云端规则引擎已经开始在落历史数据,Qt 客户端下一步可以做温度趋势曲线,配合 local 的数据库缓存,断网时也可以通过内网直连控制设备。智能家居没有所谓“标准答案”,把这条链路跑通之后,剩下的就是往里面加你真正需要的设备和逻辑了。