19. MQTT上传数据到云平台

一、什么是MQTT协议

  MQTT(Message Queuing Telemetry Transport,消息队列遥测传输协议),是一种基于发布/订阅(Publish/Subscribe)模式的轻量级通讯协议,该协议构建于 TCP/IP 协议上。MQTT 在物联网、小型设备、移动应用等方面有广泛的应用,MQTT 协议属于应用层。

  实现 MQTT 协议需要:客户端和服务器端 MQTT 协议中有三种身份:发布者(Publish)、代理(Broker)、订阅者(Subscribe)。其中,消息的发布者和订阅者都是客户端,消息代理是服务器,消息发布者可以同时是订阅者,如下图所示。

MQTT订阅和发布过程

  MQTT 传输的消息分为:主题(Topic)和 消息的内容(payload)两部分。

  • 主题:可以理解为消息的类型,订阅者订阅(Subscribe)后,就会收到该主题的消息内容。
  • 消息的内容:是指订阅者具体要使用的内容。

二、MQTT协议实现原理

  要在客户端与代理服务端建立一个 TCP 连接,建立连接的过程是由客户端主动发起的,代理服务一直是处于指定端口的监听状态,当监听到有客户端要接入的时候,就会立刻去处理。

  客户端在发起连接请求时,携带 客户端 ID账号密码心跳间隔时间 等数据。代理服务收到后检查自己的连接权限配置中是否允许该账号密码连接,如果允许则建立会话标识并保存,绑定客户端 ID 与会话,并记录心跳间隔时间(判断是否掉线和启动遗嘱时用)和遗嘱消息等,然后回发连接成功确认消息给客户端,客户端收到连接成功的确认消息后,进入下一步(通常是开始订阅主题,如果不需要订阅则跳过)。

客户端与代理服务器建立连接示意图

  客户端将需要订阅的主题经过 SUBSCRIBE 报文发送给代理服务,代理服务则将这个主题记录到该客户端 ID 下(以后有这个主题发布就会发送给该客户端),然后回复确认消息 SUBACK 报文,客户端接到 SUBACK 报文后知道已经订阅成功,则处于等待监听代理服务推送的消息,也可以继续订阅其他主题或发布主题。

客户端向服务器订阅示意图

  当某一客户端发布一个主题到代理服务后,代理服务先回复该客户端收到主题的确认消息,该客户端收到确认后就可以继续自己的逻辑了。但这时主题消息还没有发给订阅了这个主题的客户端,代理要根据质量级别(QoS)来决定怎样处理这个主题。所以这里充分体现了是 MQTT 协议是异步通信模式,不是立即端到端反应的。

客户端向代理服务器发送主题

  如果发布和订阅时的质量级别 QoS 都是 至多一次,那代理服务则检查当前订阅这个主题的客户端是否在线,在线则转发一次,收到与否不再做任何处理。这种质量对系统压力最小。

  如果发布和订阅时的质量级别 QoS 都是 至少一次,那要保证代理服务和订阅的客户端都有成功收到才可以,否则会尝试补充发送。这也可能会出现同一主题多次重复发送的情况。这种质量对系统压力较大。

  如果发布和订阅时的质量级别 QoS 都是 只有一次,那要保证代理服务和订阅的客户端都有成功收到,并只收到一次不会重复发送。这种质量对系统压力最大。

三、配置华为云远程服务器

3.1、开通免费版的实例

  我们可以从以下网址访问华为云的物联网平台:https://console.huaweicloud.com/iotdm/。如果控制台中没有实例,我们需要需要 开通免费版的实例

华为云开头免费单元

华为云免费创建实例

  然后,我们需要点击免费实例的名称,进入点击免费实例。

进入华为云的免费实例

3.2、创建产品

  进入免费实例之后,我们需要 创建产品

华为云创建产品

3.3、注册设备

  创建产品之后,我们需要 注册设备

华为云注册设备

  在我们注册完设备之后,会弹出一个选项框,我们可以查看 设备的 ID设备的密钥(设备的密钥是不可见的,但我们可以点击复制)。我们点击下方的保存并关闭按钮,会自动生成一个 txt 文件保存设备 ID 和设备的密钥。

华为云生成产品的密钥文件

3.4、查看MQTT连接参数

  然后,我们可以进入产品详情,查看 MQTT 连接参数

进入华为云的设备详情页

查看华为云设备的MQTT参数

3.5、定义物模型

  如果我们创建的设备没有物模型数据,则我们需要进入产品详情页,然后 定义物模型

进入华为云的产品详情页

  首先,我们先在产品中 添加服务

华为云产品添加自定义模型

  在产品中添加完服务之后,我们需要在 添加属性

华为云产品的模型新增属性

  然后,我们还可以查看产品的主题。

查看华为云产品的主题

3.6、新增虚拟设备调试

  在创建产品之后,我们可以新增一个虚拟设备进行调试,查看设备上传到华为云平台的 JSON 格式的字符串。

华为云产品新增虚拟设备调试

  华为云产品新增虚拟设备之后,我们在华为云产品的在线调试中选择虚拟设备。

华为云产品的在线调试中选择虚拟设备

  然后,我们选择选择设备模拟器发送数据,查看设备上传给华为云平台的 JSON 格式的字符串。

华为云产品选择设备模拟器发送数据

  发送完数据之后,我们点击平台消息跟踪查看设备上传给华为云平台的 JSON 格式的字符串数据。

image/查看设备上传给华为云平台的JSON格式的数据

3.7、查看华为云平台的开发文档

  在开发过程中遇到一些问题,我们可以查看华为云的开发文件中有没有对应的解决方案。文档的链接如下:https://support.huaweicloud.com/iothub/index.html

  如果我们想要知道设备上传属性时发送给华为云平台的 JSON 格式的字符串数据,除了使用上述的设备模式的方式,还可以直接查看华为云的开发文档。

查看华为云文档中设备上报属性时发送的JSON格式的字符串数据

四、CJson的使用

  JSON (JavaScript Object Notation) 是一种轻量级的数据交换格式,它是 JavaScript 的子集,易于人阅读和编写。JSON 是一个对象,由一个大括号表示。其中,括号中描述对象的属性,通过键值对来描述对象的属性,键与值之间使用冒号连接,多个键值对之间使用逗号分隔。键值对的键 应使用引号引住,键值对的值 ,可以是 JS 中的任意类型的数据。

{
    "services": 
    [
        {
            "service_id": "Thermometer",
            "properties": 
            {
                "temperature": 26,
                "humidity": 80
            }
        }
    ]
}

  CJSON 是一个轻量级的、用于处理 JSON 数据的 C 语言库。它提供了简单而直观的 API,使得在 C 程序中处理 JSON 数据变得相对容易。它的官方连接如下:https://github.com/DaveGamble/cJSON。如果我们要看源码的话,需要查看其中的 cJSON.ccJSON.h 文件。

  我们可以通过 cJSON_CreateObject() 函数 创建一个空的 JSON 对象,该函数返回 cJSON 的指针。

CJSON_PUBLIC(cJSON *) cJSON_CreateObject(void);

  创建 JSON 对象后,我们可以通过下列的一系列函数来添加对应类型的键值对到 JSON 对象中。该系列函数 返回一个指向新添加的 JSON 元素的指针。新返回的这个元素包含了添加的键值对。如果添加失败,返回 NULL。该系类函数的第一个参数 cJSON * const object 指向了你 要添加键值对的 JSON 对象。第二个参数 const char * const name 表示你 要添加的键值对的键值。如果有 第三个参数,则表示了你要 添加的键值对对应的值

CJSON_PUBLIC(cJSON*) cJSON_AddNullToObject(cJSON * const object, const char * const name);
CJSON_PUBLIC(cJSON*) cJSON_AddTrueToObject(cJSON * const object, const char * const name);
CJSON_PUBLIC(cJSON*) cJSON_AddFalseToObject(cJSON * const object, const char * const name);
CJSON_PUBLIC(cJSON*) cJSON_AddBoolToObject(cJSON * const object, const char * const name, const cJSON_bool boolean);
CJSON_PUBLIC(cJSON*) cJSON_AddNumberToObject(cJSON * const object, const char * const name, const double number);
CJSON_PUBLIC(cJSON*) cJSON_AddStringToObject(cJSON * const object, const char * const name, const char * const string);

  如果我们要 添加数组类型的字符串,可以先通过 cJSON_CreateArray() 等一系列方法创建 对应的数据类型的 JSON 的数组对象。该函数返回指向 JSON 数组的指针。如果有参数,第一个参数 是你要 创建对应类型的数组第二个参数数组中元素的个数

CJSON_PUBLIC(cJSON *) cJSON_CreateArray(void);
CJSON_PUBLIC(cJSON *) cJSON_CreateIntArray(const int *numbers, int count);
CJSON_PUBLIC(cJSON *) cJSON_CreateFloatArray(const float *numbers, int count);
CJSON_PUBLIC(cJSON *) cJSON_CreateDoubleArray(const double *numbers, int count);
CJSON_PUBLIC(cJSON *) cJSON_CreateStringArray(const char *const *strings, int count);

  然后通过 cJSON_AddItemToArray() 方法 将元素添加到 JSON 数组中。该函数返回一个布尔值表示成功或失败。如果成功添加元素,返回 true,否则返回 false。该函数的 CJSON *array 参数是一个指向 JSON 数组的指针,表示你要 往哪个数组中添加元素。参数 cJSON *ite 表示了你要 添加的键值对,该键值对可以是任何 JSON 数据类型,比如字符串、数字、数组、对象等。

CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToArray(cJSON *array, cJSON *item);

  我们可以通过如下的一系列方法 创建对应的 JSON 格式的数据类型

CJSON_PUBLIC(cJSON *) cJSON_CreateNull(void);
CJSON_PUBLIC(cJSON *) cJSON_CreateTrue(void);
CJSON_PUBLIC(cJSON *) cJSON_CreateFalse(void);
CJSON_PUBLIC(cJSON *) cJSON_CreateBool(cJSON_bool boolean);
CJSON_PUBLIC(cJSON *) cJSON_CreateNumber(double num);
CJSON_PUBLIC(cJSON *) cJSON_CreateString(const char *string);

  接着,我们调用 cJSON_AddItemToObject() 方法。该函数可以 将 JSON 任意数据类型的键值对添加到 JSON 对象中。该函数返回一个布尔值表示成功或失败。如果成功添加元素,返回 true,否则返回 false。该函数的第一个参数 cJSON * const object指向了你 要添加键值对的 JSON 对象。第二个参数 const char * string 表示你要 添加的键值对的键值。如果有 第三个参数,则表示了你 要添加的键值对,该键值对可以是任何 JSON 数据类型,比如字符串、数字、数组、对象等。

CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObject(cJSON *object, const char *string, cJSON *item)

  在创建完成 JSON 对象后,我们可以调用 cJSON_Print() 方法 将 JSON 对象转换为字符串。该函数的 返回值 是一个 指向字符数组的指针,表示包含 JSON 元素内容的字符串。该函数的参数 const cJSON *item 是一个指向 cJSON 元素的指针,表示你要 将哪个 JSON 元素转换成字符串

CJSON_PUBLIC(char *) cJSON_Print(const cJSON *item);

  需要注意的是,这个 返回的字符串是在堆上动态分配的,所以在使用完毕后,需要负责释放内存以防止内存泄漏。我们可以使用 cJSON_Free() 函数即可释放使用 cJSON_Print() 转换后的 JSON 格式的字符串的内存。该函数的参数 void *object 是一个 转换后的 JSON 格式的字符串的指针

CJSON_PUBLIC(void) cJSON_free(void *object)

  在释放 JSON 格式的字符串之后,我们还需要使用 cJSON_Delete() 函数释放 JSON 对象的内存控件,它的的参数 void *object 是一个指向 cJSON 元素的指针。

CJSON_PUBLIC(void) cJSON_Delete(cJSON *item);

  使用 CJSON 过程中,有的时候只需要更新整个 JSON 对象中的部分数据,CJSON 中提供了这样的接口:

CJSON_PUBLIC(void) cJSON_ReplaceItemInArray(cJSON *array, int which, cJSON *newitem);
CJSON_PUBLIC(void) cJSON_ReplaceItemInObject(cJSON *object, const char *string, cJSON *newitem);

  我们可以使用 cJSON_ReplaceItemInArray() 函数将 CJSON 对象的数组元素进行更新,参数 array要更改元素的 CJSON 数组的指针,参数 which要更改元素的 CJSON 数组的索引,参数 newitem新的元素对象

  我们还可以使用 cJSON_ReplaceItemInObject() 函数 更改 CJSON 对象的键值对,参数 object 是 JSON 对象的指针,参数 string要更改值的键值对中的键,参数 newitem要更改的值。要注意,针对键值对的更新中,第三个参数不能直接以字符串或者数字赋值,必须调用 CJSON 的接口,生成对象数据。

  如果我们使用 cJSON_ReplaceItemInArray()cJSON_ReplaceItemInObject()函数修改值时,旧的值会被自动释放,无需手动调用 cJSON_Delete() 函数。

五、MQTT常用函数

  ESP IDF 提供了一套 API 来驱动 WiFi 功能。要使用此功能,我们需要在 CMakeLists.txt 文件中导入 mqtt 依赖库,然后还需要导入必要的头文件:

# 注册组件到构建系统的函数
idf_component_register(
    # 依赖库的路径
    REQUIRES mqtt
)
#include "mqtt_client.h"

5.1、初始化MQTT客户端

  我们可以使用 esp_mqtt_client_init() 函数 初始化 MQTT 客户端,它的函数原型如下:

/**
 * @brief 初始化MQTT客户端函数
 * 
 * @param config MQTT客户端配置结构体指针
 * @return esp_mqtt_client_handle_t 成功返回MQTT客户端句柄,失败返回NULL
 */
esp_mqtt_client_handle_t esp_mqtt_client_init(const esp_mqtt_client_config_t *config);

  形参 configMQTT 客户端配置结构体指针,它的定义如下:

typedef struct esp_mqtt_client_config_t 
{
    // 与代理相关的配置
    struct broker_t 
    {
        // 代理地址配置,uri字段优先于其他字段设置,如果uri没有设置至少主机名,传输和端口应该设置。
        struct address_t 
        {
            const char *uri;                                // 完整的MQTT代理URI
            const char *hostname;                           // 主机名,设置ipv4,将其作为字符串传递
            esp_mqtt_transport_t transport;                 // 选择运输
            const char *path;                               // URI中的路径
            uint32_t port;                                  // MQTT服务器端口
        } address;

        // 代理地址和安全验证,代理身份验证,如果没有设置字段,则不验证代理的身份。出于安全考虑,建议在此结构中设置选项。
        struct verification_t 
        {
            bool use_global_ca_store;                       // 使用全局ca_store
            esp_err_t (*crt_bundle_attach)(void *conf);     // 用于使用证书包。客户端只附加bundle,清理必须由用户完成
            const char *certificate;                        // 证书数据,默认为NULL。它不会被客户端复制或释放,用户需要清理
            size_t certificate_len;                         // 证书指向的缓冲区长度
            // 用于启用PSK身份验证(作为证书验证的替代方案)。只有在没有其他方法验证代理时才启用PSK。它不会被客户端复制或释放,用户需要清理
            const struct psk_key_hint *psk_hint_key;
            bool skip_cert_common_name_check;               // 跳过对服务器证书CN字段的任何验证,这会降低TLS的安全性,并使MQTT客户端容易受到MITM攻击
            const char **alpn_protos;                       // 支持用于ALPN的应用程序协议的以空结尾的列表
            // 指向包含服务器证书通用名称的字符串的指针。
            // 如果非null,服务器证书CN必须匹配此名称;
            // 如果为NULL,服务器证书CN必须与主机名匹配。
            // 如果skip_cert_common_name_check=true,则忽略此选项。它不会被客户端复制或释放,用户需要清理
            const char *common_name;
        } verification;                                     // 代理的安全验证
    } broker;
  
    // 用于身份验证的客户端相关凭据
    struct credentials_t 
    {
        const char *username;                               // MQTT username
        const char *client_id;                              // 设置MQTT客户端标识符。如果set_null_client_id == true则忽略,如果NULL则设置默认客户端id
        bool set_null_client_id;                            // 选择一个NULL客户端id

        // 代理的用户凭据,客户端身份验证,与代理的客户端身份验证相关的字段
        // 对于使用TLS的相互身份验证,用户可以选择证书和密钥、安全元素或数字签名外围设备(如果有)
        struct authentication_t 
        {
            const char *password;                           // MQTT password
            const char *certificate;                        // ssl互认证证书,不需要互认证时不需要。
            size_t certificate_len;                         // 证书指向的缓冲区长度
            // SSL相互身份验证的私钥,如果不需要相互身份验证,则不需要。如果它不是NULL,还必须提供 certificate。它不会被客户端复制或释放,用户需要清理。
            const char *key;
            const char *key_password;                       // 客户端密钥解密密码
            int key_password_len;                           // key_password 所指向的密码长度
            bool use_secure_element;                        // 为SSL连接启用ESP32-ROOM-32SE中提供的安全元素
            void *ds_data;                                  // 为数字签名参数手柄的载体,它不会被客户端复制或释放,用户需要清理。
        } authentication;                                   // 客户端身份验证
    } credentials;
  
    // MQTT会话相关配置
    struct session_t 
    {
        // 最后遗嘱和遗嘱消息配置。
        struct last_will_t 
        {
            const char *topic;                              // LWT(最后遗嘱)信息主题
            const char *msg;                                // LWT消息,可能以NULL终止
            int msg_len;                                    // LWT消息长度,如果msg不是NULL终止必须有正确的长度
            int qos;                                        // LWT报文QoS
            int retain;                                     // LWT保留消息标志
        } last_will;

        bool disable_clean_session;                         // MQTT清洁会话,默认clean_session为true
        int keepalive;                                      // MQTT保持活动,默认值为120秒
        // 设置 disable_keepalive=true 关闭keepalive机制,默认keepalive是激活的。
        // 将配置值 keepalive 设置为 0 不会禁用keepalive功能,而是使用默认的保持时间
        bool disable_keepalive;
        esp_mqtt_protocol_ver_t protocol_ver;               // 用于连接的MQTT协议版本
        int message_retransmit_timeout;                     // 重传失败报文的超时时间
    } session; 
  
    // 网络相关配置
    struct network_t 
    {
        int reconnect_timeout_ms;                           // 如果未禁用自动重新连接,则在此值之后以毫秒为单位重新连接到代理(默认为10秒)
        int timeout_ms;                                     // 如果在此值之后未完成网络操作,则中止网络操作,以毫秒为单位(默认为10秒)
        int refresh_connection_after_ms;                    // 在此值之后刷新连接(以毫秒为单位)
        bool disable_auto_reconnect;                        // 客户端将重新连接到服务器(当错误/断开连接时)。设置 disable_auto_reconnect=true 为禁用
        esp_transport_handle_t transport;                   // 要使用的自定义传输句柄。传输在客户端生命周期内应该是有效的,并且在调用esp_mqtt_client_destroy时被销毁
        struct ifreq * if_name;                             // 数据要经过的接口名称。使用默认接口,无需配置
    } network;
  
    // 客户端任务配置
    struct task_t 
    {
        int priority;                                       // MQTT任务优先级
        int stack_size;                                     // MQTT任务堆栈大小
    } task;

    // 客户端缓冲区大小配置,客户端分别有两个缓冲区用于输入和输出。
    struct buffer_t 
    {
        int size;                                           // MQTT发送/接收缓冲区的大小
        int out_size;                                       // MQTT输出缓冲区的大小。如果未定义,则默认为buffer_size定义的大小
    } buffer;

    // 客户端发件箱配置选项。
    struct outbox_config_t 
    {
        uint64_t limit;                                     // 发件箱的大小限制(以字节为单位)
    } outbox;
} esp_mqtt_client_config_t;

5.2、注册MQTT客户端事件

  我们可以使用 esp_mqtt_client_register_event() 函数 注册 MQTT 客户端事件,它的函数原型如下:

/**
 * @brief 注册MQTT客户端事件
 * 
 * @param client MQTT客户端句柄
 * @param event 事件类型
 * @param event_handler 回调函数
 * @param event_handler_arg 回调函数参数
 * @return esp_err_t ESP_OK注册成功,其它注册失败
 */
esp_err_t esp_mqtt_client_register_event(esp_mqtt_client_handle_t client, esp_mqtt_event_id_t event, esp_event_handler_t event_handler, void *event_handler_arg);

  形参 event_handler注册的回调函数,它的类型如下:

/**
 * @brief 注册接收MQTT事件的事件处理程序
 * 
 * @param handler_args 注册到事件的用户数据
 * @param base 处理程序的事件库
 * @param event_id 接收到的事件的id
 * @param event_data 事件的数据
 */
static void mqtt_event_handler(void *handler_args, esp_event_base_t base, int32_t event_id, void *event_data);

5.3、连接MQTT服务器

  我们可以使用 esp_mqtt_client_start() 函数 连接 MQTT 服务器,它的函数原型如下:

/**
 * @brief 连接MQTT服务器
 * 
 * @param client MQTT客户端句柄
 * @return esp_err_t ESP_OK注册成功,其它注册失败
 */
esp_err_t esp_mqtt_client_start(esp_mqtt_client_handle_t client);

5.4、发布MQTT消息

  我们可以使用 esp_mqtt_client_publish() 函数 发布 MQTT 消息,它的原型如下:

/**
 * @brief 发布MQTT消息
 * 
 * @param client MQTT客户端句柄
 * @param topic 主题
 * @param data 有效载荷字符串(设置为NULL,发送空有效载荷消息)
 * @param len 数据长度,如果设置为0,则从有效负载字符串计算长度
 * @param qos 发布消息的QoS
 * @param retain 保留标志
 * @return int 成功时发布消息的message_id(对于QoS 0 message_id将始终为零)。失败时-1,发件箱满时-2。
 */
int esp_mqtt_client_publish(esp_mqtt_client_handle_t client, const char *topic, const char *data, int len, int qos, int retain);

5.5、订阅MQTT报文

  我们可以使用 esp_mqtt_client_subscribe() 函数 订阅 MQTT 报文,它的原型如下:

/**
 * @brief 订阅函数
 * 
 * @param client_handleMQTT MQTT客户端句柄
 * @param topic_type 订阅的主题,对于单个订阅需要char*,对于多个主题需要 esp_mqtt_topic_t 
 * @param qos_or_size 在订阅单个主题时,它是qos;在订阅多个主题时,它是订阅数组的大小。
 * @return int 成功订阅消息的Message_id。失败时-1,发件箱满时-2。
 */
#define esp_mqtt_client_subscribe(client_handle, topic_type, qos_or_size) _Generic((topic_type), \
      char *: esp_mqtt_client_subscribe_single, \
      const char *: esp_mqtt_client_subscribe_single, \
      esp_mqtt_topic_t*: esp_mqtt_client_subscribe_multiple \
    )(client_handle, topic_type, qos_or_size)
/**
 * @brief 订阅单个主题
 * 
 * @param client MQTT客户端句柄
 * @param topic 主题
 * @param qos 订阅的最大qos级别
 * @return int 成功订阅消息的Message_id。失败时-1,发件箱满时-2。
 */
int esp_mqtt_client_subscribe_single(esp_mqtt_client_handle_t client, const char *topic, int qos);
typedef struct topic_t 
{
    const char *filter;                     // 主题
    int qos;                                // 订阅的最大QoS级别
} esp_mqtt_topic_t;

/**
 * @brief 订阅多个主题
 * 
 * @param client MQTT客户端句柄
 * @param topic_list 要订阅的主题列表
 * @param size 订阅的主题列表的大小
 * @return int 成功订阅消息的Message_id。失败时-1,发件箱满时-2。
 */
int esp_mqtt_client_subscribe_multiple(esp_mqtt_client_handle_t client, const esp_mqtt_topic_t *topic_list, int size);

5.6、取消订阅MQTT报文

  我们可以使用 esp_mqtt_client_unsubscribe() 函数 从已定义的主题取消订阅客户端,它的原型如下:

/**
 * @brief 从已定义的主题取消订阅客户端
 * 
 * @param client MQTT客户端句柄
 * @param topic 主题
 * @return int 成功时订阅消息的Message_id,失败时为-1
 */
int esp_mqtt_client_unsubscribe(esp_mqtt_client_handle_t client, const char *topic);

六、实验例程

  我们在【components】文件夹下的【peripheral】文件夹下的【inc】文件夹(用来存放头文件)新建一个 bsp_mqtt.h 文件,在【components】文件夹下的【peripheral】文件夹下的【src】文件夹(用来存放源文件)新建一个 bsp_mqtt.c 文件。

#ifndef __BSP_MQTT_H__
#define __BSP_MQTT_H__

#include "mqtt_client.h"

// MQTT的端口是1883,MQTTS的端口是8883
#define MQTT_DEVICE_ID          "67fa5ad42902516e8670ba2e_D001"                                         // 设备ID
#define MQTT_DEVICE_SECRET      " "                                      // 设备密钥

#define MQTT_HOSTNAME           "a0278c2159.st1.iotda-device.cn-north-4.myhuaweicloud.com"              // MQTT服务器的域名
#define MQTT_PORT               1883                                                                    // MQTT服务器的端口号

#define MQTT_CLIENT_USERNAME    "67fa5ad42902516e8670ba2e_D001"                                         // 客户端用户名
#define MQTT_CLIENT_PASSWORD    "0fc313506e599821d3a72334d8c8c89f2f019130efc270d53e71fc8b149850ba"      // 客户端密码
#define MQTT_CLIENT_ID          "67fa5ad42902516e8670ba2e_D001_0_0_2025041214"                          // 客户端ID

#define MQTT_DEVICE_REPORT_PROPERTIES_TOPIC     "$oc/devices/"MQTT_DEVICE_ID"/sys/properties/report"    // 设备上报属性报文

extern bool g_mqtt_connect_flag;                                                // MQTT连接标志位

extern esp_mqtt_client_handle_t g_mqtt_client_handle;                           // MQTT客户端句柄

void bsp_mqtt_client_init(void);

#endif // !__BSP_MQTT_H__
#include "bsp_mqtt.h"

bool g_mqtt_connect_flag = false;                                               // MQTT连接标志位

esp_mqtt_client_handle_t g_mqtt_client_handle;

static void bsp_mqtt_event_handler(void *handler_args, esp_event_base_t base, int32_t event_id, void *event_data);

void bsp_mqtt_client_init(void)
{
    // 设置客户端的信息量
    esp_mqtt_client_config_t mqtt_client_config = 
    {
        .broker.address.hostname = MQTT_HOSTNAME,                               // MQTT服务器的域名
        .broker.address.port = MQTT_PORT,                                       // MQTT服务器的端口号
        .broker.address.transport = MQTT_TRANSPORT_OVER_TCP,                    // TCP模式
        .credentials.username = MQTT_CLIENT_USERNAME,                           // 客户端用户名
        .credentials.authentication.password = MQTT_CLIENT_PASSWORD,            // 客户端密码
        .credentials.client_id = MQTT_CLIENT_ID,                                // 客户端ID
    };

    g_mqtt_client_handle = esp_mqtt_client_init(&mqtt_client_config);           // 初始化MQTT客户端

    // 注册MQTT事件处理程序
    esp_mqtt_client_register_event(g_mqtt_client_handle, ESP_EVENT_ANY_ID, bsp_mqtt_event_handler, NULL);

    esp_mqtt_client_start(g_mqtt_client_handle);                                // 连接MQTT服务器 发布数据
}

/**
 * @brief 注册接收MQTT事件的事件处理程序
 * 
 * @param handler_args 注册到事件的用户数据
 * @param base 处理程序的事件库
 * @param event_id 接收到的事件的id
 * @param event_data 事件的数据
 */
static void bsp_mqtt_event_handler(void *handler_args, esp_event_base_t base, int32_t event_id, void *event_data)
{
    esp_mqtt_event_handle_t event = event_data;
    esp_mqtt_client_handle_t client = event->client;

    switch (event_id)
    {
        case MQTT_EVENT_CONNECTED:                                              // 连接事件
            g_mqtt_connect_flag = true;
            printf("mqtt connected\n");
            break;

        case MQTT_EVENT_DISCONNECTED:                                           // 断开连接事件
            g_mqtt_connect_flag = false;
            printf("mqtt disconnected\n");
            esp_mqtt_client_reconnect(client);                                  // 重连MQTT服务器
            break;

        case MQTT_EVENT_SUBSCRIBED:                                             // 订阅事件
            printf("mqtt subscribed ack\n");
            break;

        case MQTT_EVENT_PUBLISHED:                                              // 发布事件
            printf("mqtt published ack\n");
            break;

        case MQTT_EVENT_DATA:                                                   // 接收数据事件
            printf("topic: %.*s\n", event->topic_len, event->topic);
            printf("data: %.*s\n", event->data_len, event->data);
            break;

        case MQTT_EVENT_ANY:                                                    // 其它事件
            printf("mqtt event any\n");
            break;
    }
}

  然后,我们修改【components】文件夹下【peripheral】文件夹下的 CMakeLists.txt 文件。

# 源文件路径
set(src_dirs src)

# 头文件路径
set(include_dirs inc)

# 设置依赖库
set(requires 
    driver
    esp_wifi
    mqtt
)

# 注册组件到构建系统的函数
idf_component_register(
    # 源文件路径
    SRC_DIRS ${src_dirs}
    # 自定义头文件的路径
    INCLUDE_DIRS ${include_dirs}
    # 依赖库的路径
    REQUIRES ${requires}
)

# 设置特定组件编译选项的函数
# -ffast-math: 允许编译器进行某些可能减少数学运算精度的优化,以提高性能。
# -O3: 这是一个优化级别选项,指示编译器尽可能地进行高级优化以生成更高效的代码。
# -Wno-error=format: 这将编译器关于格式字符串不匹配的警告从错误降级为警告。
# -Wno-format: 这将完全禁用关于格式字符串的警告。
component_compile_options(-ffast-math -O3 -Wno-error=format=-Wno-format)

  修改【main】文件夹下的 main.c 文件。

#include "nvs_flash.h"

#include "freertos/FreeRTOS.h"

#include "cJSON.h"

#include "bsp_wifi.h"
#include "bsp_mqtt.h"
#include "bsp_rng.h"


// app_main()函数是ESP32的入口函数,它是FreRTOS的一个任务,任务优先级是1
// main()函数是C语言入口函数,它会在编译过程中插入到二进制文件中的
void app_main(void)
{
    double temperature = 0, humidity = 0;                                       // 温度和湿度变量

    esp_err_t result = nvs_flash_init();                                        // 初始化NVS

    if (result == ESP_ERR_NVS_NO_FREE_PAGES || result == ESP_ERR_NVS_NEW_VERSION_FOUND)
    {
        ESP_ERROR_CHECK(nvs_flash_erase());
        ESP_ERROR_CHECK(nvs_flash_init());
    }

    cJSON *root_node = cJSON_CreateObject();                                    // 创建一个JSON对象作为根节点

    cJSON *services_node = cJSON_CreateArray();                                 // 创建一个JSON数组的键值对
    cJSON_AddItemToObject(root_node, "services", services_node);                // 将JSON数组添加到根节点中

    cJSON *service_node = cJSON_CreateObject();                                 // 创建一个JSON对象作为services数组的子节点
    cJSON_AddItemToArray(services_node, service_node);                          // 将子节点添加到services数组中

    cJSON_AddStringToObject(service_node, "service_id", "Thermometer");         // 添加一个字符串类型的键值对service_node节点中

    cJSON *property_node = cJSON_CreateObject();                                // 创建一个JSON对象作为service_node的子节点
    cJSON_AddNumberToObject(property_node, "temperature", temperature);         // 添加一个数字类型的键值对到properties节点中
    cJSON_AddNumberToObject(property_node, "humidity", humidity);               // 添加一个数字类型的键值对到properties节点中
    cJSON_AddItemToObject(service_node, "properties", property_node);           // 将properties的子JSON节点添加到services数组中

    bsp_wifi_sta_init("HUAWEI-1AA2CE", "27185.Sakura", WIFI_AUTH_WPA2_PSK);

    while (1)
    {
        while (!g_mqtt_connect_flag)
        {
            if (g_wifi_connect_flag)
            {
                // 初始化MQTT客户端
                bsp_mqtt_client_init(); 
            }
  
            vTaskDelay(pdMS_TO_TICKS(1000));
        }

        if (g_mqtt_connect_flag)
        {
            temperature = bsp_rng_get_random_number_in_range(0, 128);
            humidity = bsp_rng_get_random_number_in_range(0, 100);

            // 修改键值对中的值
            cJSON_ReplaceItemInObject(property_node, "temperature", cJSON_CreateNumber(temperature));
            cJSON_ReplaceItemInObject(property_node, "humidity", cJSON_CreateNumber(humidity));

            char *str = cJSON_Print(root_node);                                 // 将JSON对象转换为字符串

            // 发布上报属性主题
            esp_mqtt_client_publish(g_mqtt_client_handle, MQTT_DEVICE_REPORT_PROPERTIES_TOPIC, str, 0, 1, 0); 

            printf("\n\nJSON string: %s\n\n", str);

            cJSON_free(str);                                                    // 释放JSON字符串内存
        }
    
        // 将一个任务延迟给定的滴答数,IDF中提供pdMS_TO_TICKS可以将指定的ms转换为对应的tick数
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}
posted @ 2025-04-08 22:27  星光映梦  阅读(609)  评论(0)    收藏  举报