基本功能
-
WiFi收听网络电台以及使用FM模块接收空中的电台
-
通过按键实现切换、选台、静音等功能
-
系统自动校准时间
-
用OLED屏显示时间和电台信息
项目环境:
-
ESP-IDF v4.4:乐鑫官方的开发环境,是整个项目开发的基础。
-
ESP-IOT-SOLUTION:包含了esp32开发过程中常用的外设驱动和代码框架,这里使用其中的ssd1306库驱动oled,使用button组件实现按键识别。
-
ESP-ADF:esp32的音频开发框架,借鉴了其中的pipepline_living_stream例程实现了接收网络电台。我这一版本的ADF中i2c_bus中的一些函数命名与ESP-IOT-SOLUTION中的有冲突,需修改之后才能使用。
-
ESP-IDF-LIB:使用了其中的RDA5807M库来控制FM模块。
- MCU_Font_Release:用于生成LVGL使用的中文字体。
硬件
-
ESP32-S2-MINI-1:ESP32-S2-FH4的芯片,320K的DRAM,无PSRAM。
-
FM模块:RDA5807M。
-
OLED:128*64,6线SPI协议驱动。
项目实现
在开发的过程中我将整个项目分成了校准时间、获取网络电台、获取FM电台、按键的使用以及绘制OLED屏幕几个部分。其实一开始还有个连接wifi的部分,不过在adf的pipeline_living_stream例程中,在连接网络电台的同时实现了wifi连接的功能,并且比idf中的wifi例程还要简洁(可能是调用了wifi库的原因吧),这个问题也就解决了。
设计框图
①校准时间
一开始我打算通过http请求获取网页返回的时间,idf中也确实有类似的例程可以参考,但是在实现之后才发现这一方法虽然可以获取到准确的时间,但是因为网络请求速度有限的原因,获取的时间总是会有间隔,比如经常两秒三秒地变化,有时甚至会请求失败,网站API的读取次数也有限制。后来我在idf官网上发现通过sntp只需要一开始校准一下网络时间,后面的计时由系统内部进行,idf内部也有对应例程,我就选择了这一方法来校准时间。
校准时间和时间更新任务
void time_task(void *pvParameters)
{
time_t now = 0;
struct tm timeinfo = { 0 };
char strftime_buf[64];
char detail_time_buf[32] = {' ', ' ', ' ', ' ', ' ', ' '}; //提取出时分秒
obtain_time(); //获取当前时间
while (1)
{
vTaskDelay(pdMS_TO_TICKS(900));
time(&now); // update 'now' variable with current time
localtime_r(&now, &timeinfo);
setenv("TZ", "CST-8", 1);
tzset();
localtime_r(&now, &timeinfo);
strftime(strftime_buf, sizeof(strftime_buf), "%Y-%m-%d %H:%M:%S", &timeinfo);
if (timeinfo.tm_year >= (2022-1900) && time_label != NULL && lv_scr_act() == Net_scr)
{
for (int i=0; i<16; i++) {
detail_time_buf[i+4] = strftime_buf[i+10]; //0-9为年月日
}
lv_label_set_text(time_label, detail_time_buf);
}
ESP_LOGI(TAG, "The current date/time in Shanghai is: %s", strftime_buf);
}
}
②获取网络电台
一开始我认为这一部分比起其他的来说要简单很多,因为adf中网络电台的例程已经很完整了,我只需要做一些修修补补的工作就可以了,不过在写入例程之后它就产生了报错。
在一番查询和翻阅源代码之后,我了解了例程中应该是使用的i2s协议解码,然后通过DAC输出音频数据,顺着报错我找到了报错函数,认为应该是传入其中的addr数据出了错。
void play_living_stream_init(void)
{
esp_log_level_set("*", ESP_LOG_INFO);
esp_log_level_set(TAG, ESP_LOG_DEBUG);
ESP_LOGI(TAG, "[1.0] Create audio pipeline for playback");
audio_pipeline_cfg_t pipeline_cfg = DEFAULT_AUDIO_PIPELINE_CONFIG();
pipeline = audio_pipeline_init(&pipeline_cfg);
ESP_LOGI(TAG, "[1.1] Create http stream to read data");
http_stream_cfg_t http_cfg = HTTP_STREAM_CFG_DEFAULT();
http_cfg.event_handle = _http_stream_event_handle;
http_cfg.type = AUDIO_STREAM_READER;
http_cfg.enable_playlist_parser = true;
http_stream_reader = http_stream_init(&http_cfg);
ESP_LOGI(TAG, "[2.2] Create PWM stream to write data to codec chip");
pwm_stream_cfg_t pwm_cfg = PWM_STREAM_CFG_DEFAULT();
pwm_cfg.pwm_config.gpio_num_left = 17;
pwm_cfg.pwm_config.gpio_num_right = 18;
output_stream_writer = pwm_stream_init(&pwm_cfg);
ESP_LOGI(TAG, "[2.3] Create aac decoder to decode aac file");
aac_decoder_cfg_t aac_cfg = DEFAULT_AAC_DECODER_CONFIG();
aac_decoder = aac_decoder_init(&aac_cfg);
ESP_LOGI(TAG, "[2.4] Register all elements to audio pipeline");
audio_pipeline_register(pipeline, http_stream_reader, "http");
audio_pipeline_register(pipeline, aac_decoder, "aac");
audio_pipeline_register(pipeline, output_stream_writer, "output");
ESP_LOGI(TAG, "[2.5] Link it together http_stream-->aac_decoder-->pwm_stream-->[codec_chip]");
const char *link_tag[3] = {"http", "aac", "output"};
audio_pipeline_link(pipeline, &link_tag[0], 3);
ESP_LOGI(TAG, "[2.6] Set up uri (http as http_stream, aac as aac decoder, and default output is PWM)");
audio_element_set_uri(http_stream_reader, HLS_list[0].hls_url);
/* 连接wifi */
ESP_LOGI(TAG, "[ * ] Start and wait for Wi-Fi network");
esp_periph_config_t periph_cfg = DEFAULT_ESP_PERIPH_SET_CONFIG();
set = esp_periph_set_init(&periph_cfg);
periph_wifi_cfg_t wifi_cfg = {
.ssid = CONFIG_WIFI_SSID,
.password = CONFIG_WIFI_PASSWORD,
};
esp_periph_handle_t wifi_handle = periph_wifi_init(&wifi_cfg);
esp_periph_start(set, wifi_handle);
periph_wifi_wait_for_connected(wifi_handle, portMAX_DELAY);
ESP_LOGI(TAG, "[ 3 ] Set up event listener");
audio_event_iface_cfg_t evt_cfg = AUDIO_EVENT_IFACE_DEFAULT_CFG();
evt = audio_event_iface_init(&evt_cfg);
ESP_LOGI(TAG, "[3.1] Listening event from all elements of pipeline");
audio_pipeline_set_listener(pipeline, evt);
ESP_LOGI(TAG, "[3.2] Listening event from peripherals");
audio_event_iface_set_listener(esp_periph_set_get_event_iface(set), evt);
ESP_LOGI(TAG, "[ 4 ] Start audio_pipeline");
audio_pipeline_run(pipeline);
/* 音频解码循环拆出,另开进程 */
xTaskCreate(play_living_stream_task, "play_living_stream_task", 1024*2, NULL, 2, NULL);
}
③OLED的初始化
这里我使用的是esp-iot-solution库中的ssd1306驱动来实现oled的初始化,这一部分看似简单却卡了我最长时间。因为esp-iot-solution库中虽然有ssd1306的例程,但他的初始化用的是i2c协议,而这块板子的oled屏幕用的是spi协议驱动,所以我得对着iot中spi数据的定义修改替换ssd1306初始化函数中的i2c数据。这个其实还算好的,在我修改完毕下载代码后,板子就会显示下面的警告,之后就开始无限重启。
一开始我认为是我的代码编写有问题,于是我就去网上找相关的代码来看,不过怎么改都修正不了这个bug,于是我就只能顺着报错的函数去翻源代码,因为算是第一次接触这类框架的代码,所以翻得还是挺难受的,不过好在相关的文件不多,最后我确定了问题在esp-iot-solution的一个文件内。
这里虽然它为LCD驱动配置了write_command函数,但他并没有为spi协议的驱动编写该函数,所以代码中为oled写入的命令其实并没有传到oled内,我下载了另一版本的esp-iot-solution并在其中找到了为spi编写的write_command函数,将其复制进去之后就ok了。(直接替换iot会有其他报错)
SSD1306初始化
void SSD1306_init(void)
{
scr_driver_t g_lcd; // A screen driver
esp_err_t ret = ESP_OK;
spi_bus_handle_t bus_handle = NULL;
spi_config_t bus_conf = {
.miso_io_num = -1, //不使用
.mosi_io_num = 35, //LCD_SDA
.sclk_io_num = 36, //LCD_SCK
}; // spi_bus configurations
bus_handle = spi_bus_create(SPI2_HOST, &bus_conf);
scr_interface_spi_config_t spi_ssd1306_cfg = {
.spi_bus = bus_handle, /*!< Handle of spi bus */
.pin_num_cs = 37, /*!< SPI Chip Select Pin */
.pin_num_dc = 33, /*!< Pin to select Data or Command for LCD */
.clk_freq = 20*1000*1000, /*!< SPI clock frequency */
.swap_data = false, /*!< Whether to swap data */
};
scr_interface_driver_t *iface_drv;
scr_interface_create(SCREEN_IFACE_SPI, &spi_ssd1306_cfg, &iface_drv);
/** Find screen driver for SSD1306 */
ret = scr_find_driver(SCREEN_CONTROLLER_SSD1306, &g_lcd);
if (ESP_OK != ret) {
ESP_LOGE(TAG, "screen find failed");
return;
}
/** Configure screen controller */
scr_controller_config_t lcd_cfg = {
.interface_drv = iface_drv,
.pin_num_rst = 34, // The reset pin is 34
.pin_num_bckl = -1, // The backlight pin is not connected
.rst_active_level = 0,
.bckl_active_level = 1,
.offset_hor = 0,
.offset_ver = 0,
.width = 128,
.height = 64,
.rotate = SCR_DIR_RLBT,
};
/** Initialize SSD1306 screen */
g_lcd.init(&lcd_cfg);
scr_info_t g_lcd_info;
g_lcd.get_info(&g_lcd_info);
ESP_LOGI(TAG, "Screen name:%s | width:%d | height:%d | BPP:%d", g_lcd_info.name, g_lcd_info.width, g_lcd_info.height, g_lcd_info.bpp);
lvgl_init(&g_lcd, NULL); /* Initialize LittlevGL */
}
④按键的使用
按键的初始化只需要填写几个按键的GPIO口和有效电平即可,剩下的就是为按键配置不同按下状态对应的功能函数(单击、长按等)。
按键初始化和配置功能函数
void button_init(void)
{
for(int i = 0; i < BUTTON_NUM; i++) {
button_config_t cfg = {
.type = BUTTON_TYPE_GPIO,
.gpio_button_config = {
.gpio_num = KEY_NUM[i],
.active_level = 0,
},
};
g_btns[i] = iot_button_create(&cfg);
TEST_ASSERT_NOT_NULL(g_btns[i]);
g_btn_events[i] = BUTTON_NONE_PRESS; //初始状态是未按下
/* 为按键配置单击和长按功能 */
iot_button_register_cb(g_btns[i], BUTTON_SINGLE_CLICK, button_single_click_cb);
iot_button_register_cb(g_btns[i], BUTTON_LONG_PRESS_HOLD, button_long_press_hold_cb);
}
}
这里我用一个数组g_btn_events来存储按键状态,要用时只需要扫描这个数组就行。按键功能函数就是将按键状态写入这个数组。
功能函数编写
static void button_single_click_cb(void *arg)
{
TEST_ASSERT_EQUAL_HEX(BUTTON_SINGLE_CLICK, iot_button_get_event(arg));
g_btn_events[get_btn_index((button_handle_t)arg)] = BUTTON_SINGLE_CLICK;
}
static void button_long_press_hold_cb(void *arg)
{
TEST_ASSERT_EQUAL_HEX(BUTTON_LONG_PRESS_HOLD, iot_button_get_event(arg));
g_btn_events[get_btn_index((button_handle_t)arg)] = BUTTON_LONG_PRESS_HOLD;
}
⑤获取FM电台
这个部分我直接用的网上的代码,还刚好能用,我也只是大概看了下他给出的几个接口函数,就不说了。
rda5807m初始化
void rda5807m_stream_init()
{
/* IIC初始化 */
ESP_ERROR_CHECK(i2cdev_init());
rda5807m_dev.i2c_dev.cfg.scl_pullup_en = true;
rda5807m_dev.i2c_dev.cfg.sda_pullup_en = true;
ESP_ERROR_CHECK(rda5807m_init_desc(&rda5807m_dev, I2C_PORT, SDA_GPIO, SCL_GPIO));
ESP_ERROR_CHECK(rda5807m_init(&rda5807m_dev, RDA5807M_CLK_32768HZ));
rda5807m_set_frequency_khz(&rda5807m_dev, rda5807m_current_fre);
}
如何使用
①环境搭建
首先我们得搭建esp-idf和esp-adf的开发环境,具体的搭建方法可以去官方网站上查找相应的入门文档(esp-idf\esp-adf快速入门文档)。
②idf.py menuconfig
-
Serial flasher config ---> Flash size ---> 4 MB
-
Partition Table ---> 更改如下
-
WiFi Configuration 将wifi名称密码更改为自己的
-
Audio HAL ---> Audio board ---> ESP32-S2-Kaluga-1 v1.2
-
Compiler options ---> Optimization Level ---> Optimize for size (-Os)
下面的都是 Component config 中的选项
-
ESP32S2-specific ---> CPU frequency ---> 240 MHz
-
Wi-Fi ---> 去掉下面两项
-
LCD Drivers ---> Select Screen Controller ---> [*] SSD1306
- LVGL configuration ---> [*] LVGL minimal configuration
---> Color settings ---> Color depth ---> 1: 1 byte per pixel
---> Memory settings 如下
---> Feature configuration ---> Others ---> [*] Enable float in built-in (v)snprintf functions
---> Select theme default title font ---> UNSCII 8 (Perfect monospace font)
---> Enable built-in fonts ---> 如下
配置完毕后就可以下载使用了,有时候会遇到sntp校时长时间未成功的情况(网络电台界面最下端一直显示loading),这个时候一般重启一两次就可以了。
运行效果
开机界面
网络收音机
FM收音机
遇到的问题
本人是第一次接触这一类项目,像是环境搭建、命令行开发、库和框架的使用等等这些对我来说都是十分新奇的东西,不过这也同时意味着不熟练,在完成这次项目的过程中我碰到的问题也是数不胜数,除了上面写到的代码层面的问题之外,我碰到的代码之外的问题则更多。
①环境搭建
一开始的我没有搭建开发环境的这一概念,还是上网到处搜才查到esp-idf官方的快速入门文档,按照入门文档下载了工具之后,在下载idf环境时下载速度却十分缓慢,结果好像是从github上下载代码需要搭建梯子或是vpn什么的,后来我查到可以将github.com改成github.com.cnpmjs.org后从镜像站下载,这样下载速度就快了很多。
但是在使用idf中的一些例程时它却总是报错,于是我选择删除idf然后重下,在不断重下的过程中我学会了查看命令行中打印出来的那些信息,发现有几个库总是下载失败,我将那几个库挑出来单独下载之后,这个问题就算是解决了。
②如何使用库和框架
因为是第一次接触库,在加上之前编写过的都是很短的代码,这就导致了我在一开始看例程代码时有一个很折磨我的习惯,就是总想把例程中那些看不懂的函数都彻彻底底地弄懂,于是我就不断地在源代码里面翻找各种函数的定义和实现方法,同时因为框架代码不是几个文件就能写完的,我翻到的一个函数内部又会调用另一个我看不懂的函数,于是我又去不断翻代码,这样翻完一个函数也花费了大量的时间,同时也理解得不是特别深刻,过段时间不用就会忘得一干二净,实在是得不偿失。在过了一段时间后我理解到其实跟本没有必要每一个函数都了解其运行原理,结合官方文档和例程基本上就能了解这个库的用法,同时这些函数的命名也有一定的规律,在一开始这些我认为十分冗长的函数名字在后来也能对我起到一定的提示作用。
感想
虽然翻源代码可能不是十分适合使用库和框架的方法,但是在翻源代码的过程中我深刻地体会到了这些代码编写的精妙,而自己写的代码则是难以入眼,开发项目时也大多是缝合,自己要学的东西还有很多。总而言之,我很幸运能够参加硬禾学堂的这次活动,如果还有类似的活动我也会继续参加。