外观
3.11 MQTT 云端通信
这节要搞懂:设备怎么把数据传到云端、云端怎么下发命令。 需要基础:3.10
一、什么是 MQTT
MQTT(Message Queuing Telemetry Transport)是一种轻量级的物联网通信协议。设备把数据发布(publish)到某个主题(Topic),关心这个主题的客户端订阅(subscribe)后就能收到。
打个比方:MQTT 就像一个公告板。
- 设备把温度贴到"温度"那一栏 —— 谁想看谁去看(发布)
- 你想改闹钟,就往"命令"那一栏贴张纸 —— 设备一直盯着那一栏(订阅)
双方不用互相知道对方是谁,只要认识同一个"栏目名"就行。这叫发布订阅模式。
二、为什么物联网都用它
| HTTP | MQTT | |
|---|---|---|
| 谁发起 | 只能设备主动问 | 双向都能主动 |
| 想下发命令 | 设备得不停轮询 | 服务器直接推 |
| 开销 | 每次都带一堆头部 | 极小(最小 2 字节) |
| 连接 | 一次一断 | 长连接 |
关键差别是"服务器能不能主动找设备" —— HTTP 不行,MQTT 行。这就是远程控制必须用 MQTT 的原因。
三、工程里接的是 OneNET
工程用的是中国移动的 OneNET 物联网平台(app_mqtt.c、app_onenet_ota.c)。
在平台上要先建三样东西:
| 概念 | 是什么 |
|---|---|
| 产品 | 一类设备的模板,定义有哪些属性 |
| 设备 | 具体的一台,有唯一的设备名 |
| Token | 连接凭证,相当于密码 |
Token 怎么来
OneNET 的 MQTT 连接不是简单的用户名密码,而是用产品 ID、设备名、密钥、过期时间按规则算出一个 token 字符串,填在 MQTT 的 password 字段里。
c
esp_mqtt_client_config_t mqtt_config = {
.broker.address.uri = "mqtt://mqtts.heclouds.com:1883",
.credentials.username = ONENET_PRODUCT_ID, // 产品 ID
.credentials.client_id = ONENET_DEVICE_NAME, // 设备名
.credentials.authentication.password = s_onenet_token, // ⭐ 算出来的 token
.session.keepalive = 60,
};Token 有过期时间
生成 token 时要指定一个过期时间戳,过了就连不上了。
设成远一点(比如几年后),或者在设备端定期重新生成。
调试时突然连不上云平台,先看是不是 token 过期了。
四、属性上报:设备 → 云
把当前状态发布到上报主题。工程里实际上报这些字段:
| 字段 | 含义 |
|---|---|
temp_value | 当前温度 |
humidity_value | 当前湿度 |
historical_record | 历史记录 |
city | 天气城市 |
auto_light | 自动亮度开关 |
light | 当前亮度 |
maxtemp_set / minitemp_set | 温度报警上下限 |
maxhum_set / minihum_set | 湿度报警上下限 |
上报的不只是传感器数据
注意里面也包含配置项(city、light、报警阈值……)。
这样做的好处:手机 App 打开时,不用再单独查一次配置 —— 上报的这一条里什么都有了,界面可以直接照着显示。
c
static void mqtt_report_state(void)
{
app_config_t config;
app_config_get(&config);
char payload[512];
snprintf(payload, sizeof(payload),
"{\"id\":\"1\",\"params\":{"
"\"temp_value\":{\"value\":%.1f},"
"\"humidity_value\":{\"value\":%.1f},"
"\"city\":{\"value\":\"%s\"},"
"\"auto_light\":{\"value\":%d},"
"\"light\":{\"value\":%d},"
"\"maxtemp_set\":{\"value\":%.1f},"
"\"minitemp_set\":{\"value\":%.1f}"
"}}",
s_temperature, s_humidity,
config.city, config.auto_light, config.light,
config.maxtemp_set, config.minitemp_set);
esp_mqtt_client_publish(s_client, ONENET_TOPIC_PROP_POST,
payload, 0,
1, // QoS 1:至少送达一次
0); // 不保留
}字段名要和平台上定义的物模型一致
OneNET 上建产品时要定义每个属性的标识符(identifier)。
代码里上报的名字必须和平台上定义的一模一样,否则平台收到了也认不出来,会当成非法属性丢掉。
调不通时先去平台后台对一遍字段名。
QoS 选哪个
| QoS | 保证 | 什么时候用 |
|---|---|---|
| 0 | 发出去就不管 | 高频数据,丢一两条无所谓 |
| 1 | 至少送达一次(可能重复) | ⭐ 日常用这个 |
| 2 | 恰好一次 | 开销大,很少用 |
上报频率别太高
每秒一条 = 一天 86400 条,免费额度很快就用完。
1 分钟一次足够;数据变化大时再额外推一条。
五、属性下发:云 → 设备
MQTT 能改哪些配置 ⭐
注意:MQTT 支持的配置项比 BLE 少。 当前工程 app_mqtt.c 的属性下发只处理这 8 项:
| 配置项 | BLE(3.10) | MQTT |
|---|---|---|
city 天气城市 | ✅ | ✅ |
auto_light 自动亮度开关 | ✅ | ✅ |
light 屏幕亮度 | ✅ | ✅ |
volume 音量 | ✅ | ✅ |
maxtemp_set 温度上限 | ✅ | ✅ |
minitemp_set 温度下限 | ✅ | ✅ |
maxhum_set 湿度上限 | ✅ | ✅ |
minihum_set 湿度下限 | ✅ | ✅ |
alarm 闹钟 | ✅ | ❌ 不支持 |
refresh_minutes 刷新间隔 | ✅ | ❌ 不支持 |
auto_light_min 亮度下限 | ✅ | ❌ 不支持 |
auto_light_max 亮度上限 | ✅ | ❌ 不支持 |
一句话概括两者的关系
MQTT 和 BLE 用的是相同的配置更新思想,但当前工程里 MQTT 只开放了部分配置项,BLE 配置特征值开放的字段更多。
想远程设闹钟,目前要用蓝牙
闹钟目前只能通过 BLE 设置(3.10)。
想让 MQTT 也能设,需要在 app_mqtt.c 的下发处理里补上 alarm 字段的解析 —— 因为下游 app_config_update() 本来就支持,只差解析这一步。
这也说明了统一入口的价值:加一个字段只要改解析,不用动后面任何代码。
代码
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 event = event_data;
switch (event_id) {
case MQTT_EVENT_CONNECTED:
// 连上了才能订阅
esp_mqtt_client_subscribe(s_client, ONENET_TOPIC_PROP_SET, 1);
break;
case MQTT_EVENT_DATA: {
// 收到:{"params":{"light":60,"volume":70}}
cJSON *root = cJSON_ParseWithLength(event->data, event->data_len);
if (root == NULL) {
break;
}
cJSON *params = cJSON_GetObjectItem(root, "params");
if (cJSON_IsObject(params)) {
app_config_t config;
app_config_get(&config);
cJSON *city = cJSON_GetObjectItem(params, "city");
if (cJSON_IsString(city)) {
strncpy(config.city, city->valuestring,
sizeof(config.city) - 1);
}
cJSON *light = cJSON_GetObjectItem(params, "light");
if (cJSON_IsNumber(light)) {
config.light = light->valueint;
}
cJSON *volume = cJSON_GetObjectItem(params, "volume");
if (cJSON_IsNumber(volume)) {
config.volume = volume->valueint;
}
// ... auto_light、四个报警阈值同理
app_config_update(&config); // ⭐ 和 BLE 汇合到同一个入口
}
cJSON_Delete(root); // ⭐ 别漏
break;
}
case MQTT_EVENT_DISCONNECTED:
ESP_LOGW(TAG, "mqtt disconnected"); // 客户端会自动重连
break;
}
}订阅要放在 CONNECTED 事件里
不能在 esp_mqtt_client_start() 之后马上订阅 —— 那时候还没连上,订阅会失败。
断线重连之后订阅也会失效,所以每次 MQTT_EVENT_CONNECTED 都要重新订阅一遍。
回调里别干重活
这个回调跑在 MQTT 客户端自己的任务上,卡住它整个云端通信都会停。
只解析 + 调 app_config_update(),然后立刻返回(和 BLE 回调、中断服务函数是同一个原则)。
六、统一入口的价值 ⭐
设备的"设置"分三类,各走各的链路:
【第一类:运行配置】城市、亮度、音量、闹钟、报警阈值……
BLE 配置特征值 ──► app_config_update_from_json() ──┐
├──► app_config_update()
MQTT 属性下发 ─────────────────────────────────────┘ │
▼
写入 NVS(配置)
│
▼
配置变化回调
│
▼
s_config_event_queue
│
▼
config_apply_task
│
▼
改硬件
【第二类:WiFi 凭据】ssid / password
HTTP 网页配网 ──► app_wifi_provision() / save_wifi() ──► NVS(wifi_cfg 命名空间)
BLE WiFi 特征值 ─┘
【第三类:SD 卡文件操作】
HTTP 网页 ──► app_sd / app_audio_player / app_image_viewer别把三类混为一谈
它们走的是完全不同的链路,存的也是不同的 NVS 命名空间。
| 存到哪 | 经过谁 | |
|---|---|---|
| 运行配置 | 配置命名空间 | app_config_update() → 配置回调 → config_apply_task |
| WiFi 凭据 | wifi_cfg 命名空间 | app_wifi_provision() / save_wifi() |
| SD 卡操作 | 不存配置,直接操作文件 | app_sd 等模块 |
WiFi 账号密码不走 app_config_update() —— 它有自己的保存路径。混在一起讲,学员会以为改 WiFi 和改亮度是同一条链路。
运行配置这一类确实汇合了
MQTT 和 BLE 用的是同一套配置更新思想 —— 解析完各自的格式之后,都调 app_config_update()。
汇合之后,写 NVS、通知、应用到硬件这一整套只有一份代码。
判断架构好不好,就看这个
加一个新的控制方式,要改几个地方?
这里加第四种(语音、红外遥控),只要它最终调到 app_config_update(),下游一行都不用动。
本节要点
- MQTT = 发布订阅,像公告板,双方不用互相认识
- 相比 HTTP,服务器能主动推消息给设备 —— 这是远程控制的关键
- 工程接的是 OneNET,要先在平台上建产品、设备,算出 token
- token 有过期时间,连不上先查它
- 上报用 QoS 1,1 分钟一次
- 订阅必须放在
MQTT_EVENT_CONNECTED里,断线重连后要重新订阅 - ⭐ MQTT 只开放 8 项配置,闹钟、刷新间隔、自动亮度上下限目前只能用 BLE 改
- 上报字段是
temp_value/humidity_value/city/light等,名字要和平台物模型一致 - ⭐ 回调里只解析 + 调
app_config_update(),和 BLE 汇合到同一条链路
操作过程
目标:设备连上 OneNET,在网页后台看到它上报的温湿度,并从后台改一个配置。
第 1 步:在 OneNET 上建产品和设备
- 注册并登录 OneNET 控制台
- 创建产品,接入协议选 MQTT
- 在产品下创建设备,记下:
| 要记的 | 用在哪 |
|---|---|
| 产品 ID | MQTT 的 username |
| 设备名称 | MQTT 的 client_id |
| 设备密钥 | 生成 token 用 |
第 2 步:定义物模型(重要)
在产品里添加属性,标识符要和代码里上报的字段完全一致:
temp_value 温度
humidity_value 湿度
city 城市
auto_light 自动亮度
light 亮度
maxtemp_set 温度上限
minitemp_set 温度下限
maxhum_set 湿度上限
minihum_set 湿度下限
volume 音量字段名对不上就白干
平台收到不认识的属性会直接丢掉,你在后台什么都看不到,还以为是设备没连上。
调不通时先来这里对一遍字段名。
第 3 步:生成 token 填进代码
用 OneNET 提供的 token 生成工具(或按文档算),把产品 ID、设备名、密钥、过期时间填进去生成一串 token。
c
#define ONENET_PRODUCT_ID "你的产品ID"
#define ONENET_DEVICE_NAME "你的设备名"
#define ONENET_TOKEN "version=2018-10-31&res=products%2F..."过期时间设远一点
设成几年后。token 过期会导致 MQTT 和 OTA 一起失效,而且报错不明显。
第 4 步:编译烧录
第 5 步:应该看到什么
I (8000) mqtt: MQTT_EVENT_CONNECTED ← ① 连上了
I (8010) mqtt: subscribed to prop/set ← ② 订阅成功
I (68000) mqtt: report: {"id":"1","params":{"temp_value":{"value":26.5}...OneNET 后台:设备状态变成在线,属性列表里能看到温湿度的值在更新。
第 6 步:从后台下发一个配置
在 OneNET 后台找到"属性下发",下发:
json
{"light": 30}串口:
I (90000) mqtt: MQTT_EVENT_DATA
I (90010) config: light updated: 80 -> 30屏幕应该变暗。
第 7 步:试试 MQTT 不支持的字段
下发 {"alarm":"07:30"},你会发现没反应 —— 因为当前工程 MQTT 只处理 8 个字段,闹钟不在其中(见第五节)。
这不是 bug,是没实现
想让它支持,在 app_mqtt.c 的下发处理里补上 alarm 字段的解析就行,下游代码完全不用动。
可以当成一个练习来做。
动手试试
- 在 OneNET 上建好产品和设备,跑通一次连接,看串口打印
MQTT_EVENT_CONNECTED。 - 从平台下发一次亮度设置,在
app_config_update()里打日志确认收到了。 - 分别用 BLE 和 MQTT 各改一次亮度,验证两条路走的是同一个入口。
- 试着给
app_mqtt.c补上alarm字段的解析,让 MQTT 也能设闹钟。 - 拔掉网线让 MQTT 断开,观察它多久自动重连上。