上一篇我们把 MQTT 跑通了:电脑上开个 mosquitto,ESP32 连上去发个温度,串口打印
connected。但那只是"我自己的 broker"。要让手机 App 能看数据、能点按钮控灯 ,设备得接到公有云物联网平台上去。
我第一次做的时候以为"不就是把 broker 地址换一下吗",结果连了三天没连上------卡住我的根本不是 MQTT 协议,是"鉴权参数怎么算"和"Topic 怎么拼"。
适用人群:会用 ESP32/STM32 发 MQTT(知道 publish/subscribe、QoS、keepalive),想把设备接到阿里云或华为云的同学。
读完你能得到:三元组是什么 → 两家平台的 ClientID/Username/Password 手算(附 Python)→ Topic 与属性上报 JSON → 命令下发的同步/异步 → ESP32 代码骨架 → 踩坑表 + 5 步动手练。
一、为什么"换 broker 地址"连不上
自建 broker 时你可以不加用户名密码,因为它不认识你也不在乎你是谁。公有云挂着千万台设备,必须在第一个字节之前确认你是谁,于是多了三件事:
- 先签名:Password 是用设备密钥算出来的 HMAC 值,不是明文密码;
- Topic 不能乱起 :发到哪、能发什么,是平台按产品划好的,拼错没有报错,只是数据石沉大海;
- 格式是约定的 :你发
{"t":26.5}平台看不懂,它只认物模型里定义的属性名和类型。
text
[图 1] 设备 → 云 → App 链路上,每层各管什么
┌────────┐ ┌────────────────────────────┐ ┌────────┐
│ 设备 │ MQTT │ 物联网平台 │ API │ 手机App │
│ ESP32 │ over TLS │ ① 接入鉴权(三元组签名) │ <─────> │ 看数据 │
│ 上报温度│ <═══════> │ ② Topic 路由 + 权限 │ │ 下发指令│
│ 执行命令│ │ ③ 物模型解析(Alink/产品模型)│ └────────┘
└────────┘ │ ④ 设备影子(离线缓存指令) │
└────────────────────────────┘
本文讲 ①②③,第 ④ 层见下一篇《设备影子与物模型》
前置概念表
| 术语 | 白话解释 | 你为什么要在意 |
|---|---|---|
| 三元组 | 阿里云的 ProductKey/DeviceName/DeviceSecret |
设备的身份证 + 密码,烧进固件就代表这台设备 |
| 一机一密 | 每台设备烧一份唯一的 DeviceSecret | 最好理解;代价是产线要一台台烧 |
| 一型一密 | 同产品共用 ProductSecret,首次联网动态注册换自己的密钥 | 量产友好;代价是多一次注册流程 |
| 物模型/TSL | 用 JSON 描述产品有哪些属性、事件、服务 | 没定义的属性上报了也会被丢 |
| 属性上报 / 命令下发 | 设备→云 报状态 / 云→设备 发指令 | 上下游数据链路的两端 |
| MQTTS | 跑在 TLS 上的 MQTT,一般 8883 端口 | 明文 1883 会让密码裸奔,生产一律 MQTTS |
⚠️ 华为云叫法不同但本质一样:用设备 ID(device_id) 定位设备、**设备密钥(secret)**签名。别被名词绕晕。
二、三元组就是"账号 + 密码"
阿里云创建产品(如"温控节点")+ 设备(如 esp32_01)后给三个值:
text
ProductKey : a1LhUsK**** ← 产品级 ID,同批产品共用
DeviceName : esp32_01 ← 产品内唯一
DeviceSecret : bdd043d193782d11**** ← 设备密钥,每台不同
ProductKey + DeviceName = 账号,DeviceSecret = 密码。 但你不能直接把密码传过去,所以要用 HMAC 做签名:证明"我知道密码,但我不告诉你"。
🔴 DeviceSecret 在控制台只明文出现一次。我当年截图存了,换电脑时丢了,只能重置密钥,之前烧好的板子全部连不上。第一件事:把三元组扔进密码管理器。
三、连接参数怎么算(两家都给 Python)
3.1 阿里云(一机一密,hmacsha256)
text
mqttClientId : {clientId}|securemode=2,signmethod=hmacsha256,timestamp={毫秒时间戳}|
mqttUsername : {DeviceName}&{ProductKey}
content : clientId{clientId}deviceName{DeviceName}productKey{ProductKey}timestamp{毫秒时间戳}
mqttPassword : HMAC-SHA256(key = DeviceSecret, msg = content) → 十六进制字符串
四个必须注意的点:
securemode=2= TLS 直连(8883) ;3= TCP 明文。写 2。content拼接顺序是字典序 clientId → deviceName → productKey → timestamp,不是你喜欢的顺序。clientId自定义(≤64 字符,建议 MAC/SN),content 里的必须和 ClientID 里那段完全一致。timestamp毫秒值,可不传;传了平台就会校验时间,设备时间差太多直接被拒。
python
# ali_sign.py ------ 阿里云物联网平台一机一密连接参数
import hmac, hashlib, time
def ali_sign(product_key, device_name, device_secret, client_id=None, ts_ms=None):
if client_id is None:
client_id = product_key + "." + device_name # 官方默认 pk.dn
ts = str(ts_ms if ts_ms else int(round(time.time() * 1000)))
# 拼接顺序 = 参数名字典序
content = "clientId{}deviceName{}productKey{}timestamp{}".format(
client_id, device_name, product_key, ts)
sign = hmac.new(device_secret.encode("utf-8"),
content.encode("utf-8"), hashlib.sha256).hexdigest()
return {
"client_id": "{}|securemode=2,signmethod=hmacsha256,timestamp={}|".format(client_id, ts),
"username": "{}&{}".format(device_name, product_key),
"password": sign,
}
if __name__ == "__main__":
for k, v in ali_sign("a1LhUsK****", "esp32_01", "bdd043d193782d11****").items():
print("{}: {}".format(k, v))
连上后先只验上行(paho-mqtt 1.6.1 ;2.x 要写 mqtt.Client(mqtt.CallbackAPIVersion.VERSION1, client_id=...)):
python
import json, ssl, time, paho.mqtt.client as mqtt
from ali_sign import ali_sign
PK, DN, DS = "a1LhUsK****", "esp32_01", "bdd043d193782d11****"
HOST = PK + ".iot-as-mqtt.cn-shanghai.aliyuncs.com" # 旧版公共实例
# 新版公共实例/企业版:HOST = "{instanceId}.mqtt.iothub.aliyuncs.com"
PUB = "/sys/{}/{}/thing/event/property/post".format(PK, DN)
SUB = "/sys/{}/{}/thing/event/property/post_reply".format(PK, DN)
info = ali_sign(PK, DN, DS)
c = mqtt.Client(client_id=info["client_id"], protocol=mqtt.MQTTv311)
c.username_pw_set(info["username"], info["password"])
c.tls_set(ca_certs="ali_iot_root.crt", tls_version=ssl.PROTOCOL_TLSv1_2) # 自签名证书 → 8883
c.on_connect = lambda cl, u, f, rc: (print("rc =", rc), cl.subscribe(SUB, 1) if rc == 0 else None)
c.on_message = lambda cl, u, m: print("reply:", m.payload.decode())
c.connect(HOST, 8883, keepalive=300)
c.loop_start()
c.publish(PUB, json.dumps({
"id": "1001", "version": "1.0", "sys": {"ack": 1},
"params": {"temperature": 26.5}, # 属性名必须和物模型标识符一致
"method": "thing.event.property.post"}), qos=1)
time.sleep(3); c.loop_stop()
3.2 华为云(密钥鉴权)
text
clientId : {deviceId}_{标识类型}_{密码签名类型}_{时间戳}
│ │ │ └ UTC,格式 yyyyMMddHH
│ │ └ 0=不校验时间戳;1=HMACSHA256 校验时间戳
│ └ 一机一密直连设备填 0
└ 注册设备后平台给的设备 ID
username : {deviceId}
password : HMAC-SHA256(key = 时间戳字符串, msg = deviceSecret) → 十六进制(小写)
和阿里云两个反直觉差异:
- HMAC 的 key/data 是反的 :阿里云"密钥=DeviceSecret、消息=拼接串";华为云"密钥=时间戳、消息=设备密钥"。我第一次把两边搞混,签名死活不对。
- 时间戳是
yyyyMMddHH(UTC,精确到小时),不是毫秒。因为只到小时,设备时间别差过一小时就行。
接入地址在控制台"总览 → 接入信息",形如 xxx.st1.iotda-device.cn-north-4.myhuaweicloud.com,端口 8883(MQTTS)/ 1883(明文)。
python
# hw_sign.py ------ 华为云 IoTDA 密钥鉴权
import hmac, hashlib
from datetime import datetime
def hw_sign(device_id, device_secret, check_stamp=1):
ts = datetime.utcnow().strftime("%Y%m%d%H") # UTC,不是北京时间!
pwd = hmac.new(ts.encode("utf-8"), # key = 时间戳
device_secret.encode("utf-8"), # msg = 设备密钥
hashlib.sha256).hexdigest()
return {"client_id": "{}_0_{}_{}".format(device_id, check_stamp, ts),
"username": device_id, "password": pwd}
参考:阿里云《MQTT-TLS连接通信》《MQTT连接签名示例》,华为云《设备连接鉴权》。华为云的拼接与
HmacSHA256(时间戳为密钥、secret 为内容)可对照官方 SDK 源码MqttConnection.java/IotUtil.java验证。
四、Topic 与 payload
阿里云物模型 Topic 都以 /sys/{pk}/{dn}/ 开头,记四组:
| 方向 | 用途 | Topic |
|---|---|---|
| 上行 | 属性上报 | /sys/{pk}/{dn}/thing/event/property/post |
| 下行 | 上报响应 | .../thing/event/property/post_reply |
| 下行 | 属性设置 | /sys/{pk}/{dn}/thing/service/property/set |
| 上行 | 设置响应 | .../thing/service/property/set_reply |
| 下行 | 服务调用 | /sys/{pk}/{dn}/thing/service/{服务标识符} |
json
{
"id": "1001", "version": "1.0", "sys": {"ack": 1},
"params": {"temperature": 26.5, "LightSwitch": 1},
"method": "thing.event.property.post"
}
成功回 {"code":200,"data":{},"id":"1001","message":"success",...}。
⚠️
code不是 200 就是失败 :常见的有物模型校验失败(属性没定义/类型不对)、9201设备离线。只 publish 不订阅_reply,你根本不知道数据被打回来了。
华为云统一 $oc/devices/{device_id}/sys/...,按服务分组上报:
| 方向 | 用途 | Topic |
|---|---|---|
| 上行 | 属性上报 | $oc/devices/{device_id}/sys/properties/report |
| 下行 | 属性设置 | $oc/devices/{device_id}/sys/properties/set/request_id={id} |
| 下行 | 命令下发 | $oc/devices/{device_id}/sys/commands/request_id={id} |
| 上行 | 命令响应 | $oc/devices/{device_id}/sys/commands/response/request_id={id} |
json
{"services":[{"service_id":"Temperature",
"properties":{"temperature":26.5},
"event_time":"20260101T120000Z"}]}
event_time 格式 yyyyMMddTHHmmssZ(UTC),可选。
五、命令下发:异步 vs 同步
text
[图 2] 两种下行方式
异步(属性设置 / 服务调用)
App ──API──> 平台 ──publish──> 设备执行 ──_reply──> 平台
App <──"下发成功"── (只代表平台发出去了,不代表设备执行了)
同步 RRPC(阿里云)
App ──RRpc API(阻塞)──> 平台 ── /sys/{pk}/{dn}/rrpc/request/{msgId} ──> 设备
设备 publish 到 .../rrpc/response/{msgId}
App <── 响应 或 8 秒超时 ──
| 场景 | 选哪种 |
|---|---|
| 改属性、慢慢生效(设温度阈值) | 异步:属性设置/服务调用 |
| 要结果、几秒内能返回(读当前状态) | 同步 RRPC(超时 8 秒) |
| 设备可能离线、指令不能丢 | 都不是 → 用设备影子(下一篇) |
⚠️ 华为云命令下发也走 sys/commands,设备必须把 request_id 原样拼进响应 Topic 平台才能对上号。下发时可设命令有效期,超时未响应会失效------参数名和默认值以你用的平台版本文档为准,别照抄博客。
六、ESP32 代码骨架(ESP-IDF v5.x)
顺序不能反:先校时 → 再算签名 → 最后连 MQTT。
c
/* aliyun_iot.c ------ 适用:ESP32 / ESP-IDF v5.x(esp-mqtt + mbedtls) */
#include <string.h>
#include <stdio.h>
#include "esp_log.h"
#include "esp_timer.h"
#include "esp_mac.h"
#include "mqtt_client.h"
#include "mbedtls/md.h"
static const char *TAG = "aliyun";
#define PK "a1LhUsK****"
#define DN "esp32_01"
#define DS "bdd043d193782d11****"
#define HOST "mqtts://" PK ".iot-as-mqtt.cn-shanghai.aliyuncs.com"
/* 阿里云自签名根证书 PEM:放 main 目录,用 CMakeLists 的 EMBED_FILES 嵌入
也可直接把 PEM 内容贴成字符串常量 */
extern const char root_ca_pem_start[] asm("_binary_aliyun_root_ca_pem_start");
extern const char root_ca_pem_end[] asm("_binary_aliyun_root_ca_pem_end");
/* HMAC-SHA256 → 小写十六进制(32 字节 = 64 字符 + '\0') */
static int hmac_sha256_hex(const char *key, const char *msg, char *out, size_t out_len)
{
unsigned char mac[32];
const mbedtls_md_info_t *info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
if (info == NULL || out_len < 65) return -1;
if (mbedtls_md_hmac(info, (const unsigned char *)key, strlen(key),
(const unsigned char *)msg, strlen(msg), mac) != 0) return -1;
for (int i = 0; i < 32; i++) snprintf(&out[i * 2], 3, "%02x", mac[i]);
out[64] = '\0';
return 0;
}
static int build_conn_params(char *cid, size_t cid_len, char *user,
size_t user_len, char *pass, size_t pass_len)
{
uint8_t mac[6];
char client_id[16], content[192];
esp_read_mac(mac, ESP_MAC_WIFI_STA); /* MAC 当 clientId,天然唯一 */
snprintf(client_id, sizeof(client_id), "%02x%02x%02x%02x%02x%02x",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
unsigned long long ts = (unsigned long long)(esp_timer_get_time() / 1000ULL); /* ms */
int n = snprintf(content, sizeof(content),
"clientId%sdeviceName%sproductKey%stimestamp%llu",
client_id, DN, PK, ts);
if (n < 0 || n >= (int)sizeof(content)) return -1; /* 截断了就别发出去 */
if (hmac_sha256_hex(DS, content, pass, pass_len) != 0) return -1;
snprintf(cid, cid_len, "%s|securemode=2,signmethod=hmacsha256,timestamp=%llu|",
client_id, ts);
snprintf(user, user_len, "%s&%s", DN, PK);
return 0;
}
void app_main(void)
{
/* ① 连 Wi-Fi(略) ② SNTP 校时,等 sntp_get_sync_status()==SNTP_SYNC_STATUS_COMPLETED
③ 才调用 build_conn_params ------ 没校时时时间戳从 0 起算,平台必然判签名非法 */
char cid[96], user[64], pass[80];
if (build_conn_params(cid, sizeof(cid), user, sizeof(user), pass, sizeof(pass)) != 0) {
ESP_LOGE(TAG, "签名失败"); return;
}
ESP_LOGI(TAG, "cid=%s user=%s", cid, user); /* 调试期打出来,和 Python 端对账 */
esp_mqtt_client_config_t cfg = {
.broker.address.uri = HOST ":8883",
.broker.verification.certificate = root_ca_pem_start, /* 必须校验证书 */
.credentials.client_id = cid,
.credentials.username = user,
.credentials.authentication.password = pass,
.session.keepalive = 300, /* 官方建议 ≥300 s */
};
esp_mqtt_client_handle_t client = esp_mqtt_client_init(&cfg);
if (client == NULL) { ESP_LOGE(TAG, "mqtt init 失败"); return; }
esp_mqtt_client_register_event(client, ESP_EVENT_ANY_ID, mqtt_event_handler, NULL);
esp_mqtt_client_start(client);
}
事件回调(订阅、收数据、看错误码):
c
static void mqtt_event_handler(void *arg, esp_event_base_t base,
int32_t event_id, void *event_data)
{
esp_mqtt_event_handle_t evt = event_data;
switch ((esp_mqtt_event_id_t)event_id) {
case MQTT_EVENT_CONNECTED:
esp_mqtt_client_subscribe(evt->client, "/sys/" PK "/" DN "/thing/service/property/set", 1);
esp_mqtt_client_subscribe(evt->client, "/sys/" PK "/" DN "/thing/event/property/post_reply", 1);
break;
case MQTT_EVENT_DATA: {
char topic[96], data[256];
size_t tl = evt->topic_len < sizeof(topic) - 1 ? evt->topic_len : sizeof(topic) - 1;
size_t dl = evt->data_len < sizeof(data) - 1 ? evt->data_len : sizeof(data) - 1;
memcpy(topic, evt->topic, tl); topic[tl] = '\0';
memcpy(data, evt->data, dl); data[dl] = '\0';
ESP_LOGI(TAG, "TOPIC=%s DATA=%s", topic, data);
/* TODO:区分 set / post_reply,校验 code,执行动作,回 set_reply(id 要一致) */
break;
}
case MQTT_EVENT_ERROR:
ESP_LOGE(TAG, "MQTT 错误,看 error_type / connect_return_code");
break;
default: break;
}
}
💡
connect_return_code4 = 用户名密码错误(签名错了)、5 = 未授权 。看到 4 就别怀疑网络了,九成是签名算错。⚠️ 证书和端口要配套 :阿里云自签名根证书对应 8883 ;老版 Global Sign 根证书对应 1883/443,且 2028-01-28 到期。新项目直接用自签名证书 + 8883,别关证书校验。
七、新手必踩的坑清单
| # | 坑 | 现象 / 后果 | 正确做法 |
|---|---|---|---|
| 1 | 设备时间不同步 | 签名校验失败,RC=4/5 | 上电先 SNTP,校时成功再算签名 |
| 2 | 华为云把北京时间当 UTC | 差 8 小时,跨小时鉴权失败 | 用 utcnow() / gmtime,别用本地时间 |
| 3 | 走 1883 明文传密码 | 链路里能抓到 Password 和全部数据 | 生产一律 MQTTS(8883) + 校验服务器证书 |
| 4 | 证书和端口不配套 | 自签名证书配 1883 → TLS 握手失败 | 自签名→8883;老根证书→1883/443 |
| 5 | DeviceSecret 硬编码传 GitHub | 别人能伪造你的设备上报数据 | 量产用一型一密;密钥放 NVS 加密区/eFuse |
| 6 | 多台设备烧同一套三元组 | 后连的踢先连的,两台疯狂互踢 | 每台唯一三元组;"频繁上下线"先查这个 |
| 7 | 属性类型写错(26.5 写成 "26.5") |
code 非 200,数据被丢 |
严格按物模型类型;订阅 _reply 判 code |
| 8 | Topic 拼错(pk/dn 写反、多斜杠) | publish 成功但平台查不到,零报错 | 把拼好的 Topic 打日志,对照控制台列表 |
| 9 | keepalive < 60 s | 平台主动断开,不停重连 | 官方范围 30~1200 s,建议 ≥300 s |
| 10 | 以为"下发成功"= 设备执行了 | App 显示成功但灯没开 | 异步要等设备 _reply;要结果用 RRPC |
| 11 | RRPC 回调里做耗时操作 | 超过 8 秒云端返回超时 | 只登记意图,先回响应,耗时动作丢给任务 |
| 12 | 上报频率过高 | 触发限流、消息丢弃、流量费飙升 | 按业务定间隔(秒级~分钟级),别贪快 |
八、动手练一练
练习 1:把"三件套"凑齐(30 分钟)。 阿里云免费公共实例(或华为云 IoTDA 免费单元)→ 建产品 → 加一个 temperature 属性(float,读写)→ 建设备 → 复制三元组。先在设备详情页点**"MQTT 连接参数"**,把平台生成好的参数填进 MQTTX,看到"在线"再往下走。
意义:先用平台给的正确答案验证链路是通的,后面自己算签名出错时,就知道问题一定在签名上。
练习 2:Python 手算签名跑通上行。 验收三条:平台日志服务出现"属性上报"、设备收到 post_reply 且 code==200、控制台物模型数据页看到 26.5。
练习 3(故意试错):四连击。 ① DeviceSecret 改一位 → 看 rc(应为 4 );② timestamp 改成 10 分钟前 → 看是否被拒;③ 26.5 改成字符串 → 看 code 变什么;④ Topic 里 pk/dn 对调 → 看是否"发送成功"但平台毫无反应。把四种现象记笔记,以后线上出问题你能一眼归类。
练习 4:打通下行。 控制台"在线调试"下发属性设置 → 设备收到 property/set → 打印 JSON → 回 set_reply(id 与收到的相同、code:200)→ 再上报一次属性(不回写,控制台看到的还是旧值,这步最容易漏)。
练习 5:移植 ESP32,双端对账。 让 ESP32 把算出的 client_id/username/password 全打到串口,Python 端用同一套 pk/dn/secret/timestamp 再算一遍逐字符比对:一致却连不上 → 问题在 TLS/网络;不一致 → 一定是拼接或编码的锅(我踩的三次全在这里)。
小结
- 接公有云 ≠ 换 broker 地址,工作量在三件事:算签名、拼 Topic、对格式。
- 阿里云:
clientId|securemode=2,signmethod=hmacsha256,timestamp=...|+deviceName&productKey+HMAC-SHA256(DeviceSecret, 字典序拼接串)。 - 华为云:
{deviceId}_0_{校验位}_{yyyyMMddHH}+deviceId+HMAC-SHA256(时间戳为密钥, 设备密钥为内容)------两家 key/data 是反的。 - 阿里云上报按属性平铺 (
params),华为云按服务分组 (services),别互相复制。 - 只 publish 不订阅
_reply、不看code= 闭着眼睛发数据。 - 命令下发:异步走服务/属性设置,要结果走 RRPC(8 s 超时),怕丢走设备影子。
- 先校时、再签名、再连接,顺序反了会在签名上浪费一整天(我浪费过)。
文中签名算法、Topic 格式、错误码与端口/证书要求均对照阿里云物联网平台与华为云 IoTDA 官方文档整理(MQTT-TLS连接通信、设备连接鉴权、Topic定义、RRPC),平台改版时以官方文档为准。
下一篇:《设备影子与物模型:解决"设备离线也能控"的核心机制》