硬件介绍
此次使用的ESP32-S2-MINI-1板卡采用ESP32-S2FN4芯片,主频高达240MHz,具有1MB RAM和4MB SPI Flash,完全满足基本开发需求,同时板卡还具有以下丰富的外设资源:
-
128*64 四线SPI接口 OLED
-
4个按键
-
2路音频输入:Mic与耳机插座
-
2路音频输出:喇叭和耳机插座
-
一个FM接收模块,可通过I2C接口对其进行参数设置,调节FM电台以及设置音量大小
-
一个模拟开关切换来自ESP32产生的音频还是FM输出的音频,模块开关的输出送到喇叭或耳机输出
本项目主要使用的外设为:OLED与按键。
项目介绍
本项目为制作一个本地气象台/温度计,最终需要实现以下功能:
-
利用OLED显示
-
显示当前本地的时间、温度和气象信息
设计思路
首先初始化nvs flash,然后对OLED和按键的基本GPIO引脚进行初始化,并将ESP32-S2模块配置为无线终端STA模式,以连接可以访问互联网的WIFI。在成功联网后,会先初始化SNTP,此次设置了3个可以访问的NTP服务器,如果第一个连接失败,系统会自动连接第二个,以此往复,成功获取NTP服务器时间数据后,会更新本地时间,然后断开与NTP服务器的连接,此后利用系统滴答时钟更新本地时间,每次更新都会向OLED任务发送一个事件更新完成标志,OLED成功获取后会更新屏幕上的时间显示。
此次项目采用HTTP协议访问心知天气,向其发送约定好的请求报文,然后用CJSON解析返回的响应报文,解析成功后同样会向OLED任务发送天气更新标志,以更新OLED气象数据的显示。此外,用户还可以通过Key1按键切换白天与晚上的天气显示,利用Key2按键切换显示的天数。
实现功能与展示
如上图所示,上方实时显示年月日、星期、当地时间信息,此即为网络时间实时显示功能。其余界面是天气显示相关内容,显示的“南京”当地的天气,右边的太阳表示是白天的天气,左下角显示的是气象情况,由气象图标与简体中文文本组成,右下角显示的是温湿度,图中湿度为84%,横线上方为最低温,下方为最高温,单位为摄氏度。下方的三个圆圈代表天气对于的时间,最左边圆圈代表今天的天气,以此类推,当空心圆变为实心圆时,代表当前显示是该天的天气信息。当按下key1按键后,可以切换显示的天气是白天还是晚上,晚上右边太阳图标会切换成月亮,如下图所示。
可以看到月亮图标,表示当前显示的是晚上的天气信息,此外还可以通过key2按键切换显示的天数,按下后实心圆会变换,表示已切换,默认情况下,会3秒自动更新一次显示。
主要代码说明
- WIFI配网
/**
* @brief wifi事件回调函数
* @param 无
* @return 无
*/
static void event_handler(void* arg, esp_event_base_t event_base,
int32_t event_id, void* event_data)
{
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) { // WiFi启动成功之后开始连接
esp_wifi_connect();
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { //WiFi断开连接/连接失败 尝试重连
if (s_retry_num < WIFI_MAX_RETRY) {
esp_wifi_connect();
s_retry_num++;
ESP_LOGI(TAG, "retry to connect to the AP");
} else {
xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); // 向事件组中设置相应标志位, 不能在中断上下文中调用
}
ESP_LOGI(TAG,"connect to the AP fail");
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { // WiFi连接成功 获取ip
ip_event_got_ip_t* event = (ip_event_got_ip_t*) event_data;
ESP_LOGI(TAG, "got ip:" IPSTR, IP2STR(&event->ip_info.ip));
s_retry_num = 0;
xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
}
}
/**
* @brief 初始化wifi为无线终端模式 Wireless Station
* @param 无
* @return 无
*/
void wifi_init_sta(void)
{
// 向上面的esp_event_loop_create_default()注册回调函数,在回调函数里面可以处理各种系统事件,比如wfi连接,断开等
ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT,
ESP_EVENT_ANY_ID,
&event_handler,
NULL,
&instance_any_id));
ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT,
IP_EVENT_STA_GOT_IP,
&event_handler,
NULL,
&instance_got_ip));
wifi_config_t wifi_config = {
.sta = {
.ssid = WIFI_SSID,
.password = WIFI_PASSWORD,
.threshold.authmode = WIFI_AUTH_WPA2_PSK,
.pmf_cfg = {
.capable = true,
.required = false
},
},
};
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA) ); // 设置wifi的模式为无线终端模式station
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config) ); // 设置wifi参数
ESP_ERROR_CHECK(esp_wifi_start() ); // 启动wifi
ESP_LOGI(TAG, "wifi_init_sta finished.");
// 等待wifi连接成功 或 wifi在尝试连接最大次数后失败 (放main线程中, main阻塞等待)
EventBits_t bits = xEventGroupWaitBits(s_wifi_event_group,
WIFI_CONNECTED_BIT | WIFI_FAIL_BIT,
pdFALSE,
pdFALSE,
portMAX_DELAY);
}
上面为wifi配网关键代码,首先对wifi_config结构体填入wifi账密,然后将wifi初始化为sta无线终端模式,然后采用portMAX_DELAY永久等待方式等待回调函数event_handler相应事件触发,该callback function有三种情况:
- wifi启动成功之后开始连接
- WiFi断开连接/连接失败 尝试重连,超过最大重连次数后发送WIFI_FAIL_BIT,表示连接失败
- WiFi连接成功 获取ip,发送成功连接事件标志WIFI_CONNECTED_BIT
- 启动sntp获取网络时间
static void esp_initialize_sntp(void)
{
sntp_setoperatingmode(SNTP_OPMODE_POLL); // 设置单播模式
sntp_setservername(0, "cn.ntp.org.cn"); // 设置访问服务器
sntp_setservername(1, "ntp1.aliyun.com");
sntp_setservername(2, "210.72.145.44"); // 国家授时中心服务器 IP 地址
setenv("TZ", "CST-8", 1);
sntp_init();
}
/**
* @brief 启动sntp获取网络时间
* @param 无
* @return 无
* @note tm_mon: 从0开始 tm_year: 距离1900年的差值,默认是70
* tm_yday: 一年的过去的天数 tm_isdst: 是否为夏时制
*/
void sntp_task(void *param){
time_t now;
esp_initialize_sntp();
// 延时等待SNTP初始化完成
do {
vTaskDelay(100 / portTICK_PERIOD_MS);
} while (sntp_get_sync_status() == SNTP_SYNC_STATUS_RESET);
// 成功获取网络时间后停止NTP请求,不然设备重启后会造成获取网络时间失败的现象
// 大概是服务器时根据心跳时间来删除客户端的,如果不是stop结束的客户端,下次连接服务器时就会出错
sntp_stop();
while (1)
{
time(&now); // 获取网络时间, 64bit的秒计数
tzset(); // 更新本地C库时间
localtime_r(&now, &timeinfo); // 转换成具体的时间参数
ESP_LOGI(TAG, "%4d-%02d-%02d %02d:%02d:%02d week:%d", timeinfo.tm_year + 1900, timeinfo.tm_mon + 1,
timeinfo.tm_mday, timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec, timeinfo.tm_wday);
xEventGroupSetBits(app_event_group, NTP_UPDATE_BIT);
vTaskDelay(1000 / portTICK_PERIOD_MS);
}
}
首先设置可用正常访问的ntp服务器ip,本次设置了3个可访问的服务器,防止其中1个服务器访问失败后,任务无法正常运行。这里需要修改menuconfig配置:
将SNTP最大服务器数量修改为3。
- 天气获取与解析
void weather_task(void *param)
{
int content_length = 0;
esp_http_client_config_t http_client_cfg = {
.url = weather_url,
};
esp_http_client_handle_t http_client_handle = esp_http_client_init(&http_client_cfg); // 初始化http连接
esp_http_client_set_method(http_client_handle, HTTP_METHOD_GET); // 向服务器发送get请求
memset(&weather, 0, sizeof(weather_t));
memset(&response_body, 0, sizeof(response_body));
while (1)
{
// 与目标主机创建连接,并且声明写入内容长度为0
esp_err_t err = esp_http_client_open(http_client_handle, 0);
if (err != ESP_OK)
{
ESP_LOGE(TAG, "Failed to open HTTP connection: %s", esp_err_to_name(err));
goto __fail_delay;
}
// 读取响应头报文
content_length = esp_http_client_fetch_headers(http_client_handle);
if (content_length < 0 || content_length > RESPONSE_BODY_MAX_SIZE) // 返回响应头报文长度
{
ESP_LOGE(TAG, "HTTP client fetch headers failed");
goto __fail_delay;
}
if (esp_http_client_read_response(http_client_handle, response_body, RESPONSE_BODY_MAX_SIZE) < 0)
{
ESP_LOGE(TAG, "Failed to read response");
goto __fail_delay;
}
ESP_LOGI(TAG, "HTTP GET Status = %d, content_length = %d",
esp_http_client_get_status_code(http_client_handle), //获取响应状态信息
esp_http_client_get_content_length(http_client_handle)); //获取响应信息长度
cjson_parse_xinzhi_weather(response_body);
esp_http_client_close(http_client_handle); // 断开连接
memset(&response_body, 0, sizeof(response_body));
__fail_delay:
for (size_t i = 0; i < REQUEST_INTERVAL * 60; i++)
vTaskDelay(1000 / portTICK_PERIOD_MS);
}
}
void cjson_parse_xinzhi_weather(char *rdata)
{
cJSON *pJsonRoot = cJSON_Parse(rdata);
// 如果是json格式数据,则开始解析
if (pJsonRoot == NULL)
goto __fail_exit;
// 获取results数组内容
cJSON *pResults = cJSON_GetObjectItem(pJsonRoot, "results");
if (pResults == NULL)
goto __fail_exit;
cJSON *pObject = cJSON_GetArrayItem(pResults, 0); // 解析 results 数组的内容
if (pObject == NULL)
goto __fail_exit;
// 解析 results -> location 数组元素内容
cJSON *pLocation = cJSON_GetObjectItem(pObject, "location");
if (pLocation == NULL)
goto __fail_exit;
// 解析 results -> daily 数组元素内容
cJSON *pDaily = cJSON_GetObjectItem(pObject, "daily");
if (pDaily == NULL)
goto __fail_exit;
memset(&weather, 0, sizeof(weather_t));
// 获取城市
strcpy(weather.city, cJSON_GetObjectItem(pLocation, "name")->valuestring);
// 获取 daily 的数组长度(默认3天)
int daily_size = cJSON_GetArraySize(pDaily);
for (int i = 0; i < daily_size; i++)
{
cJSON *daily_elem = cJSON_GetArrayItem(pDaily, i); // 从daily数组从取第i个元素
if (daily_elem != NULL)
{
/* 为简化代码, 不考虑解析失败情况(这里解析失败概率极低) */
strcpy(weather.text_day[i], cJSON_GetObjectItem(daily_elem, "text_day")->valuestring);
strcpy(weather.text_night[i], cJSON_GetObjectItem(daily_elem, "text_night")->valuestring);
weather.code_day[i] = atoi(cJSON_GetObjectItem(daily_elem, "code_day")->valuestring); // 白天天气(代码)
weather.code_night[i] = atoi(cJSON_GetObjectItem(daily_elem, "code_night")->valuestring); // 晚上天气(代码)
weather.degree_high[i] = atoi(cJSON_GetObjectItem(daily_elem, "high")->valuestring); // 最高气温
weather.degree_low[i] = atoi(cJSON_GetObjectItem(daily_elem, "low")->valuestring); // 最低气温
weather.humidity[i] = atoi(cJSON_GetObjectItem(daily_elem, "humidity")->valuestring); // 湿度
}
}
xEventGroupSetBits(app_event_group, WEATHER_UPDATE_BIT); // 向oled任务发送天气更新事件
__fail_exit:
if (pJsonRoot != NULL)
cJSON_Delete(pJsonRoot); // 释放cJSON_Parse分配内存
默认每隔5分钟,以get方式向心知天气服务器发送请求,如果请求失败则会跳转到延时处等待,成功请求会调用编写好的CJSON函数解析返回的响应报文,获取城市、天气文本与代码,温湿度信息,然后向oled任务发送天气更新事件,最后断开连接,阻塞挂起等待,让出CPU资源。
- oled时间显示
static void draw_time(struct tm* time)
{
u8g2_SetFont(&u8g2, u8g2_font_t0_13_mr); //0610
snprintf(oled_buf, sizeof(oled_buf), "%4d-%02d-%02d", time->tm_year + 1900, time->tm_mon + 1, time->tm_mday);
u8g2_DrawStr(&u8g2, 0, 11, oled_buf);
snprintf(oled_buf, sizeof(oled_buf), "%02d:%02d:%02d", time->tm_hour, time->tm_min, time->tm_sec);
u8g2_DrawStr(&u8g2, 40, 26, oled_buf);
if (time->tm_hour == 0 && time->tm_min == 0 && time->tm_sec == 0) //& 0点星期发生变化, 消除字体叠影
clear_box(&u8g2, 80, 0, 128 - 80, 13);
snprintf(oled_buf, sizeof(oled_buf), "星期%s", week_map[time->tm_wday - 1]);
u8g2_SetFont(&u8g2, u8g2_my_font_chinese); //1316
u8g2_DrawUTF8(&u8g2, 80, 13, oled_buf);
}
oled显示,本人移植了u8g2库,首先设置数字显示字体为u8g2_font_t0_13_mr,然后向u8g2结构Buffer中写入数字字符串数据,再将字体切换为u8g2_my_font_chinese,显示星期信息,由于中文字库采用的是UTF-8编码,所有调用u8g2_DrawUTF8函数向Buffer写入数据。最后会在oled任务中将所有数据一次性发送。
- oled天气显示
static void draw_weather(uint8_t day)
{
uint8_t x_start = 25;
uint8_t y_start = 27;
uint8_t font_w = 16; // 字宽
uint8_t font_h = 13; // 字高
uint8_t weather_code;
clear_box(&u8g2, 0, 27, 128, 63 - 27);
if (dayOrNight == DAYTIME)
weather_code = weather.code_day[day];
else
weather_code = weather.code_night[day];
if (weather_code >= 40)
weather_code = 39; // 大于40默认未知
// 城白天/夜晚图标
u8g2_SetFont(&u8g2, u8g2_font_open_iconic_weather_2x_t);
u8g2_DrawGlyph(&u8g2, 127 - 20, 32, dayOrNight);
// 城市
u8g2_SetFont(&u8g2, u8g2_my_font_chinese);
u8g2_DrawUTF8(&u8g2, 0, 25, weather.city);
// 天气图标
u8g2_DrawXBM(&u8g2, 0, 33, 22, 22, xinzhi_weather[weather_code]);
// 天气文本
for (size_t i = 0; i < strlen(weather_map[weather_code]) / 3; i++)
{
char weather_str[4];
memset(weather_str, '\0', sizeof(weather_str));
strncpy(weather_str, weather_map[weather_code] + 3 * i, 3);
if ((strlen(weather_map[weather_code]) & 0x01) && (i == strlen(weather_map[weather_code]) / 3 - 1)) { // 奇数且最后一个字符居中
u8g2_DrawUTF8(&u8g2, x_start + i / 2 * font_w, y_start + font_h + 10, weather_str);
}else{
u8g2_DrawUTF8(&u8g2, x_start + i / 2 * font_w,
i & 0x01 ? y_start + font_h * 2 + 3 : y_start + font_h, weather_str);
}
}
// 温度
u8g2_SetFont(&u8g2, u8g2_font_t0_13_mr);
snprintf(oled_buf, sizeof(oled_buf), "%d", weather.degree_low[day]);
u8g2_DrawStr(&u8g2, 127 - 18, y_start + 16, oled_buf);
u8g2_DrawHLine(&u8g2, 127 - 18, y_start + 16 + 4, 18);
snprintf(oled_buf, sizeof(oled_buf), "%d", weather.degree_high[day]);
u8g2_DrawStr(&u8g2, 127 - 18, y_start + 16 + 17, oled_buf);
//湿度
snprintf(oled_buf, sizeof(oled_buf), "%d%%", weather.humidity[day]);
u8g2_DrawStr(&u8g2, 127 - 18 - 22, y_start + 16 + 9, oled_buf);
// 底部界面图标
if (day == 0){
u8g2_DrawDisc(&u8g2, 64 - 20, 63 - 2, 2, U8G2_DRAW_ALL);
}else{
u8g2_DrawCircle(&u8g2, 64 - 20, 63 - 2, 2, U8G2_DRAW_ALL);
}
if (day == 1){
u8g2_DrawDisc(&u8g2, 64, 63 - 2, 2, U8G2_DRAW_ALL);
}else{
u8g2_DrawCircle(&u8g2, 64, 63 - 2, 2, U8G2_DRAW_ALL);
}
if (day == 2){
u8g2_DrawDisc(&u8g2, 64 + 20, 63 - 2, 2, U8G2_DRAW_ALL);
}else{
u8g2_DrawCircle(&u8g2, 64 + 20, 63 - 2, 2, U8G2_DRAW_ALL);
}
}static void draw_weather(uint8_t day)
{
uint8_t x_start = 25;
uint8_t y_start = 27;
uint8_t font_w = 16; // 字宽
uint8_t font_h = 13; // 字高
uint8_t weather_code;
clear_box(&u8g2, 0, 27, 128, 63 - 27);
if (dayOrNight == DAYTIME)
weather_code = weather.code_day[day];
else
weather_code = weather.code_night[day];
if (weather_code >= 40)
weather_code = 39; // 大于40默认未知
// 城白天/夜晚图标
u8g2_SetFont(&u8g2, u8g2_font_open_iconic_weather_2x_t);
u8g2_DrawGlyph(&u8g2, 127 - 20, 32, dayOrNight);
// 城市
u8g2_SetFont(&u8g2, u8g2_my_font_chinese);
u8g2_DrawUTF8(&u8g2, 0, 25, weather.city);
// 天气图标
u8g2_DrawXBM(&u8g2, 0, 33, 22, 22, xinzhi_weather[weather_code]);
// 天气文本
for (size_t i = 0; i < strlen(weather_map[weather_code]) / 3; i++)
{
char weather_str[4];
memset(weather_str, '\0', sizeof(weather_str));
strncpy(weather_str, weather_map[weather_code] + 3 * i, 3);
if ((strlen(weather_map[weather_code]) & 0x01) && (i == strlen(weather_map[weather_code]) / 3 - 1)) { // 奇数且最后一个字符居中
u8g2_DrawUTF8(&u8g2, x_start + i / 2 * font_w, y_start + font_h + 10, weather_str);
}else{
u8g2_DrawUTF8(&u8g2, x_start + i / 2 * font_w,
i & 0x01 ? y_start + font_h * 2 + 3 : y_start + font_h, weather_str);
}
}
// 温度
u8g2_SetFont(&u8g2, u8g2_font_t0_13_mr);
snprintf(oled_buf, sizeof(oled_buf), "%d", weather.degree_low[day]);
u8g2_DrawStr(&u8g2, 127 - 18, y_start + 16, oled_buf);
u8g2_DrawHLine(&u8g2, 127 - 18, y_start + 16 + 4, 18);
snprintf(oled_buf, sizeof(oled_buf), "%d", weather.degree_high[day]);
u8g2_DrawStr(&u8g2, 127 - 18, y_start + 16 + 17, oled_buf);
//湿度
snprintf(oled_buf, sizeof(oled_buf), "%d%%", weather.humidity[day]);
u8g2_DrawStr(&u8g2, 127 - 18 - 22, y_start + 16 + 9, oled_buf);
// 底部界面图标
if (day == 0){
u8g2_DrawDisc(&u8g2, 64 - 20, 63 - 2, 2, U8G2_DRAW_ALL);
}else{
u8g2_DrawCircle(&u8g2, 64 - 20, 63 - 2, 2, U8G2_DRAW_ALL);
}
if (day == 1){
u8g2_DrawDisc(&u8g2, 64, 63 - 2, 2, U8G2_DRAW_ALL);
}else{
u8g2_DrawCircle(&u8g2, 64, 63 - 2, 2, U8G2_DRAW_ALL);
}
if (day == 2){
u8g2_DrawDisc(&u8g2, 64 + 20, 63 - 2, 2, U8G2_DRAW_ALL);
}else{
u8g2_DrawCircle(&u8g2, 64 + 20, 63 - 2, 2, U8G2_DRAW_ALL);
}
}
调用图标字库显示白天/夜晚图标,然后显示城市、天气图标与文本信息,这里的天气图标是本人从心知天气官网下载的天气图标,然后转成bmp图片,利用工具制作而成,天气文本共有两种方案,第一种是直接解析中文天气文本,然后进行显示;第二种是利用天气文本代码,在代码建立映射图表进行显示;这里采用的是第二种方案。接下来就是显示温湿度、底部界面图标。
遇到的主要难题及解决方法
- cmake的配置问题
由于很少接触Cmake,不太了解Cmake语法,导入前期项目受阻,查阅了乐鑫手册、csdn相关博客、github相关例程,初步了解了Cmake语法,得以解决该问题,项目成功推进。
- u8g2的移植
之前使用arduino框架开发,可以直接调用u8g2库,这次切换到idf框架,需要手动对其进行移植,前期由于某个引脚配置出错,导入初始化失败,后面去github查看相关资料才得以解决。
- CJSON释放后, 指向解析天气文本字符串的指针得到错误数据
这个问题一开始没有意识到,还以为是编码的问题,由于idf不可以断点调试,只能用printf函数一步步打印调试,最终锁定时CJSON根节点释放后导致的异常,然后对相关代码分析找出了问题,最后采用字符数组接收数据,问题得以解决。
未来计划
完善本次项目,优化UI界面,显示更多气象数据,同时使界面看起来更为美观;
探索开发板音频相关模块,完成语音识别与音乐播放功能。