EMQX 设备接入实战:MQTT 认证、数据上报与指令下发

从设备账号与 Topic 权限开始,用 Python 模拟一台温湿度设备,配合 MQTTX 完成 EMQX 数据上报、指令下发、执行回执和上下线状态联调。

设备接入最容易出现的误解,是把“连接上了 EMQX”当作“业务已经跑通”。实际上,设备还需要能够上报数据、接收自己的指令,并告诉后台这条指令有没有执行成功。

这篇文章先不依赖具体开发板,用 Python 模拟一台温湿度设备,再用 MQTTX 扮演业务后台。完成这个小闭环后,把模拟采集和模拟执行替换成真实硬件驱动,就能沿用同一套 Topic 与消息约定。

示例是教学用模拟设备,不会操作真实继电器或门锁。假设你已经有可用的 EMQX 实例;不同版本的 Dashboard 菜单名称可能略有差异,请以部署版本文档为准。本文客户端采用 Paho MQTT 2.x 回调 API 和 MQTT 3.1.1。

一、先弄清楚三方各自负责什么

  • 设备:读取传感器、发送数据,收到指令后调用硬件驱动。
  • EMQX:处理 MQTT 连接、认证、授权和消息路由。
  • 业务后台:管理设备归属、存储数据、产生指令、跟踪执行结果。

EMQX 不会因为收到了一个 JSON,就自动替你完成设备业务或把数据写入业务数据库。接入阶段可以用 MQTTX观察消息,后续再接上自己的服务。

本次设备编号统一使用 sensor-001:

text
模拟设备 ── 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:

text
iot/demo/sensor-001/telemetry
iot/demo/sensor-001/status
iot/demo/sensor-001/command-reply

这里要分别订阅,不要直接换成 iot/demo/sensor-001/#:上一节配置的是三个明确的订阅权限,并没有授予通配符订阅权限。

完成后先保持 MQTTX 在线,下面启动模拟设备。

五、Python 模拟设备:上报、收指令和回执

先创建虚拟环境并安装客户端。Windows PowerShell 示例:

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 的对应命令:

bash
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 回调迁移说明。

python
"""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:关闭

消息内容:

json
{
  "requestId": "cmd-001",
  "action": "set_led",
  "value": true
}

预期在 command-reply 收到:

json
{
  "requestId": "cmd-001",
  "code": 0,
  "led": true
}

同时,之后的 telemetry 中 led 应变成 true。再发送新的 requestId,把 value 改为 false,就能观察到状态切换。

验收不要只看“连接成功”,而是依次确认:

  1. EMQX 客户端列表中能找到设备,且 Client ID 没有反复掉线。
  2. MQTTX 能收到 telemetry,字段和单位约定明确。
  3. 指令发送后,设备终端打印执行记录。
  4. command-reply 的 requestId 与下发指令一致。
  5. 新一条 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 放进前端包。

设备接入真正的交付标准,是数据能按约定进入业务、指令能被受控地执行、结果能够追踪。先把这条小链路做扎实,再扩大设备规模,比一开始堆很多平台功能更容易排查问题。