任务分析:
本次Funpack2-5完成的任务是:使用ESP32的WiFi和TTS功能,实现一个语音播报系统,联网获取天气并播报。
因为这次的开发环境为IDF,是第一次接触,所以在环境配置方面也遇到了一些难题。根据对题目的理解,我把这次的任务分解为三个子任务:
Sub1: ESP32实现联网功能,主要是WIFI模块软硬件
Sub2: ESP32实现获取天气,主要是找到合适的天气API,json格式文件的解析
Sub3: ESP32实现把获取的天气用语音播报出来,主要是乐鑫TTS功能的使用,codec的驱动等。
下文将详细介绍上述三个子任务的设计细节以及遇到的问题和挑战。
主程序流程图:
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)”模块
该开发板配备一块 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开发了。
Sub1: ESP32实现联网功能,主要是WIFI模块软硬件
乐鑫IDF安装好后,其实已经自带了非常多的组件,并且提供了相应的例程。比如WIFI联网,可以参考:
在我们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组件:
- 编译main.c的时候,遇到nvs初始化的ret异常,如下图所示,应该是nvs_flash初始化失败了。
解决办法:
初始化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来实现联网功能。
Sub2: ESP32实现获取天气,主要是找到合适的天气API,json格式文件的解析
这里使用的是心知天气的api,网上有非常多的注册教程,
比如把下面的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。
系统框图如下:
所以把天气信息作为输入送给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的驱动。
下面可以看到文字可以被正常解析:
实物:
遇到的问题:
- IDF环境搭建:国内在线安装速度挺慢的,后来发现有个离线版本,非常好用。
- Codec ES8156驱动:刚开始摸不着头脑,数据手册也是寥寥几笔,连个寄存器是什么意思都找不到。后来想到esp-box\examples\factory_demo里面有个MP3播放器,所以就一点点的把那里面的ES8156的驱动给挪过来用了。
- 程序刷写后,没有声音。不知所措,因为之前别的芯片都是直接ide点一下就ok了。后来参考相关的博文,bootloader, partition-table和应用程序的bin文件是必须的,另外语音助手需要刷入esp_tts_voice_data_xiaoxin.dat。后来通过观察官方例程中vscode中烧录的时候向哪些地址写入了哪个文件,后来发现按照下面的地址烧录,就可以正常的使用语音数据。
未来的计划:
这次是第一次使用idf来开发esp32s3,所以在前期环境搭建以及熟悉cmake上面花了不少的时间。
- 这个小制作接下来还可以引入屏幕驱动,用上高大上的lvgl等在屏幕上显示天气信息,那会更加好玩。
- 联网通过心知天气获取天气数据的过程中,有时很快就能获取到有效的json数据,有时候要登上数分钟,目前还在努力寻找原因。
- 目前的WIFI联网信息是直接固定在代码中的,后面希望能找到方法通过menuconfig来设置wifi密码。