IoT-For-Beginners 实战:将 Wio Terminal 土壤湿度传感器从公共 MQTT 迁移到 Azure IoT Hub
【免费下载链接】IoT-For-Beginners12 Weeks, 24 Lessons, IoT for All!项目地址: https://gitcode.com/GitHub_Trending/io/IoT-For-Beginners
本篇技术指南以 Azure IoT for Beginners 课程《Migrate your plant to the cloud》为核心,讲解如何把上一课中通过公共 MQTT 代理收发数据的 Wio Terminal 土壤湿度传感器程序,完整迁移为基于 Azure IoT Hub 的云连接方案:包括 PlatformIO 依赖与编译标志改造、设备连接字符串配置、NTP 时间同步、遥测上报(D2C 消息)以及直接方法(Direct Method)命令处理。读完本篇,你将掌握用 Azure IoT C SDK(Arduino 端口)在 Wio Terminal 上建立 IoT Hub 连接的完整代码骨架,并能用 Azure CLI 验证遥测与远程控制继电器。
背景:为什么要把植物从公共 MQTT 代理迁到云端
在上一课中,你的 IoT 设备通过公共 MQTT 代理(broker)发送土壤湿度遥测、接收继电器控制命令,这非常适合演示原理,但在真实商业场景中有明显短板:免费公共代理不保证可用性、可能随时下线;消息是公开的,任何人都能监听遥测或向你的硬件发送命令;吞吐量只面向少量测试消息;并且无法获知有哪些设备已连接。课程 README 将这些问题归纳为可靠性、安全性、性能与可发现性四个维度,详见 README。
云厂商提供的 IoT 服务(本文使用微软 Azure IoT Hub)专门解决这些问题:有大规模可靠性投入、内置设备级安全机制(未注册设备无法连接)、支持每天数百万条消息的吞吐,并且提供免费层(F1 SKU,每天 8000 条消息)供学习和开发使用。
IoT Hub 与设备之间的通信不像 MQTT 那样依赖多个自定义 topic,而是提供了几种定义好的消息模型,本文会用到其中两种:
- 设备到云(D2C)消息:设备发往 IoT Hub 的遥测数据,应用代码可以从中读取;
- 直接方法请求(Direct Method):应用代码通过 IoT Hub 请求设备执行某个动作(如打开/关闭继电器),且必须携带响应,让调用方知道是否执行成功。
底层实现上,IoT Hub 与设备之间的通信仍可基于 MQTT、HTTPS 或 AMQP。本文的 Arduino 示例显式指定使用 MQTT 协议。
前置条件与硬件基础
本部分教程面向Wio Terminal(Seeed Studio),开发环境为 VS Code + PlatformIO。在开始之前,需要先完成课程 README 中描述的前置工作:
- 已注册 Azure 订阅(学生可用 Azure for Students,其他人用 Azure 免费订阅),并安装 Azure CLI、添加
azure-iot扩展、执行az login登录; - 已创建资源组
soil-moisture-sensor与 IoT Hub 实例(az iot hub create,--sku F1免费层、--partition-count 2,Hub 名称全局唯一); - 已在 Hub 中注册设备
soil-moisture-sensor,并通过az iot hub device-identity connection-string show取得该设备专属的连接字符串(含 Hub URL、设备 ID 与密钥)。
关键命令摘要如下(详见 README):
# 创建资源组 az group create --name soil-moisture-sensor --location <location> # 创建 IoT Hub(免费层,每天 8000 条消息) az iot hub create --resource-group soil-moisture-sensor \ --sku F1 \ --partition-count 2 \ --name <hub_name> # 注册设备 az iot hub device-identity create --device-id soil-moisture-sensor --hub-name <hub_name> # 获取设备连接字符串(务必妥善保存,连接字符串含密钥,不能泄露) az iot hub device-identity connection-string show \ --device-id soil-moisture-sensor --output table --hub-name <hub_name>⚠️ 连接字符串相当于设备身份的凭据,应像密码一样妥善保管,切勿提交到公开仓库。安全细节会在后续课程中进一步讲解。
第一步:改造 platformio.ini —— 移除 MQTT、引入 Azure IoT 依赖
在 VS Code 中打开soil-moisture-sensor项目,编辑 platformio.ini。
移除knolleary/PubSubClient库依赖——它用于连接公共 MQTT 代理,连接 IoT Hub 不再需要。
新增以下库依赖:
seeed-studio/Seeed Arduino RTC @ 2.0.0 arduino-libraries/AzureIoTHub @ 1.6.0 azure/AzureIoTUtility @ 1.6.1 azure/AzureIoTProtocol_MQTT @ 1.6.0 azure/AzureIoTProtocol_HTTP @ 1.6.0 azure/AzureIoTSocket_WiFi @ 1.0.2各依赖的作用:
Seeed Arduino RTC:提供与 Wio Terminal 板上实时时钟(RTC)交互的代码,用于在本地跟踪时间。从源码看,它配合samd/NTPClientAz.h一起使用(见下文ntp.h);AzureIoTHub(arduino-libraries 维护):Arduino 平台上的 Azure IoT Hub 客户端,提供IoTHub_Init、IoTHubDeviceClient_LL_CreateFromConnectionString等核心 API;AzureIoTUtility:SDK 底层工具库;AzureIoTProtocol_MQTT/AzureIoTProtocol_HTTP:协议适配层,本文实际使用 MQTT;AzureIoTSocket_WiFi:基于 WiFi 的 socket 实现,承载网络传输。
在文件末尾追加编译标志:
build_flags = -DDONT_USE_UPLOADTOBLOB该标志会在编译 Arduino 版 IoT Hub 代码时被预处理器使用,用于禁用上传到 Blob 存储的相关功能,是编译通过的必要条件。
仓库中的完整
platformio.ini还保留了bblanchon/ArduinoJson @ 6.17.3(构造遥测 JSON)、seeed-studio/Seeed Arduino rpcWiFi @ 1.0.5(WiFi 连接)以及板级支持库,这些在迁移后继续沿用,不要误删。
第二步:配置 config.h —— 用连接字符串替换 MQTT 设置
打开 config.h,删除所有 MQTT 设置(如 broker 地址、端口、topic、用户名密码等),仅保留 WiFi 凭据与新的 IoT Hub 设置:
#pragma once #include <string> using namespace std; // WiFi credentials const char *SSID = "<SSID>"; const char *PASSWORD = "<PASSWORD>"; // IoT Hub settings const char *CONNECTION_STRING = "<CONNECTION STRING>";将<CONNECTION STRING>替换为你在前置步骤中用 Azure CLI 获取并保存的设备连接字符串。该字符串会作为设备身份凭据,在connectIoTHub()中传给 SDK。
第三步:NTP 时间同步 —— 新建 ntp.h
IoT Hub 的 SAS 认证使用基于时间的令牌,因此设备必须知道当前时间。与 Windows/macOS/Linux 等操作系统不同,微控制器不会自动通过网络同步时间,所以需要显式地从 NTP 服务器获取当前时间,并写入 Wio Terminal 的实时时钟(RTC),只要设备不断电,之后就能随时取到正确时间。
新建ntp.h,内容与仓库中的 ntp.h 一致:
#pragma once #include "DateTime.h" #include <time.h> #include "samd/NTPClientAz.h" #include <sys/time.h> static void initTime() { WiFiUDP _udp; time_t epochTime = (time_t)-1; NTPClientAz ntpClient; ntpClient.begin(); while (true) { epochTime = ntpClient.getEpochTime("0.pool.ntp.org"); if (epochTime == (time_t)-1) { Serial.println("Fetching NTP epoch time failed! Waiting 2 seconds to retry."); delay(2000); } else { Serial.print("Fetched NTP epoch time is: "); char buff[32]; sprintf(buff, "%.f", difftime(epochTime, (time_t)0)); Serial.println(buff); break; } } ntpClient.end(); struct timeval tv; tv.tv_sec = epochTime; tv.tv_usec = 0; settimeofday(&tv, NULL); }这段代码定义initTime()函数:通过NTPClientAz客户端向公共 NTP 服务器0.pool.ntp.org请求 epoch 时间;失败则打印错误并每 2 秒重试;成功后把返回的 UNIX 时间戳(自 1970-01-01 00:00:00 UTC 起的秒数)打印到串口,并通过settimeofday写入系统时钟(底层对应 Wio Terminal 的 RTC)。串口输出的Fetched NTP epoch time is: 1619983687就是这类 epoch 时间戳,可借助在线工具转换为可读日期。
第四步:重构 main.cpp —— 移除 MQTT,接入 IoT Hub
打开 main.cpp。
4.1 清理旧 MQTT 代码
删除所有与 MQTT 相关的代码:PubSubClient.h头文件、PubSubClient变量声明、reconnectMQTTClient与createMQTTClient方法以及所有相关调用。清理后该文件只保留三类职责:连接 WiFi、读取土壤湿度、构造 JSON 文档。
4.2 添加 IoT Hub 头文件与全局句柄
在文件顶部#include区域加入 IoT Hub 库头文件与时间设置头文件:
#include <AzureIoTHub.h> #include <AzureIoTProtocol_MQTT.h> #include <iothubtransportmqtt.h> #include "ntp.h"紧接在 include 指令下方声明连接句柄:
IOTHUB_DEVICE_CLIENT_LL_HANDLE _device_ll_handle;IOTHUB_DEVICE_CLIENT_LL_HANDLE是 SDK 提供的一个不透明句柄类型,代表一条到 IoT Hub 的设备端连接,后续的发送遥测、处理直接方法、驱动消息循环都依赖它。
4.3 连接状态回调
在句柄声明下方添加连接状态回调函数:
static void connectionStatusCallback(IOTHUB_CLIENT_CONNECTION_STATUS result, IOTHUB_CLIENT_CONNECTION_STATUS_REASON reason, void *user_context) { if (result == IOTHUB_CLIENT_CONNECTION_AUTHENTICATED) { Serial.println("The device client is connected to iothub"); } else { Serial.println("The device client has been disconnected"); } }当设备与 IoT Hub 的连接状态发生变化(建立连接、断开连接、认证成功/失败)时,SDK 会调用此回调,状态信息输出到串口,方便调试。
4.4 连接 IoT Hub
添加connectIoTHub()函数:
void connectIoTHub() { IoTHub_Init(); _device_ll_handle = IoTHubDeviceClient_LL_CreateFromConnectionString(CONNECTION_STRING, MQTT_Protocol); if (_device_ll_handle == NULL) { Serial.println("Failure creating Iothub device. Hint: Check your connection string."); return; } IoTHubDeviceClient_LL_SetConnectionStatusCallback(_device_ll_handle, connectionStatusCallback, NULL); }执行流程:
IoTHub_Init()初始化 SDK 的全局状态;IoTHubDeviceClient_LL_CreateFromConnectionString(CONNECTION_STRING, MQTT_Protocol)使用config.h中的连接字符串创建一个基于 MQTT 协议的设备客户端连接。注意这里传入的是MQTT_Protocol(由AzureIoTProtocol_MQTT.h提供);- 若创建失败返回
NULL,向串口打印提示(Failure creating Iothub device. Hint: Check your connection string.)——如果你在串口看到这句话,最可能的原因是连接字符串复制错误或已失效,请检查config.h; - 成功后将连接状态回调注册到客户端,此后 SDK 会通过它通知连接状态变化。
4.5 在 setup 中编排初始化顺序
在setup()中,按顺序完成 WiFi 连接、NTP 时间同步、IoT Hub 连接:
void setup() { Serial.begin(9600); while (!Serial) ; // Wait for Serial to be ready delay(1000); pinMode(A0, INPUT); pinMode(PIN_WIRE_SCL, OUTPUT); connectWiFi(); initTime(); // 先取得当前时间(SAS 令牌依赖它) connectIoTHub(); delay(2000); }其中initTime()调用应放在setup函数末尾(源码中紧随connectWiFi()之后、connectIoTHub()之前),用于设置当前时间;随后调用connectIoTHub()。
connectWiFi()沿用上一课的循环重试实现(见源码 main.cpp),在WiFi.status() != WL_CONNECTED期间不断打印Connecting to WiFi..并重试。
4.6 编译上传并观察串口输出
编译并上传到 Wio Terminal 后,串口监视器应输出类似以下内容:
Connecting to WiFi.. Connected! Fetched NTP epoch time is: 1619983687 Sending telemetry {"soil_moisture":391} The device client is connected to iothub从输出可以看到:先连接 WiFi,再通过 NTP 获取时间,随后设备客户端成功连接 IoT Hub。设备连接可能需要数秒,因此在连接完成前你可能会先看到土壤湿度数据行。
第五步:发送遥测 —— 用 SendEventAsync 代替 MQTT publish
设备连接成功后,就可以把土壤湿度遥测发给 IoT Hub,替代原来发给 MQTT 代理的 publish 操作。
在setup函数上方添加sendTelemetry():
void sendTelemetry(const char *telemetry) { IOTHUB_MESSAGE_HANDLE message_handle = IoTHubMessage_CreateFromString(telemetry); IoTHubDeviceClient_LL_SendEventAsync(_device_ll_handle, message_handle, NULL, NULL); IoTHubMessage_Destroy(message_handle); }执行流程:
IoTHubMessage_CreateFromString(telemetry)把字符串形式的遥测负载包装成 SDK 消息对象(IOTHUB_MESSAGE_HANDLE);IoTHubDeviceClient_LL_SendEventAsync异步地把消息作为D2C 事件发送到 IoT Hub;IoTHubMessage_Destroy释放消息对象,避免内存泄漏。
在loop()中,紧接向串口打印遥测的代码之后调用它:
void loop() { int soil_moisture = analogRead(A0); DynamicJsonDocument doc(1024); doc["soil_moisture"] = soil_moisture; string telemetry; serializeJson(doc, telemetry); Serial.print("Sending telemetry "); Serial.println(telemetry.c_str()); sendTelemetry(telemetry.c_str()); work_delay(10000); }遥测 JSON 由ArduinoJson构造,字段为soil_moisture,值来自analogRead(A0)(Wio Terminal 的 A0 引脚接土壤湿度传感器),例如{"soil_moisture":391}。
底层说明:
IoTHubDeviceClient_LL_SendEventAsync是 SDK 的 LL(Low Level)API,采用事件驱动模型,消息并不是立即发出,而是由IoTHubDeviceClient_LL_DoWork驱动底层协程/线程真正推送。这也解释了下一步为什么需要一个持续调用DoWork的循环。
第六步:处理命令 —— 直接方法回调控制继电器
服务端代码要远程控制继电器,需要以**直接方法请求(Direct Method)**的形式下发命令,设备侧必须注册对应的回调。
6.1 直接方法回调
在connectIoTHub函数之前添加回调函数:
int directMethodCallback(const char *method_name, const unsigned char *payload, size_t size, unsigned char **response, size_t *response_size, void *userContextCallback) { Serial.printf("Direct method received %s\r\n", method_name); if (strcmp(method_name, "relay_on") == 0) { digitalWrite(PIN_WIRE_SCL, HIGH); } else if (strcmp(method_name, "relay_off") == 0) { digitalWrite(PIN_WIRE_SCL, LOW); } char resultBuff[16]; sprintf(resultBuff, "{\"Result\":\"\"}"); *response_size = strlen(resultBuff); *response = (unsigned char *)malloc(*response_size); memcpy(*response, resultBuff, *response_size); return IOTHUB_CLIENT_OK; }要点说明:
- SDK 收到直接方法请求时调用此回调,被请求的方法名放在
method_name参数中; - 函数先向串口打印方法名,然后根据方法名控制继电器:
relay_on把PIN_WIRE_SCL拉高(digitalWrite(..., HIGH)),relay_off拉低——这也是硬件接线中继电器信号线所接的引脚; - 直接方法请求要求应答,应答由两部分组成:文本响应与返回码。代码构造
{"Result":""}形式的 JSON 响应字符串,把长度写入response_size,用malloc分配内存并把内容复制到response指针,最后返回IOTHUB_CLIENT_OK表示方法已被正确处理; - 提示:也可以只实现一个直接方法,把继电器期望状态放在请求的
payload中(即回调的payload参数)进行解析,从而把两个方法合并为一个。
6.2 注册回调
在connectIoTHub()函数末尾(设置连接状态回调之后)注册方法回调:
IoTHubClient_LL_SetDeviceMethodCallback(_device_ll_handle, directMethodCallback, NULL);6.3 优化消息循环:work_delay
loop()必须周期性调用IoTHubDeviceClient_LL_DoWork来驱动消息收发,否则从 Hub 发来的直接方法请求无法被及时处理。原始代码中 10 秒的delay(10000)会阻塞整条循环,导致直接方法最长 10 秒才被处理一次。
解决方式是把 10 秒延迟拆成多次 100ms 的短延迟,并在每次短延迟前调用DoWork。在loop上方添加:
void work_delay(int delay_time) { int current = 0; do { IoTHubDeviceClient_LL_DoWork(_device_ll_handle); delay(100); current += 100; } while (current < delay_time); }然后在loop()中:删除原本在函数顶部的IoTHubDeviceClient_LL_DoWork(_device_ll_handle);调用,并把delay(10000)替换为:
work_delay(10000);这样设备在等待 10 秒的过程中最多 100ms 就会调用一次DoWork,直接方法请求的响应延迟被压缩到 100ms 级别。
第七步:用 Azure CLI 验证遥测与远程控制
完成代码修改、编译上传后,可以回到命令行验证整套链路是否打通(以下命令均来自 README)。
监控遥测事件
保持设备运行并发送遥测,在终端执行:
az iot hub monitor-events --hub-name <hub_name><hub_name>替换为你的 IoT Hub 名称。控制台会实时打印设备发送的 D2C 消息,payload与设备端发送的 JSON 一致:
Starting event monitor, use ctrl-c to stop... { "event": { "origin": "soil-moisture-sensor", "module": "", "interface": "", "component": "", "payload": "{\"soil_moisture\": 376}" } }消息还附带自动生成的属性(annotations),如设备 ID、SAS 认证方式、入队时间(UNIX 时间戳)等,可用--properties anno查看。监控完毕后按Ctrl+C退出。
远程调用直接方法
执行以下命令触发relay_on:
az iot hub invoke-device-method --device-id soil-moisture-sensor \ --method-name relay_on \ --method-payload '{}' \ --hub-name <hub_name>--method-name指定要调用的方法名(对应设备端strcmp(method_name, "relay_on")的分支),--method-payload以 JSON 形式携带方法参数。调用后继电器应打开,设备串口输出:
Direct method received - relay_on重复执行并把--method-name改为relay_off,继电器关闭。
对于本项目使用的免费层 IoT Hub(每天 8000 条消息),当前代码每 10 秒发送一条遥测。课程挑战环节留了一个思考题:每 10 秒一条消息对应每天多少条消息?是否超出了免费层配额?如何调整采样频率以兼顾及时性与配额?这些问题可作为后续优化的练习。
源码对照与进一步探索
本文所有代码均可在仓库中找到可运行的完整版本:
- 完整工程目录:code/wio-terminal,其中 platformio.ini 为构建配置,src/main.cpp 为主程序,src/config.h 为 WiFi 与连接字符串配置,src/ntp.h 为 NTP 时间同步实现;
- 同一课程的 Raspberry Pi / 虚拟设备方案见 single-board-computer-connect-hub.md,其 Python 实现 app.py 使用
azure.iot.device库,通过IoTHubDeviceClient.create_from_connection_string、send_message与on_method_request_received实现与本文 Arduino 代码完全对等的功能(遥测上报 +relay_on/relay_off直接方法),是理解两种语言实现差异的绝佳对照; - 课程整体脉络、云概念讲解、Hub 创建与 CLI 命令见 README。
完成本文的迁移后,你的土壤湿度传感器程序已经稳定地接入 Azure IoT Hub,遥测上报与继电器远程控制全部经由云完成——这是从“能跑通”的本地 MQTT 演示迈向可扩展、安全、可靠的云原生 IoT 应用的关键一步。
【免费下载链接】IoT-For-Beginners12 Weeks, 24 Lessons, IoT for All!项目地址: https://gitcode.com/GitHub_Trending/io/IoT-For-Beginners
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考