一、项目简介
本项目是基于ESP32S3-BOX-Lite的warframe助手,开机连上网后会自动对时,并可以根据需要获取游戏内的相关信息。目前设计的功能包含:当前游戏内各个开放世界状态的轮换时间、有无警报、日常/精英突击的任务类型、仲裁任务的类型与持续时间、入侵任务、虚空裂隙任务、每日特惠、虚空商人信息、午夜电波。这些功能相互独立,可以根据需要主动刷新。
二、硬件方案
本项目使用的硬件平台为官方的ESP32S3-BOX-Lite开发板,该开发板是乐鑫发布的新一代 AIoT 开发平台。该 开发套件配备了一块2.4寸LCD显示屏、双麦克风、一个扬声器、两个用于硬件拓展的 Pmod™ 兼容接口和3个独立按键,可构建多样的HMI人机交互应用。开发板可实现离线语音唤醒和命令词识别,支持乐鑫自研的高性能声学前端算法构建语音交互系统。开发者可利用开源的 SDK轻松构建在线离线语音助手、智能语音设备、HMI人机交互设备、多协议网关等多样的应用。该开发板的主控核心控制器为ESP32S3,该控制器是一款集成了Wi-Fi和 BLE 的 MCU 芯片,可以通过蓝牙进行配网。ESP32-S3内搭载了Xtensa® 32 位 LX7 双核处理器,主频高达 240 MHz,内置 512 KB SRAM、384 KB ROM 存储空间,并支持多个外部 SPI、Dual SPI、 Quad SPI、Octal SPI用于外扩ROM和RAM。并且额外增加了用于加速神经网络计算和信号处理等工作的向量指令 (vector instructions)。ESP32S3具有45 个可编程 GPIO,支持常用外设接口如 SPI、I2S、I2C、PWM、RMT、ADC、UART、SD/MMC 主机控制器和 TWAITM 控制器等。
本项目主要使用到了开发板上的2.4寸LCD显示屏、按键和WIFI模块。起初是准备使用BLE模块与WIFI模块搭配起来使用的,这样可以使用手机进行WIFI配网了,但是使用官方demo测试时发现蓝牙配网一直无法成功配网,最终只能将WIFI的名称和密码写在代码里,直接开机就直接联网了。
三、软件设计方案
本项目是使用官方提供的开源SDK(ESPIDF)开发的,SDK已经帮我们移植好了Freertos,并且会启动创建一个app_main线程。这样我们开发的时候需要续实现app_main,或者通过app_main去创建我们自己的应用线程。官方BSP也帮我们移植好了LVGL,这样我们只需要关心软件逻辑实现即可。
本项目软件主要可分为三个部分:GUI部分、wifi连接、HTTP请求与解析。
1、GUI部分
GUI部分总共实现了十多个子模块页面,包括初始登录界面、主菜单页面以及11个功能页面。这些页面的初始化可以使用LVGL官方的GUI辅助工具SquareLine Studio生成,可以减少一些开发量。
2、wifi连接
wifi连接部分使用SDK内置的wifi连接API可以轻松的连接上。BSP的例子里边是使用LCD屏幕显示二维码进行蓝牙连接然后再使用手机进行蓝牙配网的,但我测试的时候一直报错,所以最终只能把wifi名与密码写死。虽然在wifi配置界面里有修改选项,但由于Lite版本没有触摸屏,导致无法使用虚拟键盘,只可以修改配置成默认配置“xiaomi”,“12345678”并重连。
3、HTTP请求与解析
HTTP请求可以参考SDK里的网络相关的例子,ESP-IDF官方文档里也有相关API说明,使用起来也是比较简单,前提是得先连上网。请求数据的解析则是使用cJSON完成。
四、主要代码片段与说明
1、主函数
这里是程序的开始部分,app_main线程是SDK中已经嵌入进去了的,所以我们只需吧自己的程序放在这里并启动自己的线程就可以了。我们这里主要是对我们需要使用的资源进行初始化、调用POSIX接口设置本地时区,并创建LVGL的刷新线程,为LVGL提供心跳,这样我们就可以自由使用LVGL了
static void lvgl_task(void *pvParam)
{
(void) pvParam;
g_guisemaphore = xSemaphoreCreateMutex();
do {
/* Try to take the semaphore, call lvgl related function on success */
if (pdTRUE == xSemaphoreTake(g_guisemaphore, portMAX_DELAY)) {
lv_task_handler();
xSemaphoreGive(g_guisemaphore);
}
vTaskDelay(pdMS_TO_TICKS(10));
} while (true);
vTaskDelete(NULL);
}
void app_main(void)
{
ESP_LOGI(TAG, "Compile time: %s %s", __DATE__, __TIME__);
//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);
ESP_ERROR_CHECK(bsp_board_init());
ESP_ERROR_CHECK(bsp_board_power_ctrl(POWER_MODULE_AUDIO, true));
ESP_ERROR_CHECK(lv_port_init());
BaseType_t ret_val = xTaskCreatePinnedToCore(lvgl_task, "lvgl_Task", 6 * 1024, NULL, configMAX_PRIORITIES - 3, &g_lvgl_task_handle, 0);
ESP_ERROR_CHECK((pdPASS == ret_val) ? ESP_OK : ESP_FAIL);
bsp_lcd_set_backlight(true); // Turn on the backlight after gui initialize
ui_init();
app_wifi_start();
setenv("TZ", "CST-8", 1);//设置为中国时区
tzset();
}
2、GUI部分
这里展示了主菜单部分的代码。主菜单部分主要是完成菜单按钮的创建、状态条的创建与刷新,以及根据按键选择跳转到相应的子项目页面,并在跳转后将按键输入源切换到相应的子界面上。
static void ui_status_bar_set_visible(bool visible)
{
if (visible) {
lv_obj_clear_flag(g_status_bar, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(g_status_bar, LV_OBJ_FLAG_HIDDEN);
}
}
lv_obj_t *ui_main_get_status_bar(void)
{
return g_status_bar;
}
void ui_main_status_bar_set_wifi(bool is_connected)
{
lv_label_set_text_static(g_lab_wifi, is_connected ? LV_SYMBOL_WIFI : "--");
}
static void label_time_handler(lv_timer_t *timer)
{
char time_str[8];
time_t time_val;
lv_obj_t *label = timer->user_data;
time(&time_val);
struct tm time;
localtime_r(&time_val, &time);
sprintf(time_str, "%02d:%02d", time.tm_hour, time.tm_min);
lv_label_set_text(label, time_str);
}
void ui_main_screen_init(void)
{
ui_main = lv_obj_create(NULL);
lv_obj_clear_flag(ui_main, LV_OBJ_FLAG_SCROLLABLE); /// Flags
lv_obj_set_style_bg_img_src(ui_main, &ui_img_warframe0008_png, LV_PART_MAIN | LV_STATE_DEFAULT);
g_main_indev_group = lv_group_create();
lv_indev_set_group(indev, g_main_indev_group);
label_app_name = lv_label_create(ui_main);
lv_obj_align(label_app_name, LV_ALIGN_BOTTOM_MID, 0, -24);
lv_obj_set_style_text_color(label_app_name,lv_color_make(255,0,0),LV_PART_MAIN);
lv_obj_set_style_text_font(label_app_name,&font_cn_gb1_28,LV_PART_MAIN);
lv_label_set_text(label_app_name,app_name[0]);
container = lv_obj_create(ui_main);
lv_obj_set_size(container, LV_PCT(100), LV_PCT(100));
lv_obj_set_scrollbar_mode(container, LV_SCROLLBAR_MODE_OFF);
lv_obj_set_flex_flow(container, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(container, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_scroll_snap_x(container, LV_SCROLL_SNAP_CENTER);
lv_obj_set_style_bg_opa(container, LV_OPA_0, LV_PART_MAIN);
lv_obj_set_style_bg_color(container, lv_color_black(), LV_PART_MAIN);
lv_obj_set_style_border_width(container, 0, LV_PART_MAIN);
lv_obj_set_style_pad_column(container, 40, LV_PART_MAIN); //图标之间的间隙
lv_obj_center(container);
//生成演示按钮
for (int i = 0; i < MAX_APP_NUM; i++)
{
lv_obj_t* btn = lv_btn_create(container);
lv_obj_set_size(btn, 80, 80);
lv_obj_set_style_bg_img_src(btn,btn_image[i],LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_add_event_cb(btn, button_event_cb, LV_EVENT_ALL, NULL);//只注册获取到焦点、按下的消息LV_EVENT_FOCUSED
//lv_obj_add_event_cb(btn, button_event_cb, LV_EVENT_PRESSED, NULL);
lv_group_add_obj(g_main_indev_group, btn);
}
lv_obj_scroll_to_view(lv_obj_get_child(container, 0), LV_ANIM_ON);
lv_obj_set_size(lv_obj_get_child(container, 1), 64, 64);
lv_obj_set_style_bg_opa(lv_obj_get_child(container, 1), 100, LV_PART_MAIN);
//Create status bar
g_status_bar = lv_obj_create(ui_main);
lv_obj_set_size(g_status_bar, lv_obj_get_width(lv_obj_get_parent(g_status_bar)), MAIN_UI_STATUS_BAR_H);
lv_obj_clear_flag(g_status_bar, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_radius(g_status_bar, 0, LV_STATE_DEFAULT);
lv_obj_set_style_bg_color(g_status_bar, lv_color_make(200, 200, 200), LV_PART_MAIN);
lv_obj_set_style_bg_opa(g_status_bar,100, LV_PART_MAIN);
lv_obj_set_style_border_width(g_status_bar, 0, LV_PART_MAIN);
lv_obj_set_style_shadow_width(g_status_bar, 0, LV_PART_MAIN);
lv_obj_align(g_status_bar, LV_ALIGN_TOP_LEFT, 0, 0);
g_lab_time = lv_label_create(g_status_bar);
lv_label_set_text(g_lab_time, "00:00");
lv_obj_align(g_lab_time, LV_ALIGN_LEFT_MID, 0, 0);
lv_timer_create(label_time_handler, 1000, g_lab_time);
g_lab_wifi = lv_label_create(g_status_bar);
if (app_wifi_is_connected()) {
ui_main_status_bar_set_wifi(1);
} else {
ui_main_status_bar_set_wifi(0);
}
lv_obj_align_to(g_lab_wifi, g_lab_time, LV_ALIGN_OUT_RIGHT_MID, 10, 0);
ui_status_bar_set_visible(1);
}
/**
* @brief 处理按钮事件的回调函数
* @param event
*/
static void button_event_cb(lv_event_t* event)
{
lv_obj_t* current_btn = lv_event_get_current_target(event);
uint32_t current_btn_index = lv_obj_get_index(current_btn);
uint32_t btn_cnt = lv_obj_get_child_cnt(container);
if (event->code == LV_EVENT_FOCUSED)
{
ESP_LOGI(TAG, "BTN FOCUSED, current btn index = %d, btn cnt=%d",current_btn_index,btn_cnt);
lv_obj_set_size(current_btn, 90, 90);
lv_obj_set_style_bg_opa(current_btn, 255, LV_PART_MAIN);
lv_label_set_text(label_app_name,app_name[current_btn_index]);
//lv_label_set_text_fmt(app_name, "应用:%d", current_btn_index);
if(current_btn_index==0){
if(btn_cnt!=1){
lv_obj_set_size(lv_obj_get_child(container, 1), 64, 64);
lv_obj_set_style_bg_opa(lv_obj_get_child(container, 1), 100, LV_PART_MAIN);
}
}
else if(current_btn_index==MAX_APP_NUM-1){
lv_obj_set_size(lv_obj_get_child(container, current_btn_index-1), 64, 64);
lv_obj_set_style_bg_opa(lv_obj_get_child(container, current_btn_index - 1), 100, LV_PART_MAIN);
}
else{
lv_obj_set_size(lv_obj_get_child(container, current_btn_index - 1), 64, 64);
lv_obj_set_size(lv_obj_get_child(container, current_btn_index + 1), 64, 64);
lv_obj_set_style_bg_opa(lv_obj_get_child(container, current_btn_index - 1), 100, LV_PART_MAIN);
lv_obj_set_style_bg_opa(lv_obj_get_child(container, current_btn_index + 1), 100, LV_PART_MAIN);
}
}
else if (event->code == LV_EVENT_PRESSED)
{
/*启动相应任务*/
ESP_LOGI(TAG, "BTN PRESSED, current btn index = %d",current_btn_index);
_ui_screen_change(ui_array[current_btn_index],LV_SCR_LOAD_ANIM_OVER_LEFT,500,0);
lv_indev_set_group(indev, ui_indev_group_array[current_btn_index]);
}
}
3、wifi连接
这里展示了开机自动联网以及后续重新联网的代码,实现方式比较简单,目前重连的时候会偶尔出现BUG,需要后续优化一下。
wifi_config_t wifi_config = {
.sta = {
.ssid = EXAMPLE_ESP_WIFI_SSID,
.password = EXAMPLE_ESP_WIFI_PASS,
/* Setting a password implies station will connect to all security modes including WEP/WPA.
* However these modes are deprecated and not advisable to be used. Incase your Access point
* doesn't support WPA2, these mode can be enabled by commenting below line */
.threshold.authmode = ESP_WIFI_SCAN_AUTH_MODE_THRESHOLD,
.sae_pwe_h2e = WPA3_SAE_PWE_BOTH,
},
};
void time_sync_notification_cb(struct timeval *tv)
{
ESP_LOGI(TAG, "Notification of a time synchronization event");
}
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) {
esp_wifi_connect();
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
if (s_retry_num < EXAMPLE_ESP_MAXIMUM_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");
if(s_sntp_init){
sntp_stop();
s_sntp_init=false;
}
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_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);
}
}
void wifi_sntp_start(void)
{
ESP_LOGI(TAG, "Initializing SNTP");
sntp_setoperatingmode(SNTP_OPMODE_POLL);
sntp_setservername(0, "pool.ntp.org");
sntp_set_time_sync_notification_cb(time_sync_notification_cb);
sntp_init();
s_sntp_init = true;
}
void wifi_task(void)
{
s_wifi_event_group = xEventGroupCreate();
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_create_default_wifi_sta();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
esp_event_handler_instance_t instance_any_id;
esp_event_handler_instance_t instance_got_ip;
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));
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA) );
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config) );
ESP_ERROR_CHECK(esp_wifi_start() );
ESP_LOGI(TAG, "wifi_init_sta finished.");
while(1)
{
EventBits_t bits = xEventGroupWaitBits(s_wifi_event_group,
WIFI_CONNECTED_BIT | WIFI_FAIL_BIT,
pdFALSE,
pdFALSE,
portMAX_DELAY);
if (bits & WIFI_CONNECTED_BIT) {
s_connected=true;
ESP_LOGI(TAG, "connected to ap SSID:%s", wifi_config.sta.ssid);
ui_main_status_bar_set_wifi(true);
wifi_sntp_start();
xEventGroupClearBits(s_wifi_event_group,WIFI_CONNECTED_BIT);
} else if (bits & WIFI_FAIL_BIT) {
ESP_LOGI(TAG, "Failed to connect to SSID:%s", wifi_config.sta.ssid);
s_connected=false;
ui_main_status_bar_set_wifi(false);
xEventGroupClearBits(s_wifi_event_group,WIFI_FAIL_BIT);
} else {
ESP_LOGE(TAG, "UNEXPECTED EVENT");
}
}
}
bool app_wifi_is_connected(void)
{
return s_connected;
}
void app_wifi_reconnect(uint8_t *ssid, uint8_t *password)
{
esp_wifi_stop();
esp_wifi_disconnect();
memset(wifi_config.sta.ssid,0,32);
memset(wifi_config.sta.password,0,64);
memcpy(wifi_config.sta.ssid,ssid,strlen((const char*)ssid));
memcpy(wifi_config.sta.password,password,strlen((const char*)password));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config) );
ESP_ERROR_CHECK(esp_wifi_start() );
ESP_LOGI(TAG, "wifi reconnecting...");
}
void app_wifi_start(void)
{
TaskHandle_t g_wifi_task_handle;
BaseType_t ret_val = xTaskCreatePinnedToCore(wifi_task, "wifi_task", 4 * 1024, NULL, configMAX_PRIORITIES - 3, &g_wifi_task_handle, 0);
ESP_ERROR_CHECK((pdPASS == ret_val) ? ESP_OK : ESP_FAIL);
}
4、HTTP请求
这里是一种可以快速通过HTTP GET请求数据的代码,这种方法是阻塞式的,最好是新建一个线程,然后每次需要请求数据的时候通过信号激活线程,避免主线程一直占用CPU,可以使界面更加流畅
esp_err_t _http_event_handler(esp_http_client_event_t *evt)
{
static char *output_buffer; // Buffer to store response of http request from event handler
static int output_len; // Stores number of bytes read
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;
case HTTP_EVENT_ON_DATA:
ESP_LOGD(TAG, "HTTP_EVENT_ON_DATA, len=%d", evt->data_len);
/*
* Check for chunked encoding is added as the URL for chunked encoding used in this example returns binary data.
* However, event handler can also be used in case chunked encoding is used.
*/
if (!esp_http_client_is_chunked_response(evt->client)) {
// If user_data buffer is configured, copy the response into the buffer
if (evt->user_data) {
memcpy(evt->user_data + output_len, evt->data, evt->data_len);
} else {
if (output_buffer == NULL) {
output_buffer = (char *) malloc(esp_http_client_get_content_length(evt->client));
output_len = 0;
if (output_buffer == NULL) {
ESP_LOGE(TAG, "Failed to allocate memory for output buffer");
return ESP_FAIL;
}
}
memcpy(output_buffer + output_len, evt->data, evt->data_len);
}
output_len += evt->data_len;
}
break;
case HTTP_EVENT_ON_FINISH:
ESP_LOGD(TAG, "HTTP_EVENT_ON_FINISH");
if (output_buffer != NULL) {
// Response is accumulated in output_buffer. Uncomment the below line to print the accumulated response
// ESP_LOG_BUFFER_HEX(TAG, output_buffer, output_len);
free(output_buffer);
output_buffer = NULL;
}
output_len = 0;
break;
case HTTP_EVENT_DISCONNECTED:
ESP_LOGI(TAG, "HTTP_EVENT_DISCONNECTED");
int mbedtls_err = 0;
esp_err_t err = esp_tls_get_and_clear_last_error(evt->data, &mbedtls_err, NULL);
if (err != 0) {
ESP_LOGI(TAG, "Last esp error code: 0x%x", err);
ESP_LOGI(TAG, "Last mbedtls failure: 0x%x", mbedtls_err);
}
if (output_buffer != NULL) {
free(output_buffer);
output_buffer = NULL;
}
output_len = 0;
break;
}
return ESP_OK;
}
bool app_http_get(char* path, char *result)
{
/**
* NOTE: All the configuration parameters for http_client must be spefied either in URL or as host and path parameters.
* If host and path parameters are not set, query parameter will be ignored. In such cases,
* query parameter should be specified in URL.
*
* If URL as well as host and path parameters are specified, values of host and path will be considered.
*/
esp_http_client_config_t config = {
.event_handler = _http_event_handler,
};
char url[64]={0};
snprintf(url,64,"http://api.warframestat.us/pc/%s",path);
config.url = url;
config.user_data = result;// Pass address of local buffer to get response
esp_http_client_handle_t client = esp_http_client_init(&config);
// GET
esp_err_t err = esp_http_client_perform(client);
if (err == ESP_OK) {
ESP_LOGI(TAG, "HTTP GET Status = %d, content_length = %d",
esp_http_client_get_status_code(client),
esp_http_client_get_content_length(client));
} else {
ESP_LOGE(TAG, "HTTP GET request failed: %s", esp_err_to_name(err));
}
//ESP_LOG_BUFFER_HEX(TAG, local_response_buffer, strlen(local_response_buffer));
esp_http_client_cleanup(client);
if(err == ESP_OK)return true;
else return false;
}
五、主要功能展示
1、开机动画
2、主功能菜单,可以通过左右两边按键来切换选择功能
3、wifi配置界面
4、已完成的功能界面
以下是已完成的一些功能页面,包括游戏内各个开放世界状态的轮换时间、有无警报、日常/精英突击的任务类型、仲裁任务的类型与持续时间、每日特惠、虚空商人信息等功能页面
5、待完成的功能界面
以下是几个待完成的功能页面,主要是这几个功能数据量较大,且比较复杂。待以后完善
六、总结
总的来说能参加这次活动还是很开心的,我本人挺喜欢GUI方面的开发的,这款开发板不仅移植好了LVGL,还外扩了16M的flash和8MRAM,简直不要太爽,完美符合我的需求。
附源码下载链接: Ordis