基于ESP-BOX-LITE的联网天气语音播报系统
基于ESP-BOX-LITE的联网天气语音播报系统。主控芯片是esp32s3,天气信息由心知天气api获取,语音播报是用乐鑫的TTS功能。
标签
WiFi
ESP32-S3
Funpack2-5
心知天气
TTS
语音播报
littlestudent
更新2023-08-01
492

任务分析:

本次Funpack2-5完成的任务是:使用ESP32的WiFi和TTS功能,实现一个语音播报系统,联网获取天气并播报

因为这次的开发环境为IDF,是第一次接触,所以在环境配置方面也遇到了一些难题。根据对题目的理解,我把这次的任务分解为三个子任务:

Sub1: ESP32实现联网功能,主要是WIFI模块软硬件

Sub2: ESP32实现获取天气,主要是找到合适的天气API,json格式文件的解析

Sub3: ESP32实现把获取的天气用语音播报出来,主要是乐鑫TTS功能的使用,codec的驱动等。

下文将详细介绍上述三个子任务的设计细节以及遇到的问题和挑战。

主程序流程图:

FiVUo5TlyWlcID5PYUlkmTiqcs8a

 

ESP32-S3-BOX-LITE硬件介绍:

ESP32-S3 是一款集成 2.4 GHz Wi-Fi 和 Bluetooth 5 (LE) 的 MCU 芯片,支持远距离模式 (Long Range)。ESP32-S3 搭载 Xtensa® 32 位 LX7 双核处理器,主频高达 240 MHz,内置 512 KB SRAM (TCM),具有 45 个可编程 GPIO 管脚和丰富的通信接口。ESP32-S3 支持更大容量的高速 Octal SPI flash 和片外 RAM,支持用户配置数据缓存与指令缓存。

  • Xtensa® 32 位 LX7 双核处理器,主频高达 240 MHz
  • 内置 512 KB SRAM、384 KB ROM 存储空间,并支持多个外部 SPI、Dual SPI、 Quad SPI、Octal SPI、QPI、OPI flash 和片外 RAM
  • 额外增加用于加速神经网络计算和信号处理等工作的向量指令 (vector instructions)
  • 45 个可编程 GPIO,支持常用外设接口如 SPI、I2S、I2C、PWM、RMT、ADC、UART、SD/MMC 主机控制器和 TWAITM 控制器等
  • 基于 AES-XTS 算法的 Flash 加密和基于 RSA 算法的安全启动,数字签名和 HMAC 模块,“世界控制器 (World Controller)”模块

FgcYrtCl9EjJGRHtNXCY16F3kCDO

该开发板配备一块 2.4 寸 LCD 显示屏、双麦克风、一个扬声器、两个用于硬件拓展的 Pmod™ 兼容接口、结合三个独立按键,可构建多样的 HMI 人机交互应用。

软件开发环境介绍

由于ESP32-S3-BOX-LITE在乐鑫的Github上有丰富的example,并且这些example都是基于IDF完成的,因此我也是第一次尝试使用IDF来开发ESP32。IDF工具链的安装建议参考乐鑫官网

从零开始设置 Windows 环境下的工具链 https://docs.espressif.com/projects/esp-idf/zh_CN/v4.4/esp32/get-started/windows-setup-scratch.html

有一点需要注意的就是box-lite的很多example都是基于idf 4.4版本的,因此强烈建议使用这个版本的IDF。

mkdir %userprofile%\esp
cd %userprofile%\esp
git clone -b v4.4 --recursive https://github.com/espressif/esp-idf.git

安装好IDF后,桌面上有自动生成的图标,双击后,如果如下图高量显示:Done,则说明环境配置成功,可以进行IDF开发了。

FpM2N5FRH63F1A5bUcgFfCsm20Nu

Sub1: ESP32实现联网功能,主要是WIFI模块软硬件

乐鑫IDF安装好后,其实已经自带了非常多的组件,并且提供了相应的例程。比如WIFI联网,可以参考:

Fv8ohjJP62cT1cSB0XbFotkePz00

在我们main组件中的main.c文件中:

//WiFi-1-Step to include #include "esp_wifi.h"
#include "esp_system.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_log.h"
#include "nvs_flash.h"

#include "lwip/err.h"
#include "lwip/sockets.h"
#include "lwip/sys.h"
#include "lwip/netdb.h"
#include "lwip/dns.h"

#include "protocol_examples_common.h"

在初始化wifi协议栈之前,需要初始化NVS(非易失性存储):

     //Initialize NVS
    esp_err_t ret = nvs_flash_init();
    if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
      ESP_ERROR_CHECK(nvs_flash_erase());
      ret = nvs_flash_init();
    }
    ESP_ERROR_CHECK(ret);

首先对nvs进行初始化,如果初始化不成功,则进行擦除,然后再执行初始化,以便可以将数据存储再nvs当中。nvs初始化完成之后,就可以执行用户自定义的WiFi初始化和连接代码。主函数中涉及到的基本的代码以及一些宏定义解释如下:

代码 注释

nvs_flash_init()

对nvs进行初始化,nvs是flash中用来保存WiFi通信的数据,具体原理参考ESP32-S3 NVS入门。

nvs_flash_erase()

在nvs没有多余空间存储页面或者nvs有新版本的时候,对nvs进行擦除,随后还是要执行nvs初始化nvs_flash_init()

WIFI联网模块遇到的问题:

  • 使用ESP-IDF找不到nvs_flash.h头文件。解决办法:尝试在main组件的CMakelists.txt中添加nvs组件:

FihDHtVvwmN7Zlhq-9lO5cIf-1_8

  • 编译main.c的时候,遇到nvs初始化的ret异常,如下图所示,应该是nvs_flash初始化失败了。

Fo3v6JT5d7U7vvZa7k0Gd4E2kHNQ

解决办法:

乐鑫的IDF框架中的SmartConfig会把SSID以及密码等存储在非易失性存储单元中。当前项目使用的分区表如下,可见并没有指定nvs分区。
FkkFDV6NDfdwswbJmBAnXifFMmp8
 
每片 ESP32-S3 的 flash 可以包含多个应用程序,以及多种不同类型的数据(例如校准数据、文件系统数据、参数存储数据等)。因此,我们在 flash 的 默认偏移地址 0x8000 处烧写一张分区表。
分区表的长度为 0xC00 字节,最多可以保存 95 条分区表条目。MD5 校验和附加在分区表之后,用于在运行时验证分区表的完整性。分区表占据了整个 flash 扇区,大小为 0x1000 (4 KB)。因此,它后面的任何分区至少需要位于 (默认偏移地址) + 0x1000 处。
分区表中的每个条目都包括以下几个部分:Name(标签)、Type(app、data 等)、SubType 以及在 flash 中的偏移量(分区的加载地址)。
参考相关的分区表例子,更新了本项目的分区表,从新烧录后问题解决。
 
FnyNlcpgIu9r9Z0zC8FzFpwlVYL9
 

 

 

初始化nvs之后,调用esp_netif_init()初始化TCP/IP协议堆栈;调用esp_event_loop_create_default()创建默认任务;最后调用example_connect()来连接wifi.

ESP_LOGI(TAG, "ESP_WIFI_MODE_STA");
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());

/* This helper function configures Wi-Fi or Ethernet, as selected in menuconfig.
     * Read "Establishing Wi-Fi or Ethernet Connection" section in
     * examples/protocols/README.md for more information about this function.
*/
ESP_ERROR_CHECK(example_connect());

关于example_connect(),idf官方例程中提供了一个readme,在本项目中也利用了这个helper function来实现联网功能。

### About the `example_connect()` Function

Protocols examples use a simple helper function, `example_connect()`, to establish Wi-Fi and/or Ethernet connection. This function is implemented in [examples/common_components/protocol_examples/common/connect.c](../common_components/protocol_examples_common/connect.c), and has a very simple behavior: block until connection is established and IP address is obtained, then return. This function is used to reduce the amount of boilerplate and to keep the example code focused on the protocol or library being demonstrated.

The simple `example_connect()` function does not handle timeouts, does not gracefully handle various error conditions, and is only suited for use in examples. When developing real applications, this helper function needs to be replaced with full Wi-Fi / Ethernet connection handling code. Such code can be found in [examples/wifi/getting_started/](../wifi/getting_started) and [examples/ethernet/basic/](../ethernet/basic) examples.
 
FnhgSrtA9-g1krWaqcVYP_vltfx8
至此,wifi联网部分的代码基本结束,接下来介绍如果获取天气信息。

Sub2: ESP32实现获取天气,主要是找到合适的天气API,json格式文件的解析

这里使用的是心知天气的api,网上有非常多的注册教程,

FikCUu8eWCBzJcSf_lkYws2Rw6i9

比如把下面的key=xx更新成自己的key,输入到浏览器,就可以看到返回的json格式的数据。

https://api.seniverse.com/v3/weather/now.json?key=xxx&location=shanghai&language=zh-Hans&unit=c

{"results":[{"location":{"id":"WTW3SJ5ZBJUY","name":"上海","country":"CN","path":"上海,上海,中国","timezone":"Asia/Shanghai","timezone_offset":"+08:00"},"now":{"text":"小雨","code":"13","temperature":"22"},"last_update":"2023-06-17T14:52:38+08:00"}]}

这种格式的数据直接看不直观,可以用在线的JSON查看工具转换一下:

{
    "results": [{
        "location": {
            "id": "WTW3SJ5ZBJUY",
            "name": "上海",
            "country": "CN",
            "path": "上海,上海,中国",
            "timezone": "Asia/Shanghai",
            "timezone_offset": "+08:00"
        },
        "now": {
            "text": "小雨",
            "code": "13",
            "temperature": "22"
        },
        "last_update": "2023-06-17T14:52:38+08:00"
    }]
}

esp32-box-lite如何获取相同的天气信息呢?这就需要用到HTTP GET方法,以及JSON解析组件。

#define WEB_SERVER "api.seniverse.com"
#define WEB_PORT "80"
#define WEB_PATH "https://api.seniverse.com/v3/weather/daily.json?key=xxx&location=shanghai&language=zh-Hans&unit=c&start=-1&days=5"

static const char *REQUEST = "GET " WEB_PATH " HTTP/1.1\r\n"
    "Host: "WEB_SERVER":"WEB_PORT"\r\n"
    "User-Agent: esp-idf/1.0 esp32\r\n"
    "\r\n";
static char weather_buf[2048];

static void http_get_task(void)
{
    const struct addrinfo hints = {
        .ai_family = AF_INET,
        .ai_socktype = SOCK_STREAM,
    };
    struct addrinfo *res;
    struct in_addr *addr;
    int s, r;
    

    while(1) {
        int err = getaddrinfo(WEB_SERVER, WEB_PORT, &hints, &res);

        if(err != 0 || res == NULL) {
            ESP_LOGE(TAG, "DNS lookup failed err=%d res=%p", err, res);
            vTaskDelay(1000 / portTICK_PERIOD_MS);
            continue;
        }

        /* Code to print the resolved IP.

           Note: inet_ntoa is non-reentrant, look at ipaddr_ntoa_r for "real" code */
        addr = &((struct sockaddr_in *)res->ai_addr)->sin_addr;
        ESP_LOGI(TAG, "DNS lookup succeeded. IP=%s", inet_ntoa(*addr));

        s = socket(res->ai_family, res->ai_socktype, 0);
        if(s < 0) {
            ESP_LOGE(TAG, "... Failed to allocate socket.");
            freeaddrinfo(res);
            vTaskDelay(1000 / portTICK_PERIOD_MS);
            continue;
        }
        ESP_LOGI(TAG, "... allocated socket");

        if(connect(s, res->ai_addr, res->ai_addrlen) != 0) {
            ESP_LOGE(TAG, "... socket connect failed errno=%d", errno);
            close(s);
            freeaddrinfo(res);
            vTaskDelay(4000 / portTICK_PERIOD_MS);
            continue;
        }

        ESP_LOGI(TAG, "... connected");
        freeaddrinfo(res);

        if (write(s, REQUEST, strlen(REQUEST)) < 0) {
            ESP_LOGE(TAG, "... socket send failed");
            close(s);
            vTaskDelay(4000 / portTICK_PERIOD_MS);
            continue;
        }
        ESP_LOGI(TAG, "... socket send success");

        struct timeval receiving_timeout;
        receiving_timeout.tv_sec = 5;
        receiving_timeout.tv_usec = 0;
        if (setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &receiving_timeout,
                sizeof(receiving_timeout)) < 0) {
            ESP_LOGE(TAG, "... failed to set socket receiving timeout");
            close(s);
            vTaskDelay(4000 / portTICK_PERIOD_MS);
            continue;
        }
        ESP_LOGI(TAG, "... set socket receiving timeout success");

            bzero(weather_buf, sizeof(weather_buf));
            r = read(s, weather_buf, sizeof(weather_buf)-1);
            ESP_LOGI(TAG, "心知天气返回结果:  %s", weather_buf);        // 打印获取的天气信息,存储在数组中
            if(true == parse_json_data(weather_buf)){
				break;
			}


        ESP_LOGI(TAG, "... done reading from socket. Last read return=%d errno=%d.", r, errno);
        close(s);
        for(int countdown = 2; countdown >= 0; countdown--) {
            ESP_LOGI(TAG, "%d... ", countdown);
            vTaskDelay(1000 / portTICK_PERIOD_MS);
        }
        ESP_LOGI(TAG, "Starting again!");
    }
}

IDF有专门的json组件,只需要在头文件包含一下即可!

#include "cJSON.h"

static bool parse_json_data(const char *analysis_buf);

接下来就是把HTTP GET返回的数据进行解析,数据存储在analysis_buf里面。

/**
 * @brief 解析天气数据(JSON)
 * 
 * @param analysis_buf 数据的存储空间
 * @return true 解析成功
 * @return false 解析失败
 */
static bool parse_json_data(const char *analysis_buf)
{
    cJSON   *json_data = NULL;
    /* 截取有效json */
    char *index = strchr(analysis_buf, '{');
    // strcpy(weather_buf, index);
    
    json_data = cJSON_Parse(index);
    if( json_data == NULL ) // 判断字段是否json格式
    {
        ESP_LOGI(TAG1, "-NO JSON DATA FOUND-"); 
        return false;
    }  

    // ESP_LOGI(TAG, "Start parsing data");   
    cJSON* cjson_item =cJSON_GetObjectItem(json_data,"results");
    cJSON* cjson_results =  cJSON_GetArrayItem(cjson_item,0);

    /* 获取天气的地址 */ 
    cJSON* cjson_location = cJSON_GetObjectItem(cjson_results,"location");
    cJSON* cjson_temperature_name = cJSON_GetObjectItem(cjson_location,"name");
    //strcpy(user_weather_info.location_name,cjson_temperature_name->valuestring);
    TextToVoice(cjson_temperature_name->valuestring);
    /* 天气信息 */
    cJSON* cjson_daily = cJSON_GetObjectItem(cjson_results,"daily");

    /* 当天的天气信息 */
    cJSON* cjson_daily_1 =  cJSON_GetArrayItem(cjson_daily,0);

    //strcat(FinalResult, cJSON_GetObjectItem(cjson_daily_1,"date")->valuestring);
    strcat(FinalResult, City);
    strcat(FinalResult, "白天");    strcat(FinalResult, cJSON_GetObjectItem(cjson_daily_1,"text_day")->valuestring);
    strcat(FinalResult, "夜间");    strcat(FinalResult, cJSON_GetObjectItem(cjson_daily_1,"text_night")->valuestring);

    ESP_LOGI(TAG1, "day_one_code is: %s", cJSON_GetObjectItem(cjson_daily_1,"code_day")->valuestring); 
    ESP_LOGI(TAG1, "day_one_temp_high is: %s", cJSON_GetObjectItem(cjson_daily_1,"high")->valuestring); strcat(FinalResult, HighTemp);strcat(FinalResult, cJSON_GetObjectItem(cjson_daily_1,"high")->valuestring);
    ESP_LOGI(TAG1, "day_one_temp_low is: %s", cJSON_GetObjectItem(cjson_daily_1,"low")->valuestring);   strcat(FinalResult, LowTemp);strcat(FinalResult, cJSON_GetObjectItem(cjson_daily_1,"low")->valuestring);
    ESP_LOGI(TAG1, "day_one_humi is: %s", cJSON_GetObjectItem(cjson_daily_1,"humidity")->valuestring);  strcat(FinalResult, Humid);strcat(FinalResult, cJSON_GetObjectItem(cjson_daily_1,"humidity")->valuestring);
    ESP_LOGI(TAG1, "day_one_windspeed is: %s", cJSON_GetObjectItem(cjson_daily_1,"wind_speed")->valuestring); strcat(FinalResult, WindSpeed);strcat(FinalResult, cJSON_GetObjectItem(cjson_daily_1,"wind_speed")->valuestring);
	TextToVoice("获取天气信息成功");
	return true;
}

至此,已经实现了联网,通过心知天气的api获取json格式数据并解析。接下来的子任务就是如何把天气给念出来!

 

Sub3: ESP32实现把获取的天气用语音播报出来,主要是乐鑫TTS功能的使用,codec的驱动等。

天气信息这块,我是把要读的文字都写入一个长字符串,主要是做static bool parse_json_data(const char *analysis_buf)中使用strcat()函数来把信息填充到FinalResult[]字符串中。这样我们就获得了一个UTF-8中文语句。

esp_tts_handle_t *tts_handle;

char *City="上海";
char *HighTemp="最高温度";   
char *LowTemp="最低温度"; 
char *Humid = "湿度";
char *WindSpeed = "风速";
char FinalResult[1024]={'\0'};

再来看乐鑫 TTS 的当前版本基于拼接法,主要组成部分包括:

  • 解析器 (Parser):根据字典与语法规则,将输入文本(采用 UTF-8 编码)转换为拼音列表。

  • 合成器 (Synthesizer):根据解析器输出的拼音列表,结合预定义的声音集,合成波形文件。默认输出格式为:单声道,16 bit @ 16000Hz。

系统框图如下:

FhYFwOfkMo2JlMh_ufTOfbrcj6SM

所以把天气信息作为输入送给TTS模块的代码:

    /*** 1. create esp tts handle ***/
    // initial voice set from separate voice data partition
    const esp_partition_t* part=esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_ANY, "voice_data");
    if (part==NULL) { 
        printf("Couldn't find voice data partition!\n"); 
        return 0;
    } else {
        printf("voice_data paration size:%d\n", part->size);
    }
    spi_flash_mmap_handle_t mmap;
    void* voicedata;
    esp_err_t err=esp_partition_mmap(part, 0, part->size, SPI_FLASH_MMAP_DATA, &voicedata, &mmap);
    if (err != ESP_OK) {
        printf("Couldn't map voice data partition!\n"); 
        return 0;
    }
    esp_tts_voice_t *voice=esp_tts_voice_set_init(&esp_tts_voice_template, (int16_t*)voicedata); 
    

    tts_handle=esp_tts_create(voice);

    /*** 2. play prompt text ***/
	TextToVoice("硬禾学堂的朋友们你们好我是您的天气助手");vTaskDelay(pdMS_TO_TICKS(2000)); 
    TextToVoice("精灵在努力尝试联网获取天气");
	http_get_task();
    while(1){

        TextToVoice(FinalResult);
        vTaskDelay(pdMS_TO_TICKS(30*60000)); 
    }

Codec方面,主要是参考了官方例程esp-box\examples\factory_demo提供的ES8156的驱动。

Fk3lRcjv0rNynlh6lVnzR7sdXvdc

下面可以看到文字可以被正常解析:

FpCnZv1eRhsT8BE4hDguJwVMCZKR

FgovQ1i-ZRlVDzOArw2UXu6hRQ31

实物:

luJUYCn9XK2Ruv4kltbPCSOpgqLO

遇到的问题:

  1. IDF环境搭建:国内在线安装速度挺慢的,后来发现有个离线版本,非常好用。
  2. Codec ES8156驱动:刚开始摸不着头脑,数据手册也是寥寥几笔,连个寄存器是什么意思都找不到。后来想到esp-box\examples\factory_demo里面有个MP3播放器,所以就一点点的把那里面的ES8156的驱动给挪过来用了。
  3. 程序刷写后,没有声音。不知所措,因为之前别的芯片都是直接ide点一下就ok了。后来参考相关的博文,bootloader, partition-table和应用程序的bin文件是必须的,另外语音助手需要刷入esp_tts_voice_data_xiaoxin.dat。后来通过观察官方例程中vscode中烧录的时候向哪些地址写入了哪个文件,后来发现按照下面的地址烧录,就可以正常的使用语音数据。
    Fp2oK5_yKrkY8wSq9rWMidZaAAaG  
        

 

未来的计划:

这次是第一次使用idf来开发esp32s3,所以在前期环境搭建以及熟悉cmake上面花了不少的时间。

  1. 这个小制作接下来还可以引入屏幕驱动,用上高大上的lvgl等在屏幕上显示天气信息,那会更加好玩。
  2. 联网通过心知天气获取天气数据的过程中,有时很快就能获取到有效的json数据,有时候要登上数分钟,目前还在努力寻找原因。
  3. 目前的WIFI联网信息是直接固定在代码中的,后面希望能找到方法通过menuconfig来设置wifi密码。
附件下载
Funpack-2023-5-Phase.zip
团队介绍
电子爱好者
评论
0 / 100
查看更多
目录
硬禾服务号
关注最新动态
0512-67862536
info@eetree.cn
江苏省苏州市苏州工业园区新平街388号腾飞创新园A2幢815室
苏州硬禾信息科技有限公司
Copyright © 2023 苏州硬禾信息科技有限公司 All Rights Reserved 苏ICP备19040198号