ESP32学习笔记

一、PWM

①duty_res最大可以设置为14

②呼吸灯设置流程

(1)定时器初始化

(2)PWM通道初始化

(3)设置渐变

③ipoint为hpoint+duty得到

二、驱动I2C

(1)包含"driver/i2c_master.h"头文件

(2)实例化i2c驱动结构体,传入时钟源、scl、sda、毛刺过滤

(3)进行初始化,其会传回一个句柄,使用全局变量接收这个句柄

(4)实例化相应设备的结构体,传入地址数量,设备地址,传输频率

(5)进行初始化,得到设备句柄,随后将会操作句柄进行设备控制

①写函数的编写

(1)写入地址,数据低八位,高八位

(2)使用传输函数传入设备句柄,写入数组,数据个数,等待时间

②读函数的编写

(1)先写入地址,随后读取数据即可

三、xl9555控制按键流程

xl9555的INT引脚在任意设置为输入的引脚的电平改变后就会触发中断,此时与INT引脚相连的GPIO17就会读取到下降沿,随后就会设置标志位,读取任务得到标志位后就会开始读取IO口电平,一旦读取了IO口电平后INT中断就会恢复,任务函数也会继续执行相应的执行回调函数

①进行i2c驱动初始化以及设备初始化后

②io口中断初始化以及事件标志组初始化

(1)相关引脚配置,注意设置触发条件

(2)注册全局中断服务(固定操作)

(3)设置相应的中断回调函数,参数为相关的中断IO口,中断函数,以及参数

 /* 设置GPIO的中断回调函数 */
    gpio_isr_handler_add(xl9555_isr_io, xl9555_exit_gpio_isr_handler, (void*) xl9555_isr_io);

(4)外部中断服务函数,其中的arg即为设置GPIO中断函数的xl9555_isr_io,在此中断中进行事件标志组的置位,要注意中断回调函数需要用IRAN_ATTR修饰

/**
 * 外部中断服务函数
 * @param arg 中断引脚号,在注册中断回调函数时已通过参数带进来
 * @return 无
 */
static void IRAM_ATTR xl9555_exit_gpio_isr_handler(void *arg)
{
    uint32_t gpio_num = (uint32_t) arg;
    BaseType_t task_woken;
    if (gpio_num == xl9555_isr_io)
    {
        if (gpio_get_level(xl9555_isr_io) == 0)
        {
            xEventGroupSetBitsFromISR(xl9555_isr_event,XL9555_ISR_BIT,&task_woken);
        }
    }
}

(5)编写任务函数(此任务等待任务标志组,得到任务标志后进行io口的判断以及进行回调函数的执行)

static void xl9555_intput_scan(void* param)
{
    esp_err_t ret;
    EventBits_t ev;
    uint16_t last_input = 0;
    xl9555_read_word(XL9555_INPUT_PORT0_REG,&last_input); 
    while(1)
    {
        uint16_t input;
        ev = xEventGroupWaitBits(xl9555_isr_event,XL9555_ISR_BIT,pdTRUE,pdFALSE,pdMS_TO_TICKS(10*1000)); //等待BIT0事件组
        if(ev & XL9555_ISR_BIT) //返回的句柄判断是否为BIT0
        {
            if ( gpio_get_level(xl9555_isr_io) != 0)
            {
                continue;
            }
            ret = xl9555_read_word(XL9555_INPUT_PORT0_REG,&input);        //读取输入寄存器
            if(ret == ESP_OK)
            {
                for(int i = 0;i < 16;i++)
                {
                    if(xl9555_io_config & (1 <<i))//判断是否已经将对应端口设置为输入
                    {
                        uint8_t value = input&(1<<i)?1:0;
                        uint8_t last_value = last_input&(1<<i)?1:0;
                        if(value != last_value && xl9555_input_callback)
                        {
                            xl9555_input_callback(1<<i,value);
                        }
                    }
                }
            }
            last_input = input;
        }
    }
}

四、按键处理

①先定义按键配置的结构体,包含按键的编号等

//按键配置结构体
typedef struct
{
    int gpio_num;           //GPIO编号
    int active_level;       //按下的电平
    int long_press_time;    //长按的时间(ms)
    button_press_cb_t short_press_cb;   //短按的回调函数
    button_press_cb_t long_press_cb;   // 长按的回调函数
    button_getlevel_cb_t getlevel_cb;  //获取电平操作
}button_config_t;

②定义按键状态的枚举值,包含按键的各个状态

//按键状态枚举
typedef enum
{
    BUTTON_RELEASE, // 按键松开
    BUTTON_PRESS,   // 按键消抖(检测到按键按下)
    BUTTON_HOLD,    // 按键按住状态
    BUTTON_LONG_PRESS_HOLD, //等待松手

} BUTTON_STATE;

③定义按键状态信息的结构体,包括按键的配置,按键状态等

//按键状态信息结构体
typedef struct Button_Info
{
    button_config_t btn_cfg;    //按键配置
    BUTTON_STATE state;         //当前按键状态
    int press_cnt;              //计数器(计时长按时间)
    struct Button_Info *next;   //下一个按键参数
}button_info_t;

④编写按键创建初始化函数,此函数传入一个按键配置结构体

(1)此函数先分配按键状态信息的结构体内存

(2)结构体参数初始化为0

(3)将按键配置结构体直接赋值给按键信息状态结构体

(4)整个按键事件是一个链表,每次添加的新按键将会添加到链表尾部,故遍历到链表尾后插入

(5)进行定时器配置(当定时器标志位未至1时)

1.定义定时器间隔时间为5ms

2.进行定时器配置,内容有回调函数,传递参数等

3.进行定时器的创建,传入定时器结构体和操作句柄

4.开启定时器轮转模式,设置时间为5000us

5.至标志位为1

esp_err_t button_event_set(button_config_t* cfg)
{
    button_info_t *btn = (button_info_t *)malloc(sizeof(button_info_t));
    if(!btn) return ESP_FAIL;
    memset(btn, 0, sizeof(button_info_t));
    memcpy(&btn->btn_cfg, cfg, sizeof(button_config_t));
    if(!button_head)
    {
        button_head = btn;
    }
    else
    {
        button_info_t* info = button_head;
        while(info->next) info = info->next;
        info->next = btn;
    }
    if(!timer_running)
    {
        static int button_interval = 5;  //定时器的间隔
        //配置定时器
        esp_timer_create_args_t button_timer = 
        {
            .callback = button_handle,
            .name = "button",
            .dispatch_method = ESP_TIMER_TASK,
            .arg = (void*)button_interval,
        };
        esp_timer_create(&button_timer,&button_timer_handle);
        esp_timer_start_periodic(button_timer_handle,5000);   //启用定时器周期运转,时间为us
        timer_running = true;
    }
    return ESP_OK;
}

⑤编写定时器回调函数

1.先获取到链表头

2.将传递的参数得到(定时器间隔时间)

3.开始从头到尾遍历链表

4.先取得相应的io口

5.使用状态机处理相应的按键状态

当按键处于松开状态时检测到按键被按下,则进入按键消抖状态

按键消抖的时长大于20ms时即可以执行按键短按状态

按键短按状态的时长大于所设的长按时间后,则触发长按模式

//回调函数
static void button_handle(void *arg)
{
    button_info_t *btn_info = button_head;
    int interval = (int)(arg);
    for(;btn_info;btn_info = btn_info->next)
    {
        int gpio_num = btn_info->btn_cfg.gpio_num;
        switch (btn_info->state)
        {
            case BUTTON_RELEASE: // 按键松开
                if (btn_info->btn_cfg.getlevel_cb(gpio_num) == btn_info->btn_cfg.active_level)
                {
                    btn_info->state = BUTTON_PRESS; //检测到按键被按下,当前状态变为消抖状态
                    btn_info->press_cnt += interval; //计数器进行计数
                }
                break;
            case BUTTON_PRESS:           // 按键消抖(检测到按键按下)
                if (btn_info->btn_cfg.getlevel_cb(gpio_num) == btn_info->btn_cfg.active_level)
                {
                    btn_info->press_cnt += interval; // 计数器进行计数
                    if(btn_info->press_cnt >= 20)   //消抖时间大于20
                    {
                        if (btn_info->btn_cfg.short_press_cb)
                        {
                            btn_info->btn_cfg.short_press_cb(gpio_num);
                        }
                        btn_info->state = BUTTON_HOLD;  //按键按住的状态
                    }
                }
                else
                {
                    btn_info->state = BUTTON_RELEASE;
                    btn_info->press_cnt = 0;
                }
                break;
            case BUTTON_HOLD:           // 按键按住状态
                if (btn_info->btn_cfg.getlevel_cb(gpio_num) == btn_info->btn_cfg.active_level)
                {
                    btn_info->press_cnt += interval; // 计数器进行计数
                    if (btn_info->press_cnt >= btn_info->btn_cfg.long_press_time) // 时间大于所所设
                    {
                        if (btn_info->btn_cfg.long_press_cb)
                        {
                            btn_info->btn_cfg.long_press_cb(gpio_num);
                        }
                        btn_info->state = BUTTON_LONG_PRESS_HOLD; //转为等待松手状态
                    }
                }
                else
                {
                    btn_info->state = BUTTON_RELEASE;
                    btn_info->press_cnt = 0;
                }
                break;
            case BUTTON_LONG_PRESS_HOLD: // 等待松手
                if (btn_info->btn_cfg.getlevel_cb(gpio_num) != btn_info->btn_cfg.active_level)
                {
                    btn_info->state = BUTTON_RELEASE;
                    btn_info->press_cnt = 0;
                }
                break;
            default:break;
        }
        
    }
}

⑥由于按键引脚与xl9555相连接,故在主函数中需要配置xl9555,读取电平的操作函数也与xl9555有关,随后还需要配置按键结构体,

⑦电平读取的处理(关键)由于xl9555使用read函数读取十分耗时,我们可以利用xl9555io电平改变触发中断的特性,使用一个16位数据来存储各个位置的值,当有值被改变时直接改变存储的数据读取电平值时只需要读取相应位的值即可

volatile uint16_t xl9555_button_level = 0xFFFF;

void xl9555_input_callback(uint16_t io_num, int level)
{
    if(level)
    {
        xl9555_button_level |= io_num;
    }
    else
    {
        xl9555_button_level &= ~io_num;
    }
}

// 获取GPIO口电平回调函数 由于使用xl9555读取电平的函数十分耗时,当电平变化时回调函数即会改变相应的位值,直接读取位值即可
int get_gpio_level_handle(int gpio)
{
    return xl9555_button_level&gpio?1:0;
}

五、WIFI连接

ESP32S3只支持2.4G频段的WIFI

①STA模式

①编写wifi初始化函数

1.首先初始化tcpip协议栈

2.创建一个默认系统事件调度循环,之后可以注册回调函数来处理系统的一些事件

3.使用默认配置创建STA对象,其会自动帮助配置网卡驱动等

4.进行WIFI的初始化,直接默认自动填充

5.对事件注册回调函数

6.设置WIFI的工作模式为STA模式,随后启动WIFI即可完成wifi初始化

// wifi初始化函数
void wifi_manager_init(p_wifi_state_cb f)
{
    ESP_ERROR_CHECK(esp_netif_init());                // 用于初始化tcpip协议栈
    ESP_ERROR_CHECK(esp_event_loop_create_default()); // 创建一个默认系统事件调度循环,之后可以注册回调函数来处理系统的一些事件
    esp_netif_create_default_wifi_sta();              // 使用默认配置创建STA对象
    // 初始化WIFI
    wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
    ESP_ERROR_CHECK(esp_wifi_init(&cfg));

    // 注册事件
    ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &event_handler, NULL));
    ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &event_handler, NULL));

    wifi_state_cb = f;
    // 启动WIFI
    ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); // 设置工作模式为STA
    ESP_ERROR_CHECK(esp_wifi_start());                 // 启动WIFI

    ESP_LOGI(TAG, "wifi_init finished.");
}

②编写wifi连接函数

1.设置wifi结构体的加密模式为WPA2

2.使用snprintf函数进行格式化配置ssid以及password

3.获取一下当前wifi的工作模式,如果工作在STA模式则直接配置结构体后启动,否则先配置为STA模式再进行启动

// wifi连接函数
esp_err_t wifi_manager_connect(const char *ssid, const char *password)
{
    wifi_config_t wifi_config =
        {
            .sta =
                {
                    .threshold.authmode = WIFI_AUTH_WPA2_PSK, // 加密方式使用WPA2
                },
        };
    snprintf((char *)wifi_config.sta.ssid, 31, "%s", ssid); //格式化赋值的方法进行ssid赋值
    snprintf((char *)wifi_config.sta.password, 63, "%s", password);
    ESP_ERROR_CHECK(esp_wifi_disconnect());
    wifi_mode_t mode;
    esp_wifi_get_mode(&mode); //获取wifi模式
    if (mode != WIFI_MODE_STA)  //当前并不工作在STA模式
    {
        ESP_ERROR_CHECK(esp_wifi_stop()); //停止WIFI的工作
        ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));  //再设置WIFI的工作模式
        ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); //再设置WIFI的配置
        esp_wifi_start();  //最后启动WIFI
    }
    else
    {
        ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
        esp_wifi_connect();
    }
    sta_connect_cnt = 0;
    return ESP_OK;
}

③编写WIFI事件回调函数(函数用来处理不同wifi事件)

1.首先判断事件类型为WIFI事件还是IP事件

2.若为WIFI事件则进一步判断是具体的什么WIFI事件

若为STA开启事件则进行wifi连接

若为未连接状态则进行未连接回调函数的处理(在连接状态转为未连接时)

若为连接状态则只需要输出连接成功

3.若为真正地与路由器连接上并得到分配的IP的状态时,则进行真正配网的回调函数,并将连接状态标志位设置为true、

//事件回调函数
static void event_handler(void* arg, esp_event_base_t event_base,int32_t event_id, void* event_data)
{
    if(event_base == WIFI_EVENT) //判断事件类型
    {
        switch(event_id)
        {
            case WIFI_EVENT_STA_START:      //STA启动成功之后就可以进行WIFI连接
                esp_wifi_connect();
                break;
            case WIFI_EVENT_STA_DISCONNECTED:
                if(is_sta_connected)
                {
                    is_sta_connected = false;
                    if (wifi_state_cb)
                    {
                        wifi_state_cb(WIFI_STATE_DISCONNECTED);
                    }
                }
                if(sta_connect_cnt<MAX_CONNECT_RETRY)
                {
                    esp_wifi_connect();
                    sta_connect_cnt++;
                }
                break;
            case WIFI_EVENT_STA_CONNECTED:
                ESP_LOGI(TAG, "Connected to AP");
                break;
            default:break;
        }
    }
    else if (event_base == IP_EVENT)
    {
        if(event_id == IP_EVENT_STA_GOT_IP) //真正地与路由器连接上并得到分配的IP了
        {
            ESP_LOGI(TAG,"Connected to ap");
            is_sta_connected = true;
            if (wifi_state_cb)
            {
                wifi_state_cb(WIFI_STATE_CONNECTED);
            }
        }
    }

}

④main函数中完成相关的状态对应回调函数,进行nvs初始化,wifi初始化,连接即可

六、AP配网

常见的配网方式

进行AP配网

①先创建一个扫描任务的信号量,用于避免bug导致多个任务同时进行扫描

1.注意先包含信号量的头文件,随后在wifi初始化函数中动态分配二进制信号量内存,释放信号量以进行初始化

#include "freertos/semphr.h"
static SemaphoreHandle_t scan_sem = NULL;

scan_sem = xSemaphoreCreateBinary(); //创建一个二进制信号量
xSemaphoreGive(scan_sem); //初始化状态

②编写STA+AP模式的初始化代码

1.如果已经是APSTA模式则直接返回OK

2.否则先断开WIFI的连接,停止WIFI的工作

3.设置工作模式为APSTA

4.进行通信信道,最大连接数,加密模式,id号,id号长度,密码的赋值,之后进行配置

5.AP模式下ESP32相当于一个网关,需要设置相应的IP地址,网关,子网掩码

使用IP4_ADDR函数进行赋值,使用此函数时要注意包含头文件“lwip/ip4_addr”

在进行结构体配置前要停用dhcp,其是一个自动分配ip的功能

随后进行结构体配置,之后再重新开启dhcp,再重新开启wifi即可

//进入的实际上是STA+AP模式,因为需要扫描存在的SSID,需要STA
esp_err_t wifi_manager_ap(void)
{
    //获取当前的模式
    wifi_mode_t mode;
    esp_wifi_get_mode(&mode);
    if (mode == WIFI_MODE_APSTA)
    {
        return ESP_OK;
    }
    esp_wifi_disconnect();  //断开当前的连接
    esp_wifi_stop();        //停止整个wifi工作
    esp_wifi_set_mode(WIFI_MODE_APSTA); // 设置工作模式
    wifi_config_t wifi_config = 
    {
        .ap = 
        {
            .channel = 5, //通信信道
            .max_connection = 2,    //最大连接数
            .authmode = WIFI_AUTH_WPA2_PSK,

        }
    };
    snprintf((char*)wifi_config.ap.ssid,32,"%s",ap_ssid_name);
    wifi_config.ap.ssid_len = strlen(ap_ssid_name);
    snprintf((char*)wifi_config.ap.password,64,"%s",ap_password_name);
    esp_wifi_set_config(WIFI_IF_AP,&wifi_config);

    //AP模式下ESP32相当于一个网关,需要设置相应的IP地址
    esp_netif_ip_info_t ipInfo;
    IP4_ADDR(&ipInfo.ip,192,168,100,1);     //设置ip
    IP4_ADDR(&ipInfo.gw, 192, 168, 100, 1); //设置网关
    IP4_ADDR(&ipInfo.netmask, 255, 255, 255, 0); // 设置子网掩码

    esp_netif_dhcps_stop(esp_netif_ap); // 停用dhcp(dhcp是一个自动分配ip的服务)
    esp_netif_set_ip_info(esp_netif_ap, &ipInfo);
    esp_netif_dhcps_start(esp_netif_ap);
    return esp_wifi_start();
}

③编写扫描函数,实际其内部即在接收到二进制信号量后进行列表清0后创建扫描任务即可

esp_err_t wifi_manager_scan(p_wifi_scan_callback f)
{
    if(pdTRUE == xSemaphoreTake(scan_sem,0))
    {
        esp_wifi_clear_ap_list();                                              // 清除之前的扫描信息
        return xTaskCreatePinnedToCore(scan_task, "scan", 8192, f, 3, NULL, 1); // 创建一个任务
    }
    return ESP_OK;
}

④编写按键扫描任务函数

1.先将创建任务得到的参数接收转化为callback函数

2.创建ap扫描数量,最大数量,扫描列表等

3.开始默认,阻塞式扫描

4.获取扫描到的AP数量

5.获取相应的网络列表

6.获取到之后传入回调函数进行处理

7.最后释放列表的内存空间,释放信号量,删除自身

static void scan_task(void* param)
{
    p_wifi_scan_callback callback = (p_wifi_scan_callback)param;
    uint16_t ap_count = 0;
    uint16_t ap_num = 20;
    wifi_ap_record_t *ap_list = (wifi_ap_record_t *)malloc(sizeof(wifi_ap_record_t) * ap_num);
    esp_wifi_scan_start(NULL,true);
    esp_wifi_scan_get_ap_num(&ap_count);    //获取扫描的AP数量
    esp_wifi_scan_get_ap_records(&ap_num,ap_list);  //最大的获取数量不会超过ap_num,如果小于的话则会返回实际数量,不要填充超过20个
    ESP_LOGI(TAG,"Total ap count:%d,actual ap number:%d",ap_count,ap_num);
    if(callback)
        callback(ap_num,ap_list);
    free(ap_list);
    xSemaphoreGive(scan_sem);
    vTaskDelete(NULL); //删除自身
}

⑥使用网页进行AP配网

⑦websocket协议简介

⑧AP配网流程

⑨建立HTTP/WEBSOCKET服务器

⑩定义一个服务器结构体,其中包含html网页字符串以及接收回调函数

typedef void(*ws_receive_cb)(uint8_t* payload,int len);

typedef struct
{
    const char* html_code; //html网页
    ws_receive_cb receive_fn;
}ws_cfg_t;

①编写启动服务器的函数

1.先将网页字符串,回调函数保存到全局变量

2.将http的congfig结构体赋默认值

3.进行http初始化启动服务器,要传入相应的http操作句柄

4.启动了服务器后要注册URI处理器,当http要请求路径为“/”时,使用get_http_req函数进行处理

5.当http要请求的路径为“/ws”时则使用handle_ws_req来处理,要注意如果为处理websocket的服URI,则需要在其结构体定义时将is_websocket赋值为true

// 启动服务器
esp_err_t web_ws_start(ws_cfg_t *cfg)
{
    if (cfg == NULL)
    {
        return ESP_FAIL;
    }
    http_html = cfg->html_code;
    ws_receive_fn = cfg->receive_fn;
    httpd_config_t config = HTTPD_DEFAULT_CONFIG();
    httpd_start(&server_handle, &config); // 进行http初始化
    httpd_uri_t uri_get =
        {
            .uri = "/",         // uri路径,根目录
            .method = HTTP_GET, // 响应HTTP 会将这个网页发送给客户端
            .handler = get_http_req,
        };
    httpd_register_uri_handler(server_handle, &uri_get);
    httpd_uri_t uri_ws =
        {
            .uri = "/ws",       // uri路径,根目录
            .method = HTTP_GET, // 响应HTTP 会将这个网页发送给客户端
            .handler = handle_ws_req,
            .is_websocket = true, // 表示此uri专门处理websocket数据

        };
    httpd_register_uri_handler(server_handle, &uri_ws);
    return ESP_OK;
}

②编写停止服务器的函数

1.先判断服务器句柄是否合法,合法的话就使用停止函数停止,随后将句柄设置为无效即可

// 停止服务器
esp_err_t web_ws_stop(void)
{
    if (server_handle) // 判断服务器句柄是否合法
    {
        httpd_stop(server_handle);
        server_handle = NULL; // 句柄设置为无效
    }
    return ESP_OK;
}

③由于websocket为双向通信,则esp32作为websocket服务器也可以向客户端主动发送数据

编写发送数据的函数

1.先定义一个帧结构体,传入数据,长度,数据类型,即可使用发送函数发送数据,要注意第二个参数为客户端socket,这个信息在第一次握手处理时已经被保存下来了

// websocket发送数据
esp_err_t web_ws_send(uint8_t *data, int len)
{
    httpd_ws_frame_t pkt;
    memset(&pkt, 0, sizeof(pkt)); // 清空结构体
    pkt.payload = data;
    pkt.len = len;
    pkt.type = HTTPD_WS_TYPE_TEXT;
    return httpd_ws_send_data(server_handle, client_fds, &pkt);
}

④编写“/”uri请求的回调处理函数

直接调用函数,第一个参数为uri信息,第二个为html字符串,第三个参数是自动计算长度的宏

// http相应请求回调函数
esp_err_t get_http_req(httpd_req_t *r)
{
    return httpd_resp_send(r, http_html, HTTPD_RESP_USE_STRLEN); // 第一个参数为请求信息,第二个参数为网页,第三个参数可以直接计算http_html字符串长度
}

⑤编写处理“/ws”websocket数据的回调函数

1.此请求的第一次为握手请求,需要过滤掉,正好也可以在此请求获取客户端的socket

2.定义一个帧结构体,用此结构体来接收客户端发送的数据

3.由于接收数据的数组需要分配大小,我们起初并不知道发送过来的数据数量,需要先知道数据长度,则使用函数,最后一个参数传入0,既可以将得到数据长度,此函数不会接受数据,但是会接受数据长度,长度的信息存储在帧的len成员中,随后使用malloc进行内存分配

4.可以判断一下内存是否分配成功,避免因内存不足引起错误

5.将分配到的内存交给payload成员,随后再次使用函数接收数据

6.接收成功后将数据交给数据处理回调函数处理

7.最后释放分配的内存即可

// 专门处理websocket数据的回调函数(websocket的通信都是基于帧的),还得过滤GET请求,第一次握手发送过来的数据是不包含信息的,通过带过来的r参数来判断
esp_err_t handle_ws_req(httpd_req_t *r)
{
    if (r->method == HTTP_GET)
    {
        client_fds = httpd_req_to_sockfd(r); // 握手阶段必然触发GET,在这里直接获取握手的客户端socket
        return ESP_OK;
    }
    // 定义一个帧结构体
    httpd_ws_frame_t pkt;
    esp_err_t ret;
    memset(&pkt, 0, sizeof(pkt));
    ret = httpd_ws_recv_frame(r, &pkt, 0); // 使用0参数时其只会传入整个数据的长度,不会传入数据
    if (ret != ESP_OK)
    {
        return ret;
    }
    uint8_t *buf = (uint8_t *)malloc(pkt.len + 1); // 根据数据长度分配相应的内存,+1存储末尾的/0
    if (buf == NULL)                               // 判断一下内存的有效性
    {
        return ESP_FAIL;
    }
    pkt.payload = buf;
    ret = httpd_ws_recv_frame(r, &pkt, pkt.len); // 会从底层的缓冲区接收一个帧数据
    if (ret == ESP_OK)
    {
        if (pkt.type == HTTPD_WS_TYPE_TEXT) // AP配网过程都是需要JSON字符串,故先判断类型符不符合
        {
            ESP_LOGI(TAG, "Get websocket message:%s", pkt.payload);
            if (ws_receive_fn)
                ws_receive_fn(pkt.payload, pkt.len);
        }
    }
    free(buf);
    return ESP_OK;
}

①整合AP配网的流程,实现相关的初始化函数

1.先进行wifi配合初始化

2.进行网页加载

3.创建事件标志组

4.创建任务函数

void ap_wifi_init(p_wifi_state_callback f)
{
    wifi_manager_init(f);
    html_code = init_web_page_buffer();
    apcfg_ev = xEventGroupCreate();
    xTaskCreatePinnedToCore(ap_wifi_task, "apcfg", 4096, NULL, 3, NULL, 1);
}

②将html网页挂载到spiffs分布区(可以实现前后端分离,使得html网页有独立的文件存放分区)

1.先包含头文件"esp_spiffs.h"

2.将相应的分区表.csv文件复制到文件夹中

3.在menuconfig中进行分区表更改,改为使用partition_table

4.定义相关挂载点

#define SPIFF_MOUNT "/spiffs" // 挂载点
#define HTML_PATH "/spiffs/apcfg.html"

5.使用函数将 SPIFFS 分区挂载到 ESP32 的文件系统目录中,使得可以使用标准 C 库文件操作函数来访问 SPIFFS 中的文件

6.使用stat函数来获取挂载点文件的信息(使用前要注意包含头文件<sys/stat.h>)

7.根据获取到的文件长度来分配相应的内存,并清空

8.使用fopen函数打开文件

9.如果文件能够成功打开则读取其中的值,否则就清空内存,避免内存泄漏

10.返回打开的字符数据,以供之后的初始化网页操作

static char *init_web_page_buffer(void)
{
    esp_vfs_spiffs_conf_t conf =
        {
            .base_path = SPIFF_MOUNT,        // 挂载点
            .format_if_mount_failed = false, // 挂载失败是否执行格式化
            .max_files = 3,                  // 最大打开文件格式
            .partition_label = NULL,         // 填NULL会自动去搜索分区表的分区
        };
    esp_vfs_spiffs_register(&conf);
    struct stat st;
    if (stat(HTML_PATH, &st))
    {
        return NULL;
    }
    char *buf = (char *)malloc(st.st_size + 1);
    memset(buf, 0, st.st_size + 1);
    FILE *fp = fopen(HTML_PATH, "r");
    if (fp)
    {
        if (0 == fread(buf, st.st_size, 1, fp))
        {
            free(buf);
            buf = NULL; // 指为空,避免内存泄漏
        }
        fclose(fp); // 关闭文件
    }
    else
    {
        free(buf);
        buf = NULL;
    }
    return buf;
}

③当esp32作为服务器,接收到了客户端发送过来的请求之后会调用接收回调函数,这个函数定义帧来接受这个数据,而后我们要处理这个数据,从项目角度知道此json实际上就是客户端发送扫描请求,或者客户端发送要连接的wifi的数据以及密码,处理数据的函数操作如下

1.此函数接收数据数组以及数组长度

2.其先创建一个root的JSON对象,其复制接收到的数据

3.从root对象中提取出键位scan、ssid、pasword的三个数据

4.如果提取得到scan数据,则将其与“start”进行比对,比对成功说明客户端提出扫描请求,esp32执行请求任务

5.如果提取到了ssid和password数据,则将id和密码进行存储,设置登录的事件标志位,交给我wifi登录的任务进行处理

// 接收数据回调函数(处理接收到的数据)
static void ws_receive_handle(uint8_t *payload, int len)
{
    cJSON *root = cJSON_Parse((char *)payload);
    if (root)
    {
        cJSON *scan_js = cJSON_GetObjectItem(root, "scan");
        cJSON *ssid_js = cJSON_GetObjectItem(root, "ssid");
        cJSON *password_js = cJSON_GetObjectItem(root, "password");
        if (scan_js)
        {
            char *scan_value = cJSON_GetStringValue(scan_js); // scan字符串
            if (strcmp(scan_value, "start") == 0)
            {
                wifi_manager_scan(wifi_scan_handle); // 启动扫描
            }
        }
        if (ssid_js && password_js)
        {
            char *ssid_value = cJSON_GetStringValue(ssid_js);
            char *password_value = cJSON_GetStringValue(password_js);
            snprintf(current_ssid, sizeof(current_ssid), "%s", ssid_value);
            snprintf(current_password, sizeof(current_password), "%s", password_value);
            // 使用wifi连接函数时会先断开STA+AP模式改为纯AP模式,在改变模式之前需要先断开服务器,但是由于回调函数底层也是在http中运行,所以
            // 不建议在这里直接关掉服务器
            // wifi_manager_connect(ssid_value,password_value);
            xEventGroupSetBits(apcfg_ev,APCFG_BIT); //设置事件位
        }
    }
}

④编写扫描处理函数,其已经从扫描任务中获取到了扫描到的数据个数和相应的数据,此函数的任务就是将这些扫描任务中获取到的数据发送给客户端显示

1.先新建一个JSON对象,向其中添加wifi_list数组,此数组用于存储接收到的每个wifi信息

2.循环扫描到的数据,建立一个JSON对象,代表数组中的一个成员,将id数据以字符串的形式传入这个对象,将信号强度以整数的形式传入对象,如果wifi的认证方式为开放模式,则将布尔0传入对象,否则将布尔1传入

3.最后将成员加入wifi_list数组中

4.将最初的json对象转化为字符串输出显示,并发送给客户端

5.最后释放字符串数据以及根JSON对象(释放根JSON对象就可以释放整个JSON对象)

//扫描处理函数
void wifi_scan_handle(int num, wifi_ap_record_t *ap_record)
{
    cJSON *root = cJSON_CreateObject(); // 新建一个cJSON对象
    cJSON *wifilist_js = cJSON_AddArrayToObject(root, "wifi_list");
    for (int i = 0; i < num; i++)
    {
        cJSON *wifi_js = cJSON_CreateObject(); // 代表数组中的一个成员
        cJSON_AddStringToObject(wifi_js, "ssid", (char *)ap_record[i].ssid);
        cJSON_AddNumberToObject(wifi_js, "rssi", ap_record[i].rssi); // 信号强度
        if (ap_record[i].authmode == WIFI_AUTH_OPEN)                 // 开放的加密方式
            cJSON_AddBoolToObject(wifi_js, "encrypted", 0);
        else
            cJSON_AddBoolToObject(wifi_js, "encrypted", 1);
        // 填充完成后把成员加到数据里
        cJSON_AddItemToArray(wifilist_js, wifi_js);
    }
    // 将cJSON对象转换成字符串
    char *data = cJSON_Print(root);
    ESP_LOGI(TAG, "WS send:%s", data);
    // 发送给客户端
    web_ws_send((uint8_t *)data, strlen(data));
    cJSON_free(data);
    cJSON_Delete(root);
}

⑤编写wifi连接的任务函数

1.当此任务接收到了事件组,事件组即是在服务器获取到客户端发送的wifi连接请求时触发的

2.此函数就先将服务器关闭,随后就使用wifi的id和密码连接wifi即可

3.配网完成,大功告成

// wifi连接的任务函数
static void ap_wifi_task()
{
    EventBits_t ev;
    while (1)
    {
        ev = xEventGroupWaitBits(apcfg_ev,APCFG_BIT,pdTRUE,pdFALSE,pdMS_TO_TICKS(10*1000));
        if(ev & APCFG_BIT)
        {
            web_ws_stop();
            wifi_manager_connect(current_ssid,current_password);
        }
    }
}

七、最后总结整个AP配网的流程

①当我按下按键时,ESP32就进入到了AP+STA的配网模式

②随后其会配置http请求的uri处理器,启动http和websocket服务器

③当我在浏览器中输入esp32的ip号时,esp32的http服务器就会与客户端进行握手,会返回相应的网页给我

④当我点击扫描wifi时,esp32服务器就会接收到帧数据,其将帧数据传给处理函数,处理函数看到是start数据,则进行扫描,扫描完esp32还会将扫描数据交给数据处理函数进行处理,处理完之后又重新发送给客户端,客户端则显示给用户界面

⑤用户在页面上可以选择相应的wifi的id号,并填写相应的密码,其再发送给服务器,服务器依旧接收得到的数据,并转交给数据处理函数,处理函数发现得到的是id和密码,则设置事件标志位,连接wifi的任务得到这个事件标志位之后就使用这个wifi和密码来获取连接wifi,最后关闭服务器,esp32完成配网,任务完成

八、OneNET的使用

编写代码通过MQTT协议接入ONENET平台

①OneNeTtoken计算算法较为复杂,直接包含相应的算法计算文件

②包含相应的头文件

#include "onenet_mqtt.h"
#include "mqtt_client.h"
#include "onenet_token.h"
#include "esp_log.h"
#include <stdio.h>
#include <stdint.h> //整型变量别名头文件
#include <string.h>

③编写启动MQTT的代码进行onenet平台的连接

1.配置MQTT客户结构体,先交内部内容清空

2.向结构体内部赋值,包括uri地址,端口号,产品名称,设备名称

3.创建静态变量token(由于mqtt的初始化函数是通过创建任务来进行初始化,如果不是静态变量,在我们自己编写的函数返回,去执行初始化函数创建的任务的话,token就会丢失),并通过token计算函数,填入存储地址,签名方式,有效日期时间戳,产品id,设备名称,秘钥来计算

4.将token传入结构体

5.进行MQTT初始化并获得操作句柄,注册操作句柄回调函数,回调函数是调试检测mqtt启动过程发生的状态,对应回调函数如下:

static void 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;
    switch ((esp_mqtt_event_id_t)event_id) {
    case MQTT_EVENT_CONNECTED:
        ESP_LOGI(TAG, "MQTT_EVENT_CONNECTED");
        break;
    case MQTT_EVENT_DISCONNECTED:
        ESP_LOGI(TAG, "MQTT_EVENT_DISCONNECTED");
        break;

    case MQTT_EVENT_SUBSCRIBED:
        ESP_LOGI(TAG, "sent publish successful");
        break;
    case MQTT_EVENT_UNSUBSCRIBED:
        ESP_LOGI(TAG, "MQTT_EVENT_UNSUBSCRIBED");
        break;
    case MQTT_EVENT_PUBLISHED:
        ESP_LOGI(TAG, "MQTT_EVENT_PUBLISHED");
        break;
    case MQTT_EVENT_DATA:
        ESP_LOGI(TAG, "MQTT_EVENT_DATA");
        break;
    case MQTT_EVENT_ERROR:
        ESP_LOGI(TAG, "MQTT_EVENT_ERROR");
        break;
    default:
        break;
    }
}

6.通过操作句柄启动MQTT

// 用来启动MQTT连接至onenet平台
esp_err_t onenet_start(void)
{
    esp_mqtt_client_config_t mqtt_config;
    memset(&mqtt_config, 0, sizeof(esp_mqtt_client_config_t));
    mqtt_config.broker.address.uri = "mqtt://mqtts.heclouds.com"; // uri地址  主动添加前缀"mqtt://"
    mqtt_config.broker.address.port = 1883;                       // 端口号
    mqtt_config.credentials.client_id = ONENET_DEVICE_NAME;
    mqtt_config.credentials.username = ONENET_PRODUCT_ID; // 平台分配的产品ID
    static char token[256];     //mqtt的连接并不是在函数里完成之后再返回,而是创建一个任务,如果token不是静态变量的话,这个函数一返回,token就无效了
    
    dev_token_generate(token,SIG_METHOD_SHA256,2063984711,ONENET_PRODUCT_ID,ONENET_DEVICE_NAME,ONENET_PRODUCT_ACCESS_KEY);    //第三个参数为有效时间戳
    mqtt_config.credentials.authentication.password = token;
    mqtt_handle = esp_mqtt_client_init(&mqtt_config); 
    esp_mqtt_client_register_event(mqtt_handle,ESP_EVENT_ANY_ID,mqtt_event_handler,NULL);
    return esp_mqtt_client_start(mqtt_handle);     //启动MQTT连接
}

④在主函数中先进行wifi的连接,当wifi连接成功之后就会置事件标志组,随后即可以接入oennet平台了

//连接到wifi后发出事件,随后在主函数中捕获这个事件,再连接onenet平台
static void wifi_state_callback(WIFI_STATE state)
{
    if(state == WIFI_STATE_CONNECTED)
    {
        xEventGroupSetBits(wifi_ev,WIFI_CONNECT_BIT);
    }
}

void app_main(void)
{
    nvs_flash_init();
    wifi_ev = xEventGroupCreate();
    wifi_manager_init(wifi_state_callback);
    wifi_manager_connect("whatcanisay","12345678");
    EventBits_t ev;
    while(1)
    {
        ev = xEventGroupWaitBits(wifi_ev,WIFI_CONNECT_BIT,pdTRUE,pdTRUE,pdMS_TO_TICKS(10*1000));
        if(ev & WIFI_CONNECT_BIT)
        {
            onenet_start();
        }
    }
}

九、物模型数据交互

①MQTT是基于订阅和发布的通信模型

②上一次我们已经实现了onenet平台的连接,这次我们实现物模型的交互,实现物联网

③编写物模型初始化,即初始化要控制的器件

1.由于我们要使用物模型来控制开发板上的三个ws2812以及led灯,所以先初始化ws2812,其有IO18口进行连接

2.随后进行led灯的初始化,先初始化其定时器,随后初始化pwm通道,

void onenet_dm_init(void)
{
    // 初始化ws2812
    ws2812_init(GPIO_NUM_18, 3, &ws2812_handle);
    // LED初始化定时器
    ledc_timer_config_t led_timer =
        {
            .clk_cfg = LEDC_AUTO_CLK,
            .duty_resolution = LEDC_TIMER_12_BIT, // 周期分辨率为12,即4095
            .freq_hz = 5000,
            .timer_num = LEDC_TIMER_0, // 定时器编号
        };
    ledc_timer_config(&led_timer);
    // PWM通道
    ledc_channel_config_t led_channel =
        {
            .channel = LEDC_CHANNEL_0,
            .duty = 0,
            .gpio_num = GPIO_NUM_15,
            .timer_sel = LEDC_TIMER_0,
        };
    ledc_channel_config(&led_channel);
    ledc_fade_func_install(0);  //启用渐变模式,会使PWM的过渡更加平滑
}

④编写订阅主题函数(MQTT协议需要我们订阅相应的主题才能够接收到ONENET平台发送过来的主题数据)

1.订阅上报属性回复主题,订阅此主题可以调试知道平台是否接收到我们上报的数据,使用函数完成订阅,函数的最后一个值为服务质量等级,1代表至少发送一次

2.订阅设置属性topic,订阅了这个即可以响应onenet平台下发的设置属性指令

// 订阅主题处理函数
static void onenet_subscribe(void)
{
    char topic[128];
    // 订阅上报属性回复主题(给onenet平台上报属性的话他会给我们回复),下面的toptic即为数据上报的响应topic
    snprintf(topic, 128, "$sys/%s/%s/thing/property/post/reply", ONENET_PRODUCT_ID, ONENET_DEVICE_NAME);
    esp_mqtt_client_subscribe_single(mqtt_handle, topic, 1); // 订阅单个主题 ,句柄,要订阅的主题,最小Qos等级要求
    // 订阅设置主题,onenet平台要给我们设置属性需要这个主题
    snprintf(topic, 128, "$sys/%s/%s/thing/property/set", ONENET_PRODUCT_ID, ONENET_DEVICE_NAME);
    esp_mqtt_client_subscribe_single(mqtt_handle, topic, 1);
}

⑤编写生成上报数据的函数,其功能是将设备此时的属性进行打包,打包并返回json格式的设备属性,使用cjson库中的操作即可

// 用来生成上报数据,用来把设备的数据上报给onenet
cJSON *onenet_property_upload()
{
    /*
        {
            "id": "123",
            "version": "1.0",
            "params": {
                "Brightness":{
                    "value":50
                },
                "LightSwitch":{
                    "value":true
                },
                "RGBColor":{
                    "value":{
                        "Red":100,
                        "Green":100,
                        "Blue":100,
                    }
                }
            }
        }
    */
    cJSON *root = cJSON_CreateObject();
    cJSON_AddStringToObject(root, "id", "123"); // 根节点,键名,值
    cJSON_AddStringToObject(root, "version", "1.0");
    cJSON *param_js = cJSON_AddObjectToObject(root, "params");
    // 亮度
    cJSON *brightness_js = cJSON_AddObjectToObject(param_js, "Brightness");
    cJSON_AddNumberToObject(brightness_js, "value", led_brightness);
    // 开关
    cJSON *lightSwitch_js = cJSON_AddObjectToObject(param_js, "LightSwitch");
    cJSON_AddBoolToObject(lightSwitch_js, "value", led_status);
    // rgb值
    cJSON *rgbColor_js = cJSON_AddObjectToObject(param_js, "RGBColor");
    cJSON *rgbColor_value_js = cJSON_AddObjectToObject(rgbColor_js, "value");
    cJSON_AddNumberToObject(rgbColor_value_js, "Red", ws2812_red);
    cJSON_AddNumberToObject(rgbColor_value_js, "Green", ws2812_green);
    cJSON_AddNumberToObject(rgbColor_value_js, "Blue", ws2812_blue);
    return root;
}

⑥编写处理下行JSON数据的函数,将接收到的数据进行处理并控制相应的器件

1.将JSON对象中的参数成员提取出来

2.使用->child,将对象包含的第一个成员提取出来(使用child而不是next使用child为对象的更深一层,而next为同一层的下一个对象)

3.随后就开始遍历所有成员,要注意键值对对应的值要存储起来,使得设备的属性可以上报

4.提取出键的值,如果为Brightness,即其为亮度设置的指令,则将亮度值提取出来,转化为对应的占空比,再将占空比设置给对应的led灯

5.如果提取的键是开关控制的,则判断其是开还是关灯,若开灯则设置led灯的占空比为50,否则设置占空比为0,即代表关灯

6.如果提取出的键是设置RGB灯的三色的话,则提取R,G,B三个对象的值,最后进行ws2812操作即可

// 用来处理下行数据
void onenet_property_handle(cJSON* property)
{
    /*
        {
            "id": "123",
            "version": "1.0",
            "params": {
                "Brightness":50,
                "LightSwitch":true,
                "RGBColor":{
                    "Red":100,
                    "Green":100,
                    "Blue":100,
                }
            }
        }
    */
    cJSON *param_js = cJSON_GetObjectItem(property, "params");
    if (param_js)
    {
        cJSON *name_js = param_js->child; // child就是param指向的第一个成员
        while (name_js)
        {
            if (strcmp(name_js->string, "Brightness") == 0) // 使用->string取得键名
            {
                led_brightness = cJSON_GetNumberValue(name_js);
                int duty = led_brightness * 4095 / 100;
                ledc_set_duty_and_update(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, duty, 0);
            }
            else if (strcmp(name_js->string, "LightSwitch") == 0)
            {
                if (cJSON_IsTrue(name_js))
                {
                    led_status = 1;
                    led_brightness = 50;
                    int duty = 50 * 4095 / 100;
                    ledc_set_duty_and_update(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, duty, 0);
                }
                else
                {
                    led_status = 0;
                    led_brightness = 0;
                    ledc_set_duty_and_update(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, 0, 0);
                }
            }
            else if (strcmp(name_js->string, "RGBColor") == 0)
            {
                ws2812_red = cJSON_GetNumberValue(cJSON_GetObjectItem(name_js, "Red"));
                ws2812_green = cJSON_GetNumberValue(cJSON_GetObjectItem(name_js, "Green"));
                ws2812_blue = cJSON_GetNumberValue(cJSON_GetObjectItem(name_js, "Blue"));
                for (int i = 0; i < 3; i++)
                {
                    ws2812_write(ws2812_handle, i, ws2812_red, ws2812_green, ws2812_blue);
                }
            }
            name_js = name_js->next;
        }
    }
}

⑦编写应答函数,此函数在接收到onenet平台下发的数据之后进行应答

1.应答的topic格式为

$sys/{pid}/{device-name}/thing/property/set_reply

2.使用snprintf函数构造topic

3.创建json对象,将id,code和msg加入json中,随后为了节省字节数,使用函数去除换行和空格,此函数在内部会用malloc函数在在堆中会分配空间,在之后必须手动释放内存,避免内存泄漏

4.使用mqtt客户端推送函数将对应的topic以及内容发送给服务器

5.最后释放data内存和json对象内存即可

static void onenet_property_ack(const char *id, int code, const char *msg) // id,错误码,消息
{
    char topic[128];
    snprintf(topic, 128, "$sys/%s/%s/thing/property/set_reply", ONENET_PRODUCT_ID, ONENET_DEVICE_NAME);
    cJSON *reply_js = cJSON_CreateObject();
    cJSON_AddStringToObject(reply_js, "id", id);
    cJSON_AddNumberToObject(reply_js, "code", code);
    cJSON_AddStringToObject(reply_js, "msg", msg);
    char *data = cJSON_PrintUnformatted(reply_js); // 节省字节数,输出无需换行和空格
    esp_mqtt_client_publish(mqtt_handle, topic, data, strlen(data), 1, 0);
    cJSON_free(data);
    cJSON_Delete(reply_js);
}

⑧在前面我们已经有了将数据打包成json数据的函数,随后我们要将数据上传到云端,所以我们还要编写一个上传数据的函数

1.设备属性上报的topic为

$sys/{pid}/{device-name}/thing/property/post

2.构建相应的topic,然后将数据上传到云端即可

esp_err_t onenet_post_property_data(const char* data)
{
    char topic[128];
    snprintf(topic, 128, "$sys/%s/%s/thing/property/post", ONENET_PRODUCT_ID, ONENET_DEVICE_NAME);
    ESP_LOGI(TAG,"Upload topic:%s,payload:%s",topic,data);
    return esp_mqtt_client_publish(mqtt_handle,topic,data,strlen(data),1,0);
}

⑨在matt的事件处理函数中我们需要在连接成功时进行设备属性的上报等,具体在下方进行分析

1.在连接成功事件发生之后,我们就讲属性进行打包,打包后进行上报即可,要注意释放内存

2.在onenet平台下行数据后则会进入到数据事件,数据事件则先比对topic中是否有出现set字眼,有则表示是设置属性的,则先将对应的数据存储,随后进行属性设置,进行回应,最后释放内存即可

static void 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;
    switch ((esp_mqtt_event_id_t)event_id)
    {
    case MQTT_EVENT_CONNECTED:
        ESP_LOGI(TAG, "MQTT_EVENT_CONNECTED");
        onenet_subscribe(); // 连接成功后就订阅我们需要的主题
        cJSON* property_js = onenet_property_upload();   //上报所有数据,进行数据同步
        char* data =  cJSON_PrintUnformatted(property_js);
        onenet_post_property_data(data);
        cJSON_free(data);
        cJSON_Delete(property_js);
        break;
    case MQTT_EVENT_DISCONNECTED:
        ESP_LOGI(TAG, "MQTT_EVENT_DISCONNECTED");
        break;

    case MQTT_EVENT_SUBSCRIBED:
        ESP_LOGI(TAG, "sent publish successful");
        break;
    case MQTT_EVENT_UNSUBSCRIBED:
        ESP_LOGI(TAG, "MQTT_EVENT_UNSUBSCRIBED");
        break;
    case MQTT_EVENT_PUBLISHED:
        ESP_LOGI(TAG, "MQTT_EVENT_PUBLISHED");
        break;
    case MQTT_EVENT_DATA: // onenet平台下行任何数据都会到这个事件
        ESP_LOGI(TAG, "MQTT_EVENT_DATA");
        printf("TOPIC=%.*s\r\n", event->topic_len, event->topic);
        printf("DATA=%.*s\r\n", event->data_len, event->data);
        if (strstr(event->topic, "property/set")) // 寻找字符串函数,没有返回0就说明找到了这个字符串
        {
            cJSON *property = cJSON_Parse(event->data);
            cJSON* id_js = cJSON_GetObjectItem(property, "id");
            onenet_property_handle(property);
            onenet_property_ack(cJSON_GetStringValue(id_js), 200, "success"); // 200为成功码
            cJSON_Delete(property);
        }
        break;
    case MQTT_EVENT_ERROR:
        ESP_LOGI(TAG, "MQTT_EVENT_ERROR");
        break;
    default:
        break;
    }
}

十、OTA功能

①想要有OTA功能,则必须要有bootloader程序,用于加载应用程序,如若没有则会从固定的地址去运行

①ota_state:代表的是ota_seq指向的那个分区的状态

②使用OTA功能至少要有两个OTA分区,在进行空中升级时,他们就会进行交替升级

①esp_image_header_t:为APP镜像头结构体

②app分区会分成许多个段,而esp_image_segment_header_t为每个段的头结构体

③使用以下的代码就可以获得段内容

esptool.py --chip esp32s3 image_info build/onenet.bin

得到的段地址如下:

Segment 1: len 0x2a124 load 0x3c0b0020 file_offs 0x00000018 [DROM]
Segment 2: len 0x04694 load 0x3fc99600 file_offs 0x0002a144 [BYTE_ACCESSIBLE,MEM_INTERNAL,DRAM]
Segment 3: len 0x01830 load 0x40374000 file_offs 0x0002e7e0 [MEM_INTERNAL,IRAM]
Segment 4: len 0xa5750 load 0x42000020 file_offs 0x00030018 [IROM]
Segment 5: len 0x13cec load 0x40375830 file_offs 0x000d5770 [MEM_INTERNAL,IRAM]

1.第一段:DROM存储的为常量数据,比如const修饰的全局变量等

2.第二段:DRAN(数据RAN),记录可用的堆信息

3.第三段以及第五段:IRAM,上电之后bootloader会把这部分拷贝到SRAM中去运行

4.第四段:IROM,存放在FLASH中的代码

④esp32s3的内存地址映射

设备完整的OTA过程如下:

①回滚的意思:但更新程序,程序自检时发现有问题,则会回滚到上一个APP分区去,新的APP程序则标记为非法

ONENET平台,OTA升级流程图:

①设备上电后上报版本号,控制台则添加升级任务

②控制台通知设备,提示要进行OTA升级,设备就去查询升级任务的信息

③随后的操作即是流程图的⑤⑥⑦

!此处要注意设备与平台进行这些任务使用的不是MQTT协议,而是HTTP协议,此时ESP32要作为一个客户端设备,向Onenet平台发起http连接!

相关的操作如下面的代码:

POST http://iot-api.heclouds.com/fuse-ota/{pro_id}/{dev_name}/version


Content-Type: application/json


Authorization:version=2022-05-01&res=userid%2F112&et=1662515432&method=sha1&sign=Pd14JLeTo77e0FOpKN8bR1INPLA%3D


host:iot-api.heclouds.com

Content-Length:xx


{"s_version":"V1.3", "f_version": "V2.0"}

十一、OTA远程升级代码编写

①通过MQTT协议连接到ONENET平台后,设备会将其版本号先进行上传,版本号上传函数的实现如下

1.首先其会先获取到自身的版本号,之前我们已经知道版本号存放在esp_app_desc这个结构体中

故我们先编写获取版本号的函数,实现过程如下:

        (1)先创建一个静态的变量,用来存储版本号字符串

        (2)随后使用 esp_ota_get_running_partition()这个函数获取到当前运行的分区信息

        (3)使用函数 esp_ota_get_partition_description(running,&app_desc);从运行分区中提取出app_desc结构体

        (4)由于不能直接返回app_desc结构体的成员,因为其为临时变量,所以我们将其值通过snprintf函数传输给app_version再返回即可

        

//获取当前app的版本号
const char* get_app_version(void)
{
    static char app_version[32] = {0};
    if(app_version[0] == 0)
    {
        //获得版本号就是获取esp_app_desc这个结构体
        const esp_partition_t * running = esp_ota_get_running_partition();    //这个函数会返回当前运行的分区信息
        esp_app_desc_t app_desc;
        esp_ota_get_partition_description(running,&app_desc);    //根据分区信息来获取结构体
        snprintf(app_version,sizeof(app_version),"%s",app_desc.version);
    }
    //不能直接返回app_desc.version,因为app_desc为临时变量,我们返回的实际上是指向这个堆内存地址的指针
    //当函数结束时此地址将会被销毁,后续我们想要去访问一个空地址将会出现错误
    return app_version;
}

2.向ONENET平台上报数据采用http协议,相关的URL地址等信息如下所示:

POST http://iot-api.heclouds.com/fuse-ota/{pro_id}/{dev_name}/version


Content-Type: application/json


Authorization:version=2022-05-01&res=userid%2F112&et=1662515432&method=sha1&sign=Pd14JLeTo77e0FOpKN8bR1INPLA%3D


host:iot-api.heclouds.com

Content-Length:xx


{"s_version":"V1.3", "f_version": "V2.0"}

据此构建URL地址和数据上报的JSON数据字符串

3.使用函数onenet_ota_http_connect(url,HTTP_METHOD_POST,version)进行http上报,此函数将esp32作为http协议的客户端进行数据上报,具体的实现过程在下方:

        (1)此函数的参数为url地址,数据上报方法,进行上报的json数据数组

        (2)先配置相应的结构体,传入url和http请求事件处理回调函数,回调函数的具体内容在步骤4

        (3)将配置结构体传入初始化函数

        (4)根据信息生成token

        (5)设置客户端的请求方法为POST,设置其主机号,内容类型,token信息,将对应的数据发送给服务器

        (6)在我们最后调用esp_http_client_perform(client)函数时,其就可以接收到服务器的信息了,所以我们先清空接受缓冲器的内容,将接收数据量记录清0

        (7)调用esp_http_client_perform(client)函数,这个函数会阻塞执行,自动完成整个 HTTP 事务的所有步骤:

                1. DNS 解析 - 将主机名解析为 IP 地址

                2.TCP 连接 - 建立到服务器的 TCP 连接

                3.SSL/TLS 握手(如果使用 HTTPS)

                4.发送 HTTP 请求 - 发送请求头和请求体

                5.接收 HTTP 响应 - 接收响应头和响应体

                6.连接关闭 - 完成事务后关闭连接

        (8)完成后释放token开辟的空间,将客户端的内容清空释放

static esp_err_t onenet_ota_http_connect(const char *url,esp_http_client_method_t method,const char* payload)
{
    esp_http_client_config_t config = {
        .url = url,
        .event_handler = _http_event_handler,   //事件处理函数,处理请求过程中的各种事件
    };
    esp_http_client_handle_t client = esp_http_client_init(&config);
    char *token = (char*)malloc(256);   //在堆上定义数组
    memset(token,0,256);
    dev_token_generate(token,SIG_METHOD_SHA256, TOKEN_VALID_TIMERSTAMP, ONENET_PRODUCT_ID, NULL, ONENET_PRODUCT_ACCESS_KEY);   //设备名称不填,用的是产品级的验证
    ESP_LOGI(TAG,"ota token:%s",token);
    // POST
    esp_http_client_set_method(client, method);
    esp_http_client_set_header(client, "Content-Type", "application/json");
    esp_http_client_set_header(client, "host", "iot-api.heclouds.com");
    esp_http_client_set_header(client, "Authorization", token);
    if(payload)
    {
        ESP_LOGI(TAG,"post data:%s",payload);
        esp_http_client_set_post_field(client, payload, strlen(payload));
    }
    memset(data_ota_buff,0,sizeof(data_ota_buff));       //发起http后即可收到数据了,先清空
    ota_data_size = 0;   //接收的长度也清0
    esp_err_t err = esp_http_client_perform(client);
    free(token);
    esp_http_client_cleanup(client);
    return err;
}

4.http请求事件处理回调函数,由于我们只需要处理接收到数据的时候,所以具体编写HTTP_EVENT_ON_DATA事件

1.为了避免我们的缓冲区存量不足,所以每次存入都要判断一下内存是否溢出,如果溢出则舍弃溢出部分存入

2.其他事件我们打印提示即可

esp_err_t _http_event_handler(esp_http_client_event_t *evt)
{
    switch(evt->event_id) {
        case HTTP_EVENT_ERROR:
            ESP_LOGD(TAG, "HTTP_EVENT_ERROR");
            break;
        case HTTP_EVENT_ON_CONNECTED:
            ESP_LOGD(TAG, "HTTP_EVENT_ON_CONNECTED");
            break;
        case HTTP_EVENT_HEADER_SENT:
            ESP_LOGD(TAG, "HTTP_EVENT_HEADER_SENT");
            break;
        case HTTP_EVENT_ON_HEADER:
            ESP_LOGD(TAG, "HTTP_EVENT_ON_HEADER, key=%s, value=%s", evt->header_key, evt->header_value);
            break;
        //接收到http给我们返回的数据,一次接收可能无法全部接收平台下发的所有数据,所以要有多次接收的考虑
        case HTTP_EVENT_ON_DATA:
            ESP_LOGD(TAG, "HTTP_EVENT_ON_DATA, len=%d", evt->data_len);
            printf("HTTP_EVENT_ON_DATA=%.*s\r\n",evt->data_len,(char*)evt->data);  //可以打印固定长度的字符串
            int copy_len = 0;   //具体的拷贝长度
            if(evt->data_len > OTA_BUFF_LEN - ota_data_size)
            {
                copy_len = OTA_BUFF_LEN - ota_data_size;
            }
            else
            {
                copy_len =  evt->data_len;
            }
            memcpy(&data_ota_buff[ota_data_size],evt->data,copy_len);
            ota_data_size += copy_len;
            break;
        case HTTP_EVENT_ON_FINISH:
            ESP_LOGD(TAG, "HTTP_EVENT_ON_FINISH");
            break;
        case HTTP_EVENT_DISCONNECTED:
            ESP_LOGI(TAG, "HTTP_EVENT_DISCONNECTED");
            break;
        case HTTP_EVENT_REDIRECT:
            ESP_LOGD(TAG, "HTTP_EVENT_REDIRECT");
            break;
        default:break;
    }
    return ESP_OK;
}

5.上报版本号后data_ota_buff就会接收到服务器返回的响应信息,信息格式如下:

{
	"code": 0,
	"msg": "succ",
	"request_id": "**********"
}

        (1)我们比较关心code码,当其为0时说明我们上报版本号成功

        (2)我们将data_ota_buff转化为JSON对象,提取出其code成员,在提取出对应的值,判断是否为0,随后根据判断的结果输出提示信息

//用于上报版本号
esp_err_t onenet_ota_upload_version(void)
{
    char url[128];
    char version[128];
    const char* app_version = get_app_version();
    esp_err_t ret = ESP_FAIL;
    snprintf(url,sizeof(url),ONENET_OTA_URL"/%s/%s/version",ONENET_PRODUCT_ID,ONENET_DEVICE_NAME);
    snprintf(version,sizeof(version),"{\"s_version\":\"%s\", \"f_version\": \"%s\"}",app_version,app_version);
    if(ESP_OK == onenet_ota_http_connect(url,HTTP_METHOD_POST,version))
    {
        cJSON *root = cJSON_Parse((const char*)data_ota_buff);   
        if(root)
        {
            cJSON* code_js = cJSON_GetObjectItem(root,"code");
            if(code_js && cJSON_GetNumberValue(code_js)==0)
            {
                ret = ESP_OK;
            }
            cJSON_Delete(root);
        }
    }
    if(ret != ESP_OK)
    {
        ESP_LOGI(TAG,"Upload version fail!");   //上报版本号失败
    }
    return ret;
}

②在上报了版本号后我们需要检测升级任务,此函数与上报版本号函数的实现较为类似,其为get请求,相应的请求信息如下

GET http://iot-api.heclouds.com/fuse-ota/{pro_id}/{dev_name}/check?type=1&version=1.2
Content-Type: application/json

Authorization:version=2022-05-01&res=userid%2F112&et=1662515432&method=sha1&sign=Pd14JLeTo77e0FOpKN8bR1INPLA%3D

host:iot-api.heclouds.com

Content-Length:20

1.url地址中的type指的是传输为完整包还是差分包

2.将传入的信息使用snprintf函数构建url地址

3.使用onenet_ota_http_connect函数进行http请求,方法为GET方法

4.随后ota_data_buff就会收到响应信息,格式如下:

{
	"code": 0,
	"msg": "succ",
	"request_id": "**********",
	"data": {
		"target": "1.2", // 升级任务的目标版本
		"tid": 12, //任务ID
		"size": 123, //文件大小
		"md5": "dfkdajkfd", //升级文件的md5
		"status": 1 | 2 | 3, //1 :待升级, 2 :下载中, 3 :升级中
		"type": 1 | 2 // 1:完整包,2:差分包  
	}
}

        (1)将code码提取出来,data成员提取出来,随后打印出响应信息中的版本号信息,并且把成功与否的信息也打印出来

esp_err_t onenet_ota_check_task(const char* type,const char* version)
{
    char url[128];
    esp_err_t ret = ESP_FAIL;
    snprintf(url,sizeof(url),ONENET_OTA_URL"/%s/%s/check?type=%s&version=%s",ONENET_PRODUCT_ID,ONENET_DEVICE_NAME,type,version);
    if(ESP_OK == onenet_ota_http_connect(url,HTTP_METHOD_GET,NULL))
    {
        cJSON *root = cJSON_Parse((const char*)data_ota_buff);   
        if(root)
        {
            cJSON* code_js = cJSON_GetObjectItem(root,"code");
            cJSON* data_js = cJSON_GetObjectItem(root,"data");
            cJSON* target_js = cJSON_GetObjectItem(data_js,"target");
            cJSON* tid_js = cJSON_GetObjectItem(data_js,"tid");
            if(code_js && cJSON_GetNumberValue(code_js)==0)
            {
                if(target_js && tid_js)
                {
                    snprintf(target_version,sizeof(target_version),"%s",cJSON_GetStringValue(target_js));
                    task_id = cJSON_GetNumberValue(tid_js);
                    ret = ESP_OK;
                }
            }
            else
            {
                ESP_LOGI(TAG,"Check ota task invalid code");
            }
            cJSON_Delete(root);
        }
    }
    return ret;
}

③上报任务的升级状态进度,其中tid参数为任务id,step参数为目前的百分比进度,上报升级状态的POST请求如下:

POST http://iot-api.heclouds.com/fuse-ota/{pro_id}/{dev_name}/{tid}/status

Content-Type: application/json

Authorization:version=2022-05-01&res=userid%2F112&et=1662515432&method=sha1&sign=Pd14JLeTo77e0FOpKN8bR1INPLA%3D 

host:iot-api.heclouds.com

Content-Length:20

{"step":10}

1.依旧使用snprintf函数进行url构建,以及内容的构建

2.返回的响应信息如下

{
	"code": 0,
	"msg": "succ",
	"request_id": "**********"
}

3.最后输出提示信息即可

4.具体代码如下:

//上报任务进度函数
esp_err_t onenet_ota_upload_status(int tid,int step)
{
    char url[128];
    char payload[16];
    esp_err_t ret = ESP_FAIL;
    snprintf(url,sizeof(url),ONENET_OTA_URL"/%s/%s/%d/status",ONENET_PRODUCT_ID,ONENET_DEVICE_NAME,tid);
    snprintf(payload,sizeof(payload),"{\"step\":%d}",step);
    if(ESP_OK == onenet_ota_http_connect(url,HTTP_METHOD_POST,payload))
    {
        cJSON *root = cJSON_Parse((const char*)data_ota_buff);   
        if(root)
        {
            cJSON* code_js = cJSON_GetObjectItem(root,"code");
            if(code_js && cJSON_GetNumberValue(code_js)==0)
            {
                ret = ESP_OK;
            }
            cJSON_Delete(root);
        }
    }
    if(ret != ESP_OK)
    {
        ESP_LOGI(TAG,"Upload task status fail!");   //上报版本号失败
    }
    return ret;
}

④升级包下载函数,ESP32已经知道下载升级包使用的都是http协议,所以其已经为我们准备好了相应的API函数,下载升级包的请求信息如下

GET 
http://iot-api.heclouds.com/fuse-ota/{pro_id}/{dev_name}/{tid}/download

Authorization:version=2022-05-01&res=userid%2F112&et=1662515432&method=sha1&sign=Pd14JLeTo77e0FOpKN8bR1INPLA%3D

host:iot-api.heclouds.com

1.构建相应的url地址,使用esp为我们封装的函数需要传入类型为esp_https_ota_config_t的结构体,结构体中还需要有http客户端结构体,我们依次进行赋值

2.其中结构体还有一个成员需要我们传入一个回调函数,当使用esp_https_ota函数发起http请求之前,会调用这个初始化回调函数,为了让我们去写请求头,此回调函数的实现如下

//下载升级包所需要的回调函数(所带参数为http客户端句柄)
esp_err_t onenet_ota_init_cb(esp_http_client_handle_t client)
{
    static char token[256];
    memset(token,0,256);
    dev_token_generate(token,SIG_METHOD_SHA256, TOKEN_VALID_TIMERSTAMP, ONENET_PRODUCT_ID, NULL, ONENET_PRODUCT_ACCESS_KEY);   //设备名称不填,用的是产品级的验证
    // POST
    esp_http_client_set_method(client,HTTP_METHOD_GET);
    esp_http_client_set_header(client, "Content-Type", "application/json");
    esp_http_client_set_header(client, "host", "iot-api.heclouds.com");
    esp_http_client_set_header(client, "Authorization", token);
    return ESP_OK;
}

3.随后我们只需要调用函数esp_https_ota(&ota_cfg_t);即可下载相应的数据包

4.最后根据函数返回值输出是否成功的提示信息即可

5.具体的代码如下:

//升级包下载(esp32知道下载升级包都是使用http协议的,所以已经为我们封装好了函数)
esp_err_t onenet_ota_download(int tid)
{
    char url[128];
    snprintf(url,sizeof(url),"http://iot-api.heclouds.com/fuse-ota/%s/%s/%d/download",ONENET_PRODUCT_ID,ONENET_DEVICE_NAME,tid);
    esp_http_client_config_t http_cfg = 
    {
        .url = url,
    };
    esp_https_ota_config_t ota_cfg_t = 
    {
        .http_config = &http_cfg,
        .http_client_init_cb = onenet_ota_init_cb,    //当esp_https_ota发起http请求之前,会调用这个初始化回调函数,为了让我们去写请求头
    };
    esp_err_t ota_ret = ESP_FAIL;
    ota_ret = esp_https_ota(&ota_cfg_t);
    if(ota_ret == ESP_OK)
    {
        ESP_LOGI(TAG,"Upgrade successful...");
    }
    else
    {
        ESP_LOGI(TAG,"Upgrade fail!,Code:%d",ota_ret);
    }
    return ota_ret;
}

⑤最后上报进度为100%,并进行重启,删除自身任务即可,如若在中途出现任何错误都将会直接退出,具体的任务流程函数如下:

static void onenet_ota_task(void* param)
{
    esp_err_t ret = ESP_FAIL;
    //上报版本号
    ret = onenet_ota_upload_version();
    if(ret != ESP_OK)
    {
        ESP_LOGE(TAG,"Upload version fail!");
        goto delete_ota_task;
    }
    //检测升级任务
    ret = onenet_ota_check_task("1",get_app_version());
    if(ret != ESP_OK)
    {
        ESP_LOGE(TAG,"Check task fail!");
        goto delete_ota_task;
    }
    //上报任务升级状态10%
    ret = onenet_ota_upload_status(task_id,10);
    if(ret != ESP_OK)
    {
        ESP_LOGE(TAG,"Upload status fail!");
        goto delete_ota_task;
    }
    //进行http下载
    ret = onenet_ota_download(task_id);
    if(ret != ESP_OK)
    {
        ESP_LOGE(TAG,"download fail!");
        goto delete_ota_task;
    }
    //上报进度100%
    ret = onenet_ota_upload_status(task_id,100);
    if(ret != ESP_OK)
    {
        ESP_LOGE(TAG,"Upload status fail!");
        goto delete_ota_task;
    }
    //重启
    esp_restart();
delete_ota_task:    //任何错误都要直接结束
    ota_is_running = false;
    vTaskDelete(NULL);
}

⑥启动OTA即创建ota任务,代码如下:

//启动OTA
void onenet_ota_start(void)
{
    if(ota_is_running)
        return ;
    ota_is_running = true;
    ESP_LOGI(TAG,"Start OTA");
    xTaskCreatePinnedToCore(onenet_ota_task,"onenet_ota",8192,NULL,2,NULL,1);
}

⑦我们还需要有一个标记程序是否合法的函数,我们认为能够成功连接上MQTT即合法,在其他项目中的自检方式也要根据实际项目需求去做

1.先获取到当前运行的任务分区

2.通过任务分区获取到当前的运行状态,通过前面的分区运行状态图我们能够知道,当分区运行状态为ESP_OTA_IMG_PENDING_VERIFY时为关键节点,当程序合法时,下次启动就从新的APP分区启动,当为不合法时则会进行回滚

3.当我们判断程序合法时,下一个分区即不需要回滚即可,否则需要回滚

4.具体实现代码如下:

//标记程序是否合法,传入0则表示不合法,否则合法
void set_app_vaild(int valid)
{
    const esp_partition_t * running = esp_ota_get_running_partition(); 
    esp_ota_img_states_t state;
    if(esp_ota_get_state_partition(running,&state) == ESP_OK)
    {
        if(state == ESP_OTA_IMG_PENDING_VERIFY)
        {
            if(valid)
            {
                esp_ota_mark_app_valid_cancel_rollback();   //标记程序为合法,取消回滚
            }
            else
            {
                esp_ota_mark_app_invalid_rollback_and_reboot(); //标记APP分区不合法,回滚并重启
            }
        }
    }
    
}

⑧想要收到OTA升级的信息,需要进行订阅,我们在订阅函数进行订阅,系统维护升级OTA的主题如下

	$sys/{pid}/{device-name}/ota/inform

1.在订阅函数中加入以下代码即可

    // 订阅ota升级通知主题
    snprintf(topic, 128, "$sys/%s/%s/ota/inform", ONENET_PRODUCT_ID, ONENET_DEVICE_NAME);
    esp_mqtt_client_subscribe_single(mqtt_handle, topic, 1);

2.在收到数据后还会进行应答,应答的格式如下

	$sys/{pid}/{device-name}/ota/inform_reply

具体的实现代码与之前设置属性应答的代码类似,实现如下:

static void onenet_ota_ack(const char *id, int code, const char *msg) // id,错误码,消息
{
    char topic[128];
    snprintf(topic, 128, "$sys/%s/%s/ota/inform_reply", ONENET_PRODUCT_ID, ONENET_DEVICE_NAME);
    cJSON *reply_js = cJSON_CreateObject();
    cJSON_AddStringToObject(reply_js, "id", id);
    cJSON_AddNumberToObject(reply_js, "code", code);
    cJSON_AddStringToObject(reply_js, "msg", msg);
    char *data = cJSON_PrintUnformatted(reply_js); // 节省字节数,输出无需换行和空格
    esp_mqtt_client_publish(mqtt_handle, topic, data, strlen(data), 1, 0);//最后一个参数是是否保留信息,用于下一次订阅是发送
    cJSON_free(data);
    cJSON_Delete(reply_js);
}

⑨在MQTT服务器收到了主题含有OTA的数据时我们就进行OTA相关的处理,将数据信息提取出来,并进行回应,随后就开启OTA升级流程即可

else if(strstr(event->topic, "ota/inform"))
        {
            cJSON *ota_js = cJSON_Parse(event->data);
            cJSON* id_js = cJSON_GetObjectItem(ota_js, "id");
            onenet_ota_ack(cJSON_GetStringValue(id_js), 200, "success"); // 200为成功码
            cJSON_Delete(ota_js);
            //开始OTA升级流程
            onenet_ota_start();
        }

十二、I2S总线

①I2S的三种工作传输模式

1.标准模式(飞利浦模式)

2.左对齐模式

3.右对齐模式

十三、PCM脉冲编码调制

①I2S总线传输的为数字信号,但是现实生活都为模拟信号,我们需要将模拟信号转化为数字信号

②PCM过程:采样->量化->编码

十三、PDM编码

相应的PDM接口

十四、麦克风和喇叭驱动编写

喇叭驱动电路:

①由于喇叭直接使用ESP32S3是无法驱动的,所以我们使用NS4168功放芯片对其进行驱动,此芯片最大可以驱动2.5W的喇叭,并且其为I2S接口,支持8KHZ到96KHZ的采样率

②ESP32S3将PCM格式的数据通过I2S总线输出给功放芯片后,功放芯片将直接驱动喇叭进行播放

③我们的开发板只有一个声道,左右声道通过CTRL引脚来设置,当引脚拉高选择的是右声道,当引脚输入1V左右的电平为左声道,直接拉低则关断芯片,这里我们直接通过XL9555芯片拉高CTRL引脚,设置为右声道

麦克风驱动电路:

①PDM接口的麦克风,出来的直接是数字信号,可以与ESP32S3直接相连

②其通过L/R引脚来控制声道,拉高为右,拉低为左,直接选择拉高右声道

相应的代码编写如下:

①编写喇叭初始化函数,第一个参数为bclk引脚(位时钟线引脚),第二个参数为声道选择线,第三个参数为串行数据输出线,第四个参数为采样率

1.使用I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_1,I2S_ROLE_MASTER)函数对i2s通道的结构体进行默认初始化,第二个参数即将通道配置为主模式,表示blck和ws由esp32内部提供

2.i2s_new_channel(&chan_cfg,&tx_handle,NULL),使用此函数初始化通道,获取输出操作句柄

3.配置完通道结构体后进行i2s的结构体初始化,其中的clk_cfg成员为时钟源,我们通过采用宏I2S_STD_CLK_DEFAULT_CONFIG(sample_rate)既可以获取到我们对应需要的采样率所适配的时钟频率;slot_cfg成员则是时隙配置,其配置数据帧的格式,包括数据位宽、通道模式、通信标准,我们使用宏I2S_STD_PHILIP_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT,I2S_SLOT_MODE_MONO),将此结构体配置为16bit的采样位深,单数据通道模式,协议为飞利浦协议;其中的gpio_cfg结构体则将我们对应的引脚传入,其中din和mclk无需,故为-1

4.由于我们的硬件电路设计只能够使用右通道,故我们还需要在最后手动改变成右通道数据i2s_tx_cfg.slot_cfg.slot_mask = I2S_STD_SLOT_RIGHT

5.传入对应的API函数进行初始化,并开启通道即可

6.具体代码如下:

//初始化喇叭,也就是初始I2S通道,参数为各个引脚,以及采样率
void init_speaker(gpio_num_t bclk,gpio_num_t ws,gpio_num_t sd,uint32_t sample_rate)
{
    i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_1,I2S_ROLE_MASTER);  //esp32s3有i2s0以及i2s1
    i2s_new_channel(&chan_cfg,&tx_handle,NULL);      //会进行i2s通道的初始化,会返回一个发送句柄和接收句柄
    i2s_std_config_t i2s_tx_cfg = 
    {
        .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(sample_rate), //采样率
        .slot_cfg = I2S_STD_PHILIP_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT,I2S_SLOT_MODE_MONO),   //第一个参数采样位深,16位在嵌入式中更为常用,第二个是单通道还是双通道
        .gpio_cfg = 
        {
            .bclk = bclk,
            .dout = sd,
            .din = -1,
            .ws = ws,   //声道选择线
            .mclk = -1,    //当我们用外部作为主时钟的时候才需要设置
        },
    };
    i2s_tx_cfg.slot_cfg.slot_mask = I2S_STD_SLOT_RIGHT; //选择右通道数据
    i2s_channel_init_std_mode(tx_handle,&i2s_tx_cfg);
    i2s_channel_enable(tx_handle);  //启用通道
}

②编写初始化麦克风的函数

麦克风与喇叭初始化函数的不同之处在于麦克风使用pdm协议,故相关的初始化与pdm有关,具体代码如下所示:

//初始化麦克风,也就是初始化pdm接口,也是使用i2s进行传输的,i2s0可以作为pdm接口传输
void init_pdm_microphone(gpio_num_t data,gpio_num_t clk,uint32_t sampe_rate)
{
    i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0,I2S_ROLE_MASTER);  //esp32s3有i2s0以及i2s1
    i2s_new_channel(&chan_cfg,NULL,&rx_handle);      //会进行i2s通道的初始化,会返回一个发送句柄和接收句柄
    i2s_pdm_rx_config_t pdm_cfg = 
    {
        .clk_cfg = I2S_PDM_RX_CLK_DEFAULT_CONFIG(sampe_rate),
        .slot_cfg = I2S_PDM_RX_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT,I2S_SLOT_MODE_MONO),
        .gpio_cfg = 
        {
            .clk = clk,
            .din = data,
        },
    };
    pdm_cfg.slot_cfg.slot_mask = I2S_PDM_SLOT_RIGHT;
    i2s_channel_init_pdm_rx_mode(rx_handle,&pdm_cfg);
    i2s_channel_enable(rx_handle);  //启用通道
}

③编写读音频数据的函数

1.使用i2s_channel_read(rx_handle,data,samples*2,&bytes_read,1000);函数,传入输入操作句柄,数据存储区,要读取的数据字长度,存储实际读到的长度变量地址,超时时间,即可完成读取

//用于读音频数据,第一个参数为要读取的音频数据,第二个为要读取的音频数据的字长,返回值为实际读取到的音频字长
int audio_read(int16_t* data,int samples)
{
    size_t bytes_read;
    i2s_channel_read(rx_handle,data,samples*2,&bytes_read,1000); //我们samples为字长,而此API需要传入的是字节,1字等于2个字节,所以需要乘以2
    bytes_read = bytes_read/2;
    return bytes_read;
}

④编写写音频数据函数,与读音频数据类似

//写入音频数据到喇叭,用于播放
int audio_write(const int16_t* data,int samples)
{
    size_t bytes_write;
    i2s_channel_write(tx_handle,data,samples*2,&bytes_write,1000); 
    bytes_write = bytes_write/2;
    return bytes_write;
}

十五、录音播放实验

相应的流程:

①程序启动录音,从麦克风读取音频数据,通过写文件的方式写到spiffs保存

②通过读文件的方式,从spiffs中读取数据,通过i2s总线传输给功放芯片,从而进行喇叭播放

spiffs文件系统的使用

①在文件夹下添加相应的分区表,分区表中添加相应的分区

②使用menuconfig,将分区表改为用户自己设置的分区,并且填写分区表文件名

③包含头文件"esp_spiffs.h"

④编写spiffs文件系统的初始化函数

1.配置文件系统的结构体,填写挂载点,所用分区,最大打开文件数,挂载失败是否初始化

2.使用初始化函数进行初始化即可

3.具体代码如下:

void audio_spiffs_init(void)
{
    esp_vfs_spiffs_conf_t conf = 
    {
        .base_path = AUDIO_MOUNT,   //挂载点
        .partition_label = "audio",
        .max_files = 2,
        .format_if_mount_failed = true,  //第一次上电基本都会挂载失败,需要进行格式化
    };
    esp_vfs_spiffs_register(&conf);
}

4.挂载完后存放在此挂载点下的文件即可被存放入flash中,并且可以以c语言的方式操作文件

编写麦克风开始录音函数

//开始录音函数
void start_record(uint32_t rec_time)
{
    //录音数据总长度(字)
    const int flash_rec_size = SAMPLE_RATE*rec_time;    //采样率乘以时间
    const size_t read_size_word = 8192;     //每次读取的音频数据长度(字)
    int flash_wr_size = 0;  //记录写了多少数据到文件
    ESP_LOGI(TAG,"Start record");
    FILE* f = fopen(AUDIO_MOUNT"/record.pcm","w");       //第一个参数为路径,
    if(!f)
    {
        ESP_LOGI(TAG,"open record file fail!");
        return ;
    }
    int16_t* i2s_read_buf = (int16_t*)malloc(8192*2);  //分配缓冲区内存
    while(flash_wr_size<flash_rec_size)
    {
        int read_word = audio_read(i2s_read_buf,read_size_word);
        if(read_word)
        {
            for(int i=0;i<read_word;i++)
            {
                int32_t temp = i2s_read_buf[i]<<3;
                //限制最大最小值,避免越界失真
                if(temp > 32767) temp = 32767;
                if(temp < -32768) temp = -32768;
                i2s_read_buf[i] = (int16_t)temp;   //将音频数据放大,声音放大,pcm格式数据的振幅代表音量,但是要注意避免过大失真

            }
            ESP_LOGI(TAG,"audio read word:%d",read_word);
            fwrite(i2s_read_buf,read_word*2,1,f);
            flash_wr_size += read_word;
        }
    }
    free(i2s_read_buf);
    fclose(f);
    ESP_LOGI(TAG,"Record done");
}

①函数的参数为录音的持续时间(后续可进行优化避免录音时间过长,超出内存容量)

②先通过录音时间计算出,录音数据的总字长,通过采样率乘以录音时间即可得

③由于我们要分次将音频数据写入到文件,所以定义每次读取的音频数据长度,单位也为字

创建一个变量来存储已经读到多少数据到文件

④使用fopen函数使用写方式打开文件,文件打开失败则进行提示

⑤分配数据缓冲区的内存,由于单位为字节,故我们使用字乘以2

⑥已经读取到的数据小于总的数据时,则进行循环地读取,使用audio_read函数

⑦将每个数据点,进行放大(增大音量,但是要注意不要超过数据类型大小限制)

⑧随后将数据写入到存储文件即可

⑨最后释放刚刚分配的内存,关闭文件,打印提示即可

编写使用喇叭播放音频的函数

void play_audio(void)
{
    struct stat st;
    const size_t write_size_word = 8192;
    if(stat(AUDIO_MOUNT"/record.pcm",&st)==0)
    {
        ESP_LOGI(TAG,"record.pcm filesize:%ld",st.st_size);
    }
    else
    {
        ESP_LOGI(TAG,"record.pcm file not exist!");
        return;
    }
    FILE* f = fopen(AUDIO_MOUNT"/record.pcm","r");
    if(!f)
    {
        ESP_LOGI(TAG,"record.pcm file not exist!");
        return;
    }
    ESP_LOGI(TAG,"Start play");
    size_t read_byte = 0;
    int16_t *i2s_write_buff = (int16_t*)malloc(write_size_word);
    do
    {
        fread(i2s_write_buff,write_size_word*2,1,f);
        audio_write(i2s_write_buff,write_size_word);
        read_byte += write_size_word*2;
    }while(read_byte < st.st_size);
    free(i2s_write_buff);
    fclose(f);
    ESP_LOGI(TAG,"Start done");
}

①包含stat操作头文件#include <sys/stat.h>,通过stat函数获取到文件的信息,我们想要获取到文件的数据长度

②随后运用读的方法打开文件,分配内存缓冲区,读取文件数据,将数据传给音频播放函数进行播放

③记录已读字节,最后释放内存,关闭文件即可

主函数调用流程

void app_main(void)
{
    audio_spiffs_init();
    xl9555_init(GPIO_NUM_10,GPIO_NUM_11,GPIO_NUM_NC,NULL);
    xl9555_ioconfig((~IO0_0)&0xFFFF);   //把IO0_0引脚清0,设置为输出
    init_speaker(GPIO_NUM_46,GPIO_NUM_9,GPIO_NUM_8,SAMPLE_RATE);
    init_pdm_microphone(GPIO_NUM_42,GPIO_NUM_3,SAMPLE_RATE);
    xl9555_pin_write(IO0_0,0);
    start_record(10);
    xl9555_pin_write(IO0_0,1);
    vTaskDelay(pdMS_TO_TICKS(100));    //延时一下,免得电平还没稳定就开始工作
    play_audio();
    xl9555_pin_write(IO0_0,0);
    while(1)
    {
        vTaskDelay(pdMS_TO_TICKS(5000));
    }
}

①先进行spiffs文件系统初始化

②初始化对应的xl9555引脚,中断引脚和中断回调函数不需要

③将IO0_0引脚设置为输出

④初始化麦克风,喇叭等

⑤当IO0_0设置为低电平时,和喇叭相连的功放芯片被关断,我们进行麦克风录音,随后开启为1,开启右声道,等电平稳定后即播放喇叭音频即可,最后关断。

十六、MCP协议应用

MCP协议交互流程

MCP 消息是封装在基础通信协议(如 WebSocket 或 MQTT)的消息体中的。其内部结构遵循 JSON-RPC 2.0 规范。

整体消息结构示例:

{
  "session_id": "...", // 会话 ID
  "type": "mcp",       // 消息类型,固定为 "mcp"
  "payload": {         // JSON-RPC 2.0 负载
    "jsonrpc": "2.0",
    "method": "...",   // 方法名 (如 "initialize", "tools/list", "tools/call")
    "params": { ... }, // 方法参数 (对于 request)
    "id": ...,         // 请求 ID (对于 request 和 response)
    "result": { ... }, // 方法执行结果 (对于 success response)
    "error": { ... }   // 错误信息 (对于 error response)
  }
}

相关的链接:https://github.com/78/xiaozhi-esp32/blob/main/docs/mcp-protocol.md

### ESP32 NVS 学习教程 #### 非易失性存储(NVS)简介 非易失性存储(NVS)允许应用程序以键值对形式保存数据到闪存中,即使设备断电后也能保持这些数据不变。对于ESP32来说,这是一项非常有用的功能,可以用来储存配置参数或其他重要信息[^1]。 #### 初始化NVS分区 为了能够读取和写入NVS中的数据,在操作之前需要先初始化相应的分区: ```c #include "nvs_flash.h" void app_main(void){ // 初始化默认的NVS命名空间 esp_err_t err = nvs_flash_init(); if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { // 如果NVS尚未被初始化,则擦除并重新初始化 ESP_ERROR_CHECK(nvs_flash_erase()); err = nvs_flash_init(); } } ``` 这段代码展示了如何安全地初始化NVS分区,处理可能遇到的一些错误情况,比如当没有可用页面或者发现了新的版本时会执行擦除再重试的操作[^4]。 #### 向NVS中写入整数型数据 下面是一个简单的例子来展示怎样把一个`int32_t`类型的数值储存在指定的名字空间下,并赋予特定的关键字名称: ```c #include "nvs.h" #include "nvs_flash.h" static void write_data_to_nvs(const char *namespace_name, const char *key, int32_t value){ nvs_handle my_handle; esp_err_t err; // 打开给定名字的空间以便于写入 err = nvs_open(namespace_name, NVS_READWRITE, &my_handle); if (err != ESP_OK) return; // 将value作为名为key的数据项写入打开的手柄所指向的位置 err = nvs_set_i32(my_handle, key, value); // 提交更改至flash nvs_commit(my_handle); // 关闭当前使用的句柄 nvs_close(my_handle); } ``` 此函数接收三个参数:要访问的名字空间字符串、代表目标变量名的字符串以及待存储的实际数值。通过调用`nvs_open()`获取对应区域的一个手柄对象;接着利用该手柄配合`nvs_set_i32()`完成实际赋值工作;最后记得关闭这个连接以防资源泄露[^3]。 #### 从NVS中读取整数型数据 同样地,这里也给出了一段示范性的C语言程序片段用于提取先前已经存在的记录: ```c static bool read_data_from_nvs(const char *namespace_name, const char *key, int32_t *out_value){ nvs_handle my_handle; esp_err_t err; // 获取name_space对应的handle err = nvs_open(namespace_name, NVS_READONLY, &my_handle); if (err != ESP_OK) return false; // 查询是否存在key对应的值 err = nvs_get_i32(my_handle, key, out_value); // 不管成功与否都要释放掉handel nvs_close(my_handle); return err == ESP_OK ? true : false; } ``` 上述方法接受两个输入参数——所属的名字空间与查询关键字,还有一个指针形参用来返回找到的结果。如果一切顺利的话将会更新传入的指针位置处的内容为匹配上的那个整数值;反之则不会改变它原本的状态。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值