IoT-For-Beginners 第 4 课实战:Wio Terminal 通过 MQTT 订阅云端命令实现夜灯 LED 远程控制
【免费下载链接】IoT-For-Beginners12 Weeks, 24 Lessons, IoT for All!项目地址: https://gitcode.com/GitHub_Trending/io/IoT-For-Beginners
本文基于 IoT-For-Beginners 课程 1-getting-started 的第 4 课(Connect your device to the Internet)中面向 Seeed Studio Wio Terminal 的官方教程 wio-terminal-commands.md,详解如何让夜灯(Nightlight)设备从“只上报”升级为“可被远程控制”:在设备端订阅 MQTT 命令主题、编写消息回调、解析 JSON 命令并驱动 LED。读完并跟着操作后,你将完整掌握 PubSubClient 的订阅与回调机制、MQTT 消息 payload 的字节数组处理、ArduinoJson 的命令解析,以及“遥测上行、命令下行”这一物联网核心闭环在 Arduino 工程中的完整落地方式。
一、背景:夜灯项目走到哪一步了
在课程 4 的前置小节中,你已经完成了两件事:
- 按 wio-terminal-mqtt.md 的方法,用 PubSubClient 库把 Wio Terminal 连上了公共 MQTT 测试代理 test.mosquitto.org(1883 端口,明文,仅用于学习,请勿承载敏感数据);
- 按 wio-terminal-telemetry.md 的方法,在
loop()中每 2 秒读取一次WIO_LIGHT模拟引脚的光照值,以 JSON 形式{"light": <数值>}发布到<ID>/telemetry主题;本地运行的 Python 服务器代码(Paho-MQTT)订阅该主题并打印收到的遥测。
本节要补齐的是闭环的下半部分:服务器端根据遥测做出决策,再通过 MQTT 把命令发回设备,设备收到命令后点亮或熄灭 LED。整个链路即课程 README 中描述的“光照值被读取并检查、LED 被远程受控”的作业流程,对应代码位于 code-commands/wio-terminal 目录。
二、定义命令主题 SERVER_COMMAND_TOPIC
第一步是在配置头文件中声明设备要订阅的命令主题。按照教程,在 config.h 文件末尾添加:
const string SERVER_COMMAND_TOPIC = ID + "/commands";SERVER_COMMAND_TOPIC是设备接收 LED 开/关命令的主题。结合仓库中该文件的完整内容可以看到,项目把所有 MQTT 相关配置集中在此文件中统一管理:
#pragma once #include <string> using namespace std; // WiFi credentials const char *SSID = "<SSID>"; const char *PASSWORD = "<PASSWORD>"; // MQTT settings const string ID = "<ID>"; const string BROKER = "test.mosquitto.org"; const string CLIENT_NAME = ID + "nightlight_client"; const string CLIENT_TELEMETRY_TOPIC = ID + "/telemetry"; const string SERVER_COMMAND_TOPIC = ID + "/commands";几个关键约定(<SSID>、<PASSWORD>、<ID>需替换为你自己的值):
ID是设备唯一标识,前后缀拼接出主题名,与服务器端 server/app.py 中id + '/commands'、id + '/telemetry'的约定必须完全一致,否则设备订阅不到服务器发布的命令;CLIENT_NAME(<ID>nightlight_client)与服务器端的<ID>nightlight_server相互区分,避免两个客户端复用同一 MQTT 客户端 ID 导致互踢;BROKER固定为test.mosquitto.org,这是 Mosquitto 官方提供的公共测试代理,无需账号即可连接,适合验证客户端逻辑,但它是公开且无加密的,任何人都可能监听你发布的内容。
三、在重连函数中订阅命令主题
MQTT 连接可能在运行中因网络波动而断开。本项目采用的模式是:setup()中先建立一次连接,此后loop()每轮都会调用reconnectMQTTClient()检查连接状态;断开则以CLIENT_NAME重新client.connect(),失败时打印client.state()返回码并等待 5 秒重试。
订阅动作必须放在连接成功之后执行,因为订阅是对 broker 的请求,连接不存在时无法生效。按教程,在 main.cpp 的reconnectMQTTClient函数中、client.connect成功后添加:
client.subscribe(SERVER_COMMAND_TOPIC.c_str());仓库中该函数的完整实现如下,订阅语句位于if (client.connect(...))分支内:
void reconnectMQTTClient() { while (!client.connected()) { Serial.print("Attempting MQTT connection..."); if (client.connect(CLIENT_NAME.c_str())) { Serial.println("connected"); client.subscribe(SERVER_COMMAND_TOPIC.c_str()); } else { Serial.print("Retying in 5 seconds - failed, rc="); Serial.println(client.state()); delay(5000); } } }这里用c_str()是因为 PubSubClient 的subscribe接受 C 风格字符串(const char*),而SERVER_COMMAND_TOPIC是 C++std::string。这种“每次重连都重新订阅”的写法值得注意:MQTT 的订阅状态保存在 broker 侧,会话非持久(clean session)时重连后需要重新发起订阅,放在这里可以确保任何一次重连后订阅关系都被重建。
四、实现消息回调 clientCallback:从字节数组到 LED 动作
这是本节的核心代码。按教程在reconnectMQTTClient函数下方添加:
void clientCallback(char *topic, uint8_t *payload, unsigned int length) { char buff[length + 1]; for (int i = 0; i < length; i++) { buff[i] = (char)payload[i]; } buff[length] = '\0'; Serial.print("Message received:"); Serial.println(buff); DynamicJsonDocument doc(1024); deserializeJson(doc, buff); JsonObject obj = doc.as<JsonObject>(); bool led_on = obj["led_on"]; if (led_on) digitalWrite(D0, HIGH); else digitalWrite(D0, LOW); }逐段拆解其技术要点:
- 参数含义:
topic是消息所到达的主题名,payload是消息体的原始字节,length是字节数。PubSubClient 约定回调签名即为(char*, uint8_t*, unsigned int)。 - payload 的字节数组转换:MQTT 消息体在网络层只是字节序列,回调收到的是
uint8_t*(无符号 8 位整数数组),不能直接当 C 字符串使用。代码用 VLA(char buff[length + 1])逐字节拷贝并追加'\0'结束符,使其成为可被Serial和 ArduinoJson 解析的文本。这也是教程特别强调的一点。 - JSON 解析:服务器发布的命令体是 JSON 对象
{"led_on": true/false}。代码使用 ArduinoJson 库(DynamicJsonDocument doc(1024),容量 1024 字节,对这种小命令体绰绰有余)执行deserializeJson,再用doc.as<JsonObject>()取出对象,读取led_on布尔属性。 - 驱动执行器:Wio Terminal 板载一颗 LED 接在
D0引脚(setup()中已pinMode(D0, OUTPUT)声明为输出)。led_on为 true 时digitalWrite(D0, HIGH)点亮,false 时熄灭。
从源码结构看,clientCallback的定义位置(全局作用域、reconnectMQTTClient之前)不影响运行,因为回调是运行时由setCallback注册的函数指针,而非编译期静态注册;关键是它必须与PubSubClient::Callback的签名严格匹配。
五、在 createMQTTClient 中注册回调
定义好回调后还需把它“挂”到客户端上。按教程,在 main.cpp 的createMQTTClient函数中添加:
client.setCallback(clientCallback);仓库中该函数的完整实现:
void createMQTTClient() { client.setServer(BROKER.c_str(), 1883); client.setCallback(clientCallback); reconnectMQTTClient(); }三步分别是:指定 broker 地址与 1883 端口、注册消息回调、执行首次连接(连接成功后会触发第二节的订阅)。
教程中还有一条重要提示:clientCallback会被所有已订阅主题的消息触发。本项目当前只订阅了<ID>/commands,所以回调里可以直接按命令格式解析;如果日后监听多个主题,应利用回调的第一个参数topic区分消息来源,再分支处理,而不是假设所有进来的消息都是同一种结构。
六、上传、验证与预期现象
用 PlatformIO 将代码上传到 Wio Terminal。项目的 platformio.ini 声明了开发板与全部依赖版本,可据此核对本地环境是否一致:
[env:seeed_wio_terminal] platform = atmelsam board = seeed_wio_terminal framework = arduino lib_deps = knolleary/PubSubClient @ 2.8 bblanchon/ArduinoJson @ 6.17.3 seeed-studio/Seeed Arduino rpcWiFi @ 1.0.5 seeed-studio/Seeed Arduino FS @ 2.1.1 seeed-studio/Seeed Arduino SFUD @ 2.0.2 seeed-studio/Seeed Arduino rpcUnified @ 2.1.3 seeed-studio/Seeed_Arduino_mbedtls @ 3.0.1其中与命令功能直接相关的是 PubSubClient 2.8(
subscribe/setCallback/ 回调签名均按此版本 API)与 ArduinoJson 6.17.3(DynamicJsonDocument、deserializeJson、doc.as<JsonObject>()是 v6 的写法)。打开 Serial Monitor(9600 波特率,与
setup()中Serial.begin(9600)一致),先确认设备正常连接 WiFi、连上 broker 并每 2 秒发出遥测。在本地运行服务器代码 server/app.py。该代码订阅
<ID>/telemetry,每收到一条遥测即按阈值light < 300生成命令并发布到<ID>/commands:def handle_telemetry(client, userdata, message): payload = json.loads(message.payload.decode()) print("Message received:", payload) command = { 'led_on' : payload['light'] < 300 } print("Sending message:", command) client.publish(server_command_topic, json.dumps(command))改变设备检测到的光照(物理传感器遮挡/照射,或虚拟设备模拟),预期现象是:设备串口持续出现
Message received:{"led_on": true}或{"led_on": false},D0 板载 LED 随之亮灭;服务器终端则交替打印Message received: {'light': ...}与Sending message: {'led_on': ...}。教程指出你会“在终端看到收到的消息与发出的命令,并看到 LED 随光照水平亮灭”,这正是遥测—决策—命令闭环成立的标志。
完整的设备端实现可对照 code-commands/wio-terminal 目录下的 src/main.cpp;若使用虚拟 IoT 设备替代 Wio Terminal,等价的 Python 客户端实现见 code-commands/virtual-device 中的 nightlight/app.py,其handle_command回调与上文 C++ 回调的处理逻辑一一对应(解析led_on后led.on()/off()),可互为参考。
七、源码级纵览:命令回调是如何被触发的
把 main.cpp 的完整调用链串起来,可以更清楚地理解回调触发时机:
setup() ├─ pinMode(WIO_LIGHT, INPUT) / pinMode(D0, OUTPUT) // 传感器输入、LED 输出 ├─ connectWiFi() // 阻塞直到 WL_CONNECTED └─ createMQTTClient() ├─ client.setServer(BROKER, 1883) ├─ client.setCallback(clientCallback) // 注册回调(本节新增) └─ reconnectMQTTClient() └─ client.connect(CLIENT_NAME) 成功后 └─ client.subscribe(SERVER_COMMAND_TOPIC) // 订阅命令主题(本节新增) loop() // 每轮循环 ├─ reconnectMQTTClient() // 断线则重连并重新订阅 ├─ client.loop() // 处理 keepalive、收发 MQTT 包 │ └─ 收到订阅主题的消息时触发 clientCallback │ └─ 解析 led_on → digitalWrite(D0, HIGH/LOW) ├─ analogRead(WIO_LIGHT) → JSON 序列化 ├─ client.publish(CLIENT_TELEMETRY_TOPIC, ...) // 遥测上行 └─ delay(2000)两个值得注意的实现细节:
client.loop()是回调的生命线。PubSubClient 是“非阻塞网络 + 阻塞业务循环”的混合模型:网络包收发、心跳以及已接收消息的回调分发都发生在client.loop()内部。从源码结构看,loop()里既发遥测又处理入站命令,因此遥测频率(delay(2000))间接决定了命令的最大处理延迟——命令到达后必须等下一次client.loop()才会执行回调。- 订阅在重连路径中重复执行。
reconnectMQTTClient内的while (!client.connected())循环意味着连接建立或恢复的每一次都会重新subscribe,保证 broker 侧订阅关系始终存在;而createMQTTClient只调用一次(在setup()中),因此不能指望它在断线重连后再次生效。 - D0 的双重角色:
D0同时是板载 LED 引脚。setup()中的pinMode(D0, OUTPUT)使该引脚在收到命令前就默认为低电平(LED 熄灭),命令只是改变其电平,逻辑上属于“状态覆盖”而非“动作触发”——这也解释了为何最新一条命令总是直接决定 LED 当前状态,天然容忍命令乱序或丢失(旧命令被新状态覆盖)。
八、设计要点小结与延伸
- 主题命名即 API 契约:设备端
ID + "/commands"、ID + "/telemetry"与服务器端必须严格一致。课程 README 还提示,如果要对特定设备单独下发命令,可采用/commands/device1这类按设备 ID 细粒度的主题,让每台设备只收听发给自己的消息,而不是所有设备共享一个命令主题。 - 回调是“多主题共用”的:本例只订阅一个主题,回调里可硬编码解析命令;扩展到多主题时必须用
topic参数路由,否则结构假设会失效。 - payload 永远先当字节处理:
uint8_t* + length转成以'\0'结尾的char数组是所有 Arduino 侧 MQTT 消息处理的标准前置步骤,JSON、CSV 等文本格式都依赖这一步。 - 命令的语义决定断线策略:本例
led_on是幂等状态指令(最新一条覆盖之前所有),因此 MQTT 无队列、离线期间命令丢失是可接受的;若命令需要严格顺序执行(如机械臂“抬起—闭合”),则需在设计上引入序列号、保留消息(retained flag)或重放机制——这些取舍课程 4 的 README 在 Loss of connectivity 一节有专门讨论,值得延伸阅读。 - 安全边界:test.mosquitto.org 为明文公共 broker,仅适合学习。生产环境应自建或选用带 TLS 与鉴权的 broker(项目依赖中的
Seeed_Arduino_mbedtls库即为 TLS 支持预留了基础)。
参考资料(仓库内路径)
- 教程原文:wio-terminal-commands.md(英文原版)、阿拉伯语译本 translations/ar/1-getting-started/lessons/4-connect-internet/wio-terminal-commands.md
- 设备端代码:src/main.cpp、src/config.h、platformio.ini
- 服务器端代码:code-commands/server/app.py
- 虚拟设备等价实现:code-commands/virtual-device/nightlight/app.py
- 课程总览(MQTT 原理、QoS、断线处理讨论):1-getting-started/lessons/4-connect-internet/README.md
【免费下载链接】IoT-For-Beginners12 Weeks, 24 Lessons, IoT for All!项目地址: https://gitcode.com/GitHub_Trending/io/IoT-For-Beginners
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考