设备接入最容易出现的误解,是把“连接上了 EMQX”当作“业务已经跑通”。实际上,设备还需要能够上报数据、接收自己的指令,并告诉后台这条指令有没有执行成功。
这篇文章先不依赖具体开发板,用 Python 模拟一台温湿度设备,再用 MQTTX 扮演业务后台。完成这个小闭环后,把模拟采集和模拟执行替换成真实硬件驱动,就能沿用同一套 Topic 与消息约定。
示例是教学用模拟设备,不会操作真实继电器或门锁。假设你已经有可用的 EMQX 实例;不同版本的 Dashboard 菜单名称可能略有差异,请以部署版本文档为准。本文客户端采用 Paho MQTT 2.x 回调 API 和 MQTT 3.1.1。
一、先弄清楚三方各自负责什么
- 设备:读取传感器、发送数据,收到指令后调用硬件驱动。
- EMQX:处理 MQTT 连接、认证、授权和消息路由。
- 业务后台:管理设备归属、存储数据、产生指令、跟踪执行结果。
EMQX 不会因为收到了一个 JSON,就自动替你完成设备业务或把数据写入业务数据库。接入阶段可以用 MQTTX观察消息,后续再接上自己的服务。
本次设备编号统一使用 sensor-001:
模拟设备 ── telemetry / status ──> EMQX ──> MQTTX
模拟设备 <────── command ──────── EMQX <── MQTTX
模拟设备 ───── command-reply ───> EMQX ──> MQTTX
二、准备连接参数和设备账号
准备 Python 3.11、MQTTX,以及一台已配置 MQTT over TLS 监听器的 EMQX。本文用 8883 作为示例端口;如果你的监听端口不同,需要同步修改客户端。
| 参数 | 设备端示例 | MQTTX 后台模拟端 |
|---|---|---|
| Broker 地址 | mqtt.example.com,替换成真实域名 |
同一个 Broker |
| 端口 | 8883 |
8883 |
| MQTT 版本 | 3.1.1 | 3.1.1 |
| Client ID | device-sensor-001 |
backend-debug-001 |
| Username | sensor-001 |
backend-demo |
| Password | 设备专用强密码 | 另一份独立强密码 |
| TLS | 启用并校验证书 | 启用并校验证书 |
在 EMQX Dashboard 的 访问控制 → 认证 中,使用“密码认证 + 内置数据库”,认证标识选择 Username,然后创建上表的两个 MQTT 用户。不要设为超级用户,也不要直接拿 Dashboard 管理员密码当设备密码。具体入口见 EMQX 内置数据库认证文档。
需要提前检查:
- 域名解析到正确服务器,安全组和防火墙允许连接相应 MQTT 端口。
- 服务器已真正启用 TLS 监听器,并配置匹配该域名的证书;单纯把端口写成 8883 不会自动启用加密。
- 使用私有 CA 时,把 CA 证书交给客户端信任,不要通过关闭证书校验“解决”错误。
- 两个客户端不要使用同一个 Client ID。一个设备也不要同时启动两份相同编号的脚本。
公网接入应使用 TLS。明文 1883 只适合隔离、可信的开发测试环境;管理控制台也不应无保护地暴露在公网。参见 EMQX 安全检查清单。
三、先约定 Topic,再配置最小权限
本例统一使用 iot/demo/sensor-001 作为前缀,不把全部数据和指令混在一个 Topic 里。
| Topic 后缀 | 用途 | 发布方 | 订阅方 | Retain |
|---|---|---|---|---|
telemetry |
温湿度等采样数据 | 设备 | 后台 | 关闭 |
status |
最近一次在线状态 | 设备/遗嘱 | 后台 | 开启 |
command |
控制指令 | 后台 | 设备 | 关闭 |
command-reply |
执行回执 | 设备 | 后台 | 关闭 |
实际完整 Topic,例如 iot/demo/sensor-001/telemetry,需要与代码和 MQTTX 完全一致,注意大小写。
在 访问控制 → 授权 中配置内置数据库授权,按 Username 添加规则:
sensor-001:允许发布 telemetry、status、command-reply 三个完整 Topic;只允许订阅 command。backend-demo:允许订阅 telemetry、status、command-reply 三个完整 Topic;只允许发布 command。- 两端都使用 QoS 1,规则需允许该 QoS。若限制 Retain,status 的发布规则要允许 true,其他发布规则允许 false。
规则的动作分别选 Publish 或 Subscribe,不要为了省事直接给设备开放 #。以上是本文的最小权限设计,规则入口和匹配字段可参考 内置数据库授权文档。
还要检查整个授权链:未匹配规则的行为应为拒绝(no_match = deny),且不存在提前命中的“允许所有”规则。否则即使新加了几条精细规则,设备仍可能访问其他设备的 Topic。修改已有生产规则前先确认不会影响其他业务。参见 EMQX 授权机制。
四、先用 MQTTX 把观察窗口准备好
用 backend-demo 建立连接,Client ID 填 backend-debug-001,启用 TLS 和证书验证。此时先分别订阅以下三个 Topic,QoS 设为 1:
iot/demo/sensor-001/telemetry
iot/demo/sensor-001/status
iot/demo/sensor-001/command-reply
这里要分别订阅,不要直接换成 iot/demo/sensor-001/#:上一节配置的是三个明确的订阅权限,并没有授予通配符订阅权限。
完成后先保持 MQTTX 在线,下面启动模拟设备。
五、Python 模拟设备:上报、收指令和回执
先创建虚拟环境并安装客户端。Windows PowerShell 示例:
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install "paho-mqtt>=2.1,<3"
$env:MQTT_HOST = "mqtt.example.com"
$env:MQTT_PORT = "8883"
$env:MQTT_USERNAME = "sensor-001"
# 私有 CA 才需要设置;使用受信任的公有 CA 时通常可以省略。
# $env:MQTT_CA = "C:\certs\ca.pem"
.\.venv\Scripts\python.exe device_demo.py
Linux/macOS 的对应命令:
python3 -m venv .venv
.venv/bin/python -m pip install 'paho-mqtt>=2.1,<3'
export MQTT_HOST=mqtt.example.com
export MQTT_PORT=8883
export MQTT_USERNAME=sensor-001
# export MQTT_CA=/path/to/ca.pem
.venv/bin/python device_demo.py
将下面代码保存为 device_demo.py。脚本会交互式读取设备密码,不需要把真实密码写进文章、源码或命令历史。无人值守部署时,可以通过受保护的环境变量注入 MQTT_PASSWORD。
Paho 2.x 的连接、断开和订阅回调参数与旧教程有所不同,下面明确使用 CallbackAPIVersion.VERSION2,避免“装了新包却复制旧回调”的问题。参见 Paho 回调迁移说明。
"""EMQX tutorial: simulated sensor, TLS, Paho MQTT 2.x callback API."""
import getpass
import json
import os
import random
import ssl
import threading
import time
import uuid
import paho.mqtt.client as mqtt
DEVICE_ID = "sensor-001"
BASE = f"iot/demo/{DEVICE_ID}"
READY = threading.Event()
STATE = {"led": False}
def publish_json(client, suffix, data, retain=False):
return client.publish(
f"{BASE}/{suffix}", json.dumps(data), qos=1, retain=retain
)
def on_connect(client, userdata, flags, reason_code, properties):
READY.clear()
if reason_code.is_failure:
print("Connection rejected:", reason_code)
return
# Subscribe again after every successful reconnect.
result, _ = client.subscribe(f"{BASE}/command", qos=1)
if result != mqtt.MQTT_ERR_SUCCESS:
print("Subscribe request failed:", result)
def on_subscribe(client, userdata, mid, reason_codes, properties):
if not reason_codes or any(code.is_failure for code in reason_codes):
print("Command subscription denied:", reason_codes)
client.disconnect()
return
publish_json(client, "status", {"online": True}, retain=True)
READY.set()
print("Device ready:", DEVICE_ID)
def on_disconnect(client, userdata, flags, reason_code, properties):
READY.clear()
print("Disconnected:", reason_code)
def on_message(client, userdata, message):
if message.topic != f"{BASE}/command" or len(message.payload) > 4096:
return
try:
command = json.loads(message.payload.decode("utf-8"))
if not isinstance(command, dict):
raise ValueError("Expected a JSON object")
request_id = command.get("requestId")
if not isinstance(request_id, str) or not request_id.strip():
raise ValueError("Missing requestId")
if command.get("action") != "set_led":
raise ValueError("Unsupported action")
if not isinstance(command.get("value"), bool):
raise ValueError("value must be a boolean")
# Simulation only: replace this assignment with a real device driver.
STATE["led"] = command["value"]
reply = {"requestId": request_id, "code": 0, "led": STATE["led"]}
publish_json(client, "command-reply", reply)
print("Command executed:", reply)
except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
print("Invalid command:", exc)
def main():
host = os.environ["MQTT_HOST"] # Hostname only, without mqtts://.
password = os.getenv("MQTT_PASSWORD") or getpass.getpass("Device password: ")
client = mqtt.Client(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id=f"device-{DEVICE_ID}",
protocol=mqtt.MQTTv311,
clean_session=True,
)
client.username_pw_set(os.getenv("MQTT_USERNAME", DEVICE_ID), password)
client.tls_set_context(ssl.create_default_context(cafile=os.getenv("MQTT_CA")))
client.reconnect_delay_set(min_delay=1, max_delay=30)
client.max_queued_messages_set(20)
client.max_inflight_messages_set(10)
client.will_set(f"{BASE}/status", '{"online": false}', qos=1, retain=True)
client.on_connect = on_connect
client.on_subscribe = on_subscribe
client.on_disconnect = on_disconnect
client.on_message = on_message
client.connect_async(host, int(os.getenv("MQTT_PORT", "8883")), keepalive=60)
client.loop_start()
try:
if not READY.wait(timeout=30):
raise RuntimeError("Not ready: check network, TLS, authentication and ACL")
while True:
if READY.is_set():
payload = {
"messageId": uuid.uuid4().hex,
"deviceId": DEVICE_ID,
"ts": int(time.time() * 1000),
"temperature": round(random.uniform(22, 28), 1),
"humidity": round(random.uniform(45, 65), 1),
"led": STATE["led"],
}
info = publish_json(client, "telemetry", payload)
print("Telemetry queued:", info.rc, payload)
time.sleep(10)
except KeyboardInterrupt:
print("Stopping device")
finally:
if client.is_connected():
try:
publish_json(client, "status", {"online": False}, True).wait_for_publish(2)
except RuntimeError:
pass
client.disconnect()
client.loop_stop()
if __name__ == "__main__":
main()
这段代码做了几个有意的取舍:
- 连接成功后重新订阅 command,收到订阅确认后才开始周期上报。
- 温湿度由随机数模拟,
STATE["led"]只是内存状态,并不表示控制了真实 GPIO。 - 网络线程负责收发消息;回调内不做耗时操作,也不等待发布确认。
- 每条采样带
messageId和毫秒时间戳,方便业务端去重、排查时钟问题。 - 使用有限的发送队列;离线期间跳过采样,不实现断电持久化和离线补传。
publish() 返回成功,表示消息交给了客户端处理,并不表示业务后台已经处理完毕。客户端网络循环、重连和发布确认的细节可参考 Paho Client API。
六、发送一条指令,检查整个闭环
脚本显示 Device ready: sensor-001 后,MQTTX 应能收到 status,以及大约每 10 秒一条 telemetry。温湿度是模拟值,不是现场测量结果。
然后在 MQTTX 发布:
- Topic:
iot/demo/sensor-001/command - QoS:1
- Retain:关闭
消息内容:
{
"requestId": "cmd-001",
"action": "set_led",
"value": true
}
预期在 command-reply 收到:
{
"requestId": "cmd-001",
"code": 0,
"led": true
}
同时,之后的 telemetry 中 led 应变成 true。再发送新的 requestId,把 value 改为 false,就能观察到状态切换。
验收不要只看“连接成功”,而是依次确认:
- EMQX 客户端列表中能找到设备,且 Client ID 没有反复掉线。
- MQTTX 能收到 telemetry,字段和单位约定明确。
- 指令发送后,设备终端打印执行记录。
- command-reply 的 requestId 与下发指令一致。
- 新一条 telemetry 反映更新后的设备状态。
这个示例中的非法指令只记录日志并忽略。正式项目应增加明确的失败回执、参数范围校验和超时处理,不能只定义成功场景。
七、上线前必须分清的三个概念
1. QoS 1 不等于业务只执行一次
QoS 1 是“至少一次”交付,重复消息需要由业务处理。本文的 set_led 是设置目标状态,重复设为 true 不会像“切换一次”那样反复翻转;但扣费、开锁计次等动作必须有独立的幂等设计。
真实设备应根据 requestId 去重,并考虑重启后仍能识别已经执行过的指令。后台还应给指令设置有效期,过期的控制动作不能在重连后继续执行。MQTT 的 PUBACK 也不能替代业务执行回执。
2. Retain 是保存最近一条消息,不是历史数据库
status 适合保留最近状态,方便新订阅者立刻知道最后一次报告的在线情况。控制指令通常不应保留,否则设备重新订阅时可能收到旧动作。
如果之前误发了保留指令,需要清除这个 Topic 已有的 retained 消息;仅把下一条普通消息的 Retain 关掉,并不会自动清除旧记录。保留消息行为见 EMQX Retained Message 文档。
3. 遗嘱消息不代表瞬时、绝对可靠的在线检测
脚本连接前设置了 offline 遗嘱。EMQX 检测到非正常断开后会发布它;正常退出时,脚本主动发送 offline,再断开连接。
断网检测存在时间差,不能看到 retained 的 online 就认为设备此刻一定能执行指令。后台还应结合最近上报时间和指令超时判断状态。遗嘱的触发条件见 EMQX Will Message 文档。
八、常见问题:按照链路逐层排查
| 现象 | 优先检查 |
|---|---|
| 连接超时、连接被拒绝 | 域名、端口、监听器、安全组和防火墙;不要把 Dashboard 端口当 MQTT 端口 |
| TLS 握手失败 | 证书域名、有效期、CA 信任链、设备系统时间;不要关闭证书验证 |
| 用户名密码错误 | 是否在 MQTT 认证源中建了用户,认证标识是否选 Username,是否误用了管理账号 |
| 连接成功但订阅被拒绝 | Subscribe 动作、完整 Topic、QoS 和用户名授权规则 |
| 能连上但收不到上报 | 设备 Publish 权限、后台 Subscribe 权限、Topic 大小写、网络循环是否运行 |
| 设备频繁掉线 | 两个程序是否共用 Client ID,网络是否不稳定,认证或限流是否拒绝重连 |
| 后台显示发送成功但没有回执 | 设备是否在线、command 订阅是否成功、JSON 是否合法、requestId 是否匹配 |
| 设备重连后执行了旧动作 | 是否曾对 command 开启 Retain,是否缺少指令有效期和去重机制 |
排障时分别看客户端日志、EMQX 客户端详情与针对该 Client ID 的日志追踪。不要为了临时调通就把认证、授权和证书验证全部关掉。
九、接真实设备与业务后台,下一步做什么
闭环跑通后,再把示例逐步替换:
- 用真实传感器读取函数替代随机温湿度,统一字段、单位和采样频率。
- 将 set_led 接到真实驱动,区分“指令已接收”和“硬件实际执行成功”。
- 由业务服务订阅 telemetry 和 command-reply,校验设备归属后写入数据库。
- 给每台设备分配独立身份和受限权限,支持凭据撤销与轮换。
- 增加离线数据策略、指令有效期、持久化幂等、告警和流量限制。
浏览器页面通常只调用业务 API,由后台决定是否允许某个用户控制某台设备;不要把所有设备的 MQTT 账号或管理 API Key 放进前端包。
设备接入真正的交付标准,是数据能按约定进入业务、指令能被受控地执行、结果能够追踪。先把这条小链路做扎实,再扩大设备规模,比一开始堆很多平台功能更容易排查问题。