一、设计目标
1.RP2040 Game Kit板通过提供的ESP32-S2的WiFi模块连接网络。
2.在RP2040 Game Kit上显示某一个城市的气象信息 - 时间、天气实况、生活指数、天气预报...
3.通过RP2040 Game Kit上的按键和四向摇杆配合能够切换显示不同城市的信息 ,做到能切换显示、刷新数据、修改城市名。
4. 通过显示屏与摇杆按键交互来模拟一个九键键盘,实现城市名的自主输入,输入错误也会有错误提示。
5. 搭配上图片来丰富显示内容,包括天气气象符号、各生活指数示意图等。
二、准备工作
1、硬件连接
Rp2040游戏机与esp32-s2模块的连线如下图所示。
2. 开发环境
(1)thonny。安装过程具体可参考 https://class.eetree.cn/live_pc/l_60fe7f4fe4b0a27d0e360f74
(2) Vscode的插件Espressif IDF v1.3.0。
3. 参考例程
(1)ESP32 IDF v4.3.1:乐鑫ESP开发环境,本项目参考了其中的http request,uart,wifi station 例程。具体可参考官方文档ESP-IDF 编程指南。
(2)硬禾学堂2022寒假在家练:基于树莓派RP2040的嵌入式系统学习平台,相关内容可参考https://www.eetree.cn/project/detail/698
4.源代码目录结构
(1)Rp2040
-/
-weather_main.py 主函数
-draw.py 画图部分
-http_deal.py http数据处理部分
-location.py 键盘键位内容
-button.py 按键
-board.py 引脚定义
-vga2_8x8.py字体小
-vga1_16x32.py字体大
-vga1_8x16.py 字体中
-weather_picture_small/ 天气现象图片(小)
-weather_picture_big/ 天气现象图片(大)
-index of living/ 生活指数插图
(2)ESP32-S2
5.使用说明
(1)先将wifi_name和wifi_passwd分别修改成要连接的热点的名字和密码。
(2)分别编译下载程序到pico和esp32s2(esp32s2可用vscode),具体可参照上面的源代码目录结构。
(3)使用杜邦线进行硬件连接,具体连线请参照上面的硬件连接。
(4)上电开机。
6.注意事项
(1)部分wifi可能不能被esp32识别。
(2)使用过程中请保持网络顺畅,若失去网络连接或产生一些其它错误,可以试着先按下esp32的reset键重启,再运行RP2040的主程序。
(3)由于使用心知天气平台的免费版,暂时只支持国内部分城市。
三、软件流程图
四、实现过程
1、网络连接
(1)WiFi连接
wifi名和密码需提前设定,具体在RP2040的weather_main.py中修改,如下。
# 在此处修改你要连接的wifi名和密码
wifi_name = "123"
wifi_passwd = "12345678"
发送wifi信息给esp32前需进行简单编码以供esp32识别,具体请参考下面的多机通信部分。
在esp32接收到信息后立即调用wifi_init_sta()函数进行wifi连接,这里是在esp32idf的例程 ~\Espressif\frameworks\esp-idf-v4.4.1\examples\wifi\getting_started\station 的基础上修改的,具体如下。
/**********wifi初始化函数**************/
void wifi_init_sta( char *wifi_ssid , char *wifi_password)
{
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));
wifi_config_t wifi_config = {
.sta = {
// .ssid = wifi_ssid ,
// .password = wifi_password ,
/* 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 = WIFI_AUTH_WPA2_PSK,
},
};
memcpy(wifi_config.sta.ssid, wifi_ssid, sizeof(wifi_config.sta.ssid));
memcpy(wifi_config.sta.password, wifi_password, sizeof(wifi_config.sta.password));
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.");
/* Waiting until either the connection is established (WIFI_CONNECTED_BIT) or connection failed for the maximum
* number of re-tries (WIFI_FAIL_BIT). The bits are set by event_handler() (see above) */
EventBits_t bits = xEventGroupWaitBits(s_wifi_event_group,
WIFI_CONNECTED_BIT | WIFI_FAIL_BIT,
pdFALSE,
pdFALSE,
portMAX_DELAY);
/* xEventGroupWaitBits() returns the bits before the call returned, hence we can test which event actually
* happened. */
if (bits & WIFI_CONNECTED_BIT) {
ESP_LOGI(TAG, "connected to ap SSID:%s password:%s",
wifi_ssid, wifi_password);
http_get_task(); //连接成功,发送http请求
// sendData(TAG,"connectsucess");
} else if (bits & WIFI_FAIL_BIT) {
ESP_LOGI(TAG, "Failed to connect to SSID:%s, password:%s",
wifi_ssid, wifi_password);
sendData(TAG,"Connectfail"); //连接失败,发送状态告知pico
} else {
ESP_LOGE(TAG, "UNEXPECTED EVENT");
sendData(TAG,"Connectfail"); //连接失败,发送状态告知pico
}
/* The event will not be processed after unregister */
ESP_ERROR_CHECK(esp_event_handler_instance_unregister(IP_EVENT, IP_EVENT_STA_GOT_IP, instance_got_ip));
ESP_ERROR_CHECK(esp_event_handler_instance_unregister(WIFI_EVENT, ESP_EVENT_ANY_ID, instance_any_id));
vEventGroupDelete(s_wifi_event_group);
}
wifi连接成功后就立即发送http请求,失败则返回状态给RP2040。
(2)http请求
这里参考了esp32idf的例程~\Espressif\frameworks\esp-idf-v4.4.1\examples\protocols\http_request,将其中的循环任务改成了单次调动并根据不同的请求内容增加了参数判断,就能根据需要进行http请求,并在发生错误时发送状态给RP2040,具体内容在http_main.c中,如下。
//HTTP请求函数
void http_get(char arg)
{
const struct addrinfo hints = {
.ai_family = AF_INET,
.ai_socktype = SOCK_STREAM,
};
struct addrinfo *res;
struct in_addr *addr;
int s, r;
char recv_buf[64];
char mid_buf[1400]; //接受http报文正文部分
memset(mid_buf,0,sizeof(mid_buf));
char WEB_PATH[200] = "GET " ;
// 组合字段构成http请求的发送内容,根据不同的请求进行不同的组合
switch (arg){
//实时天气,例:http://api.seniverse.com/v3/weather/now.json?key=your_api_key&location=beijing&language=en&unit=c
case WEATHER_CURRENT:
strcat(WEB_PATH,WEB_PATH_CURRENT_1);
strcat(WEB_PATH,reqLocation);
strcat(WEB_PATH,WEB_PATH_CURRENT_2);
strcat(WEB_PATH,REQUEST_ED);
break;
//生活指数,例:http://api.seniverse.com/v3/life/suggestion.json?key=SzOM2PDJp7crLA0Ug&location=haikou&language=en
case WEATHER_LIFE:
strcat(WEB_PATH,WEB_PATH_LIFE_1);
strcat(WEB_PATH,reqLocation);
strcat(WEB_PATH,WEB_PATH_LIFE_2);
strcat(WEB_PATH,REQUEST_ED);
break;
//天气预报,例:http://api.seniverse.com/v3/weather/daily.json?key=your_api_key&location=beijing&language=zh-Hans&unit=c&start=0&days=5
case WEATHER_FORECAST:
strcat(WEB_PATH,WEB_PATH_FORECAST_1);
strcat(WEB_PATH,reqLocation);
strcat(WEB_PATH,WEB_PATH_FORECAST_2);
strcat(WEB_PATH,REQUEST_ED);
break;
default:ESP_LOGI(TAG, "wrong");
}
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);
sendData(TAG,"httprequestfail"); //http初始化失败,告知pico
}else {
/* 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);
sendData(TAG,"httprequestfail"); //http初始化失败,告知pico
}else{
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);
sendData(TAG,"httprequestfail"); //http初始化失败,告知pico
}else{
ESP_LOGI(TAG, "... connected");
freeaddrinfo(res);
if (write(s, WEB_PATH, strlen(WEB_PATH)) < 0) {
ESP_LOGE(TAG, "... socket send failed");
close(s);
vTaskDelay(4000 / portTICK_PERIOD_MS);
sendData(TAG,"httprequestfail"); //http初始化失败,告知pico
}else{
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);
sendData(TAG,"httprequestfail"); //http初始化失败,告知pico
}else{
ESP_LOGI(TAG, "... set socket receiving timeout success");
/* Read HTTP response */
do {
bzero(recv_buf, sizeof(recv_buf));
r = read(s, recv_buf, sizeof(recv_buf)-1);
strcat(mid_buf,recv_buf);
for(int i = 0; i < r; i++) {
putchar(recv_buf[i]);
}
} while(r > 0);
// ESP_LOGI(TAG,"return=%s",mid_buf);
//json格式转化
cjson_to_struct_info(mid_buf,arg);
ESP_LOGI(TAG, "... done reading from socket. Last read return=%d errno=%d.", r, errno);
close(s);
}
}
}
}
}
}
由于要请求的内容有三项(天气实况、生活指数、天气预报),分别对应三个不同的请求行:
void http_get_task(void)
{
memset(send_data_quene,0,sizeof(send_data_quene));
http_get(WEATHER_CURRENT); //天气实况
vTaskDelay(1000 / portTICK_PERIOD_MS); //适当延时
http_get(WEATHER_FORECAST); //天气预报
vTaskDelay(1000 / portTICK_PERIOD_MS);
http_get(WEATHER_LIFE); //生活指数
vTaskDelay(1000 / portTICK_PERIOD_MS);
ESP_LOGI(TAG,"send_data:%s",send_data_quene);
sendData(TAG,send_data_quene);//整合发送
}
#define WEB_SERVER "api.seniverse.com"
#define WEB_PORT "80"
#define reqUserKey "SzOM2PDJp7crLA0Ug"
// #define reqLocation "Shenzhen"
#define reqUnit "c"
//天气实况
#define WEATHER_CURRENT 'C'
#define WEB_PATH_CURRENT_1 "/v3/weather/now.json?key=" reqUserKey "&location="
#define WEB_PATH_CURRENT_2 "&language=en&unit=" reqUnit
//生活指数
#define WEATHER_LIFE 'L'
#define WEB_PATH_LIFE_1 "/v3/life/suggestion.json?key=" reqUserKey "&location="
#define WEB_PATH_LIFE_2 "&language=en"
//天气预报
#define WEATHER_FORECAST 'F'
#define WEB_PATH_FORECAST_1 "/v3/weather/daily.json?key=" reqUserKey "&location="
#define WEB_PATH_FORECAST_2 "&language=en&unit=" reqUnit "&start=0&days=5"
//http请求尾
static const char *REQUEST_ED = " HTTP/1.0\r\n"
"Host: "WEB_SERVER":"WEB_PORT"\r\n"
"User-Agent: esp-idf/1.0 esp32\r\n"
"\r\n";
//城市名
char *reqLocation ;
由于请求的城市名是会变化的,所以利用C语言strcat函数进行组合,组合好后就可以发送完整的请求行了。
而接收到的数据都是json格式,这里调用了cjson库来进行解码,针对不同的http报文有不同的处理方式,具体在http_main.c中,如下。
/***********json格式解析************/
void cjson_to_struct_info(char *text,char arg)
{
cJSON *root,*psub;
cJSON *arrayItem;
//截取有效json
char *index=strchr(text,'{');
strcpy(text,index);
root = cJSON_Parse(text);
if(root!=NULL)
{
/*******************天气实况**********/
if(arg == WEATHER_CURRENT){
psub = cJSON_GetObjectItem(root, "results");
arrayItem = cJSON_GetArrayItem(psub,0);
cJSON *locat = cJSON_GetObjectItem(arrayItem, "location");
cJSON *now = cJSON_GetObjectItem(arrayItem, "now");
if((locat!=NULL)&&(now!=NULL))
{
psub=cJSON_GetObjectItem(locat,"name");
sprintf(weathe.cit,"%s",psub->valuestring);
ESP_LOGI(TAG,"city:%s",weathe.cit);
strcat(send_data_quene,weathe.cit); //拼接发送字符串
strcat(send_data_quene,"+"); //分割符,让pico识别
psub=cJSON_GetObjectItem(now,"text");
sprintf(weathe.weather_text,"%s",psub->valuestring);
ESP_LOGI(TAG,"weather:%s",weathe.weather_text);
strcat(send_data_quene,weathe.weather_text);
strcat(send_data_quene,"+");
psub=cJSON_GetObjectItem(now,"code");
sprintf(weathe.weather_code,"%s",psub->valuestring);
ESP_LOGI(TAG,"%s",weathe.weather_code);
strcat(send_data_quene,weathe.weather_code);
strcat(send_data_quene,"+");
psub=cJSON_GetObjectItem(now,"temperature");
sprintf(weathe.temperatur,"%s",psub->valuestring);
ESP_LOGI(TAG,"temperatur:%s",weathe.temperatur);
strcat(send_data_quene,weathe.temperatur);
strcat(send_data_quene,"+");
}else{
sendData(TAG,"httprequestfail"); //json格式有误。http请求失败
}
}
/*****************天气预报*************************/
if(arg == WEATHER_FORECAST){
psub = cJSON_GetObjectItem(root, "results");
arrayItem = cJSON_GetArrayItem(psub,0);
cJSON *locat = cJSON_GetObjectItem(arrayItem, "location");
cJSON *daily = cJSON_GetObjectItem(arrayItem, "daily");
if((locat!=NULL)&&(daily!=NULL))
{
for(int i = 0;i<3;i++){
arrayItem = cJSON_GetArrayItem(daily,i);
psub = cJSON_GetObjectItem(arrayItem, "date");
sprintf(weathe.daily_weathe[i].date,"%s",psub->valuestring);
ESP_LOGI(TAG,"date:%s",weathe.daily_weathe[i].date);
strcat(send_data_quene,weathe.daily_weathe[i].date);
strcat(send_data_quene,"+");
psub = cJSON_GetObjectItem(arrayItem, "text_day");
sprintf(weathe.daily_weathe[i].text_day,"%s",psub->valuestring);
ESP_LOGI(TAG,"text_day:%s",weathe.daily_weathe[i].text_day);
strcat(send_data_quene,weathe.daily_weathe[i].text_day);
strcat(send_data_quene,"+");
psub = cJSON_GetObjectItem(arrayItem, "code_day");
sprintf(weathe.daily_weathe[i].code_day,"%s",psub->valuestring);
ESP_LOGI(TAG,"code_day:%s",weathe.daily_weathe[i].code_day);
strcat(send_data_quene,weathe.daily_weathe[i].code_day);
strcat(send_data_quene,"+");
psub = cJSON_GetObjectItem(arrayItem, "text_night");
sprintf(weathe.daily_weathe[i].text_night,"%s",psub->valuestring);
ESP_LOGI(TAG,"text_night:%s",weathe.daily_weathe[i].text_night);
strcat(send_data_quene,weathe.daily_weathe[i].text_night);
strcat(send_data_quene,"+");
psub = cJSON_GetObjectItem(arrayItem, "code_night");
sprintf(weathe.daily_weathe[i].code_night,"%s",psub->valuestring);
ESP_LOGI(TAG,"code_night:%s",weathe.daily_weathe[i].code_night);
strcat(send_data_quene,weathe.daily_weathe[i].code_night);
strcat(send_data_quene,"+");
psub = cJSON_GetObjectItem(arrayItem, "high");
sprintf(weathe.daily_weathe[i].high,"%s",psub->valuestring);
ESP_LOGI(TAG,"high:%s",weathe.daily_weathe[i].high);
strcat(send_data_quene,weathe.daily_weathe[i].high);
strcat(send_data_quene,"+");
psub = cJSON_GetObjectItem(arrayItem, "low");
sprintf(weathe.daily_weathe[i].low,"%s",psub->valuestring);
ESP_LOGI(TAG,"low:%s",weathe.daily_weathe[i].low);
strcat(send_data_quene,weathe.daily_weathe[i].low);
strcat(send_data_quene,"+");
psub = cJSON_GetObjectItem(arrayItem, "precip");
sprintf(weathe.daily_weathe[i].precip,"%s",psub->valuestring);
ESP_LOGI(TAG,"precip:%s",weathe.daily_weathe[i].precip);
strcat(send_data_quene,weathe.daily_weathe[i].precip);
strcat(send_data_quene,"+");
psub = cJSON_GetObjectItem(arrayItem, "humidity");
sprintf(weathe.daily_weathe[i].humidity,"%s",psub->valuestring);
ESP_LOGI(TAG,"humidity:%s",weathe.daily_weathe[i].humidity);
strcat(send_data_quene,weathe.daily_weathe[i].humidity);
strcat(send_data_quene,"+");
}
}
else{
sendData(TAG,"httprequestfail"); //json格式有误。http请求失败
}
}
/**************************生活指数****************************************/
if(arg == WEATHER_LIFE){
psub = cJSON_GetObjectItem(root, "results");
arrayItem = cJSON_GetArrayItem(psub,0);
cJSON *locat = cJSON_GetObjectItem(arrayItem, "location");
cJSON *suggestion = cJSON_GetObjectItem(arrayItem, "suggestion");
if((locat!=NULL)&&(suggestion!=NULL))
{
cJSON *car_washing=cJSON_GetObjectItem(suggestion,"car_washing");
psub=cJSON_GetObjectItem(car_washing,"brief");
sprintf(weathe.car_washing,"%s",psub->valuestring);
ESP_LOGI(TAG,"car_washing:%s",weathe.car_washing);
strcat(send_data_quene,weathe.car_washing);
strcat(send_data_quene,"+");
cJSON *dressing=cJSON_GetObjectItem(suggestion,"dressing");
psub=cJSON_GetObjectItem(dressing,"brief");
sprintf(weathe.dressing,"%s",psub->valuestring);
ESP_LOGI(TAG,"dressing:%s",weathe.dressing);
strcat(send_data_quene,weathe.dressing);
strcat(send_data_quene,"+");
cJSON *flu=cJSON_GetObjectItem(suggestion,"flu");
psub=cJSON_GetObjectItem(flu,"brief");
sprintf(weathe.flu,"%s",psub->valuestring);
ESP_LOGI(TAG,"flu:%s",weathe.flu);
strcat(send_data_quene,weathe.flu);
strcat(send_data_quene,"+");
cJSON *sport=cJSON_GetObjectItem(suggestion,"sport");
psub=cJSON_GetObjectItem(sport,"brief");
sprintf(weathe.sport,"%s",psub->valuestring);
ESP_LOGI(TAG,"sport:%s",weathe.sport);
strcat(send_data_quene,weathe.sport);
strcat(send_data_quene,"+");
cJSON *travel=cJSON_GetObjectItem(suggestion,"travel");
psub=cJSON_GetObjectItem(travel,"brief");
if (psub->valuestring[0] == '\0'){
sprintf(weathe.travel,"%s","No Result");
}else{
sprintf(weathe.travel,"%s",psub->valuestring);
}
ESP_LOGI(TAG,"travel:%s",weathe.travel);
strcat(send_data_quene,weathe.travel);
strcat(send_data_quene,"+");
cJSON *uv=cJSON_GetObjectItem(suggestion,"uv");
psub=cJSON_GetObjectItem(uv,"brief");
sprintf(weathe.uv,"%s",psub->valuestring);
ESP_LOGI(TAG,"uv:%s",weathe.uv);
strcat(send_data_quene,weathe.uv);
// strcat(send_data_quene,"+");
}else{
sendData(TAG,"httprequestfail"); //json格式有误。http请求失败
}
}
}
cJSON_Delete(root);
}
cjson解析完后进行组合,将消息发送给RP2040。
2. 多机串口通信
本项目涉及到两个模块之间的通信问题,在开机后双方都各持有一定信息,但需要相互通信才能完成工作。
流程基本为:RP2040发给esp32需要的wifi名和密码,esp32在http请求成功后发给RP2040需要的天气信息。RP2040可根据需要发送城市名给esp32让其去发送http请求,esp32在网络产生异常后也能及时发送状态给RP2040。
(1)RP2040发送wifi名和wifi密码给esp32
在本项目中esp32主要接收来自三种数据:城市名,wifi名,wifi密码,并不复杂,设置简单的识别规则即可。
wifi名:在消息头部添加"+"
wif密码:在消息头部添加"-"
城市名:不处理
具体处理代码详见weather_main.py的initialise_wifi()函数,如下。
async def initialise_wifi(self):
# 初始化界面
self.drawing.draw_opening()
await asyncio.sleep_ms(2000)
# "+"和"-"用于让esp32识别是wifi名还是密码
self.send_quene = "+" + wifi_name
self.uart.write(self.send_quene)
await asyncio.sleep_ms(1000)
self.send_quene = "-" + wifi_passwd
self.uart.write(self.send_quene)
self.picture_index = 0
self.drawing.draw_sending() #发送中
self.err = True
# 检测wifi是否连接成功
while self.err == True:
await self.uart_task()
在esp32接收识别后把头部去掉即可,具体请见esp32的uart.c的rx_task()函数的相关部分,如下。
void rx_task(void *arg)
{
........................
if (data[0] == '+'){
//收到“+”开头,判断为wifi名
Wifi_ssid = &data[1]; //截取
strcpy (ssid,Wifi_ssid); //转存
}
else if(data[0] == '-'){
//收到“-”开头,判断为wifi密码
Wifi_password = &data[1]; //截取
strcpy (passwd,Wifi_password); //转存
ESP_LOGI(RX_TASK_TAG, "ssid %s password: '%s'", ssid, passwd);
wifi_init_sta(ssid,passwd); //wifi初始化
}else{
...............................
}
}
(2)esp32把处理好的数据整合发送给RP2040
由于更新一次数据需要发送3次http请求,所以方案有3种:
1.收到即发:这样的话要求RP2040需要严格控制读取顺序,容易出错。
2.完成一个请求才发。
3.全部整合在一起再发。
显然第二种方法除了整合数据之外,还需要进行接收信息的判断,在考虑尽可能少判断和少发送次数的前提下,采用第3种方法。只需设置合适的分隔符,将所有数据一次发送即可,接收端接收后去除分割符,按照次序读取即可。
这里我采用"+"作为分隔符,在esp32中利用strcat()函数拼接(详见上文json解析部分),利用python中的字符串内建函数split()可以很容易分解并读取,具体请参考RP2040的http_deal.py。
def data_deal(self):
if self.text.find(b'+') != -1:
decode_receiveStr = self.text.decode() #去编码,转化为文本
self.receive_items = decode_receiveStr.split('+')
print(self.receive_items)
http_get_data.city_name_text = self.receive_items[0]
http_get_data.weather_current_text = self.receive_items[1]
http_get_data.weather_current_code = self.receive_items[2]
http_get_data.current_temperature = self.receive_items[3]
http_get_data.date0 = self.receive_items[4]
http_get_data.date0_day_text = self.receive_items[5]
http_get_data.date0_day_code = self.receive_items[6]
http_get_data.date0_night_text = self.receive_items[7]
http_get_data.date0_night_code = self.receive_items[8]
........
(3)RP2040发送城市名给ESP32
发送城市名不做处理直接发送,在RP2040的wearher_main.py中,先是发送标志生效(self.send_flag = True),然后在uart_task()函数中发送,具体如下。
async def uart_task(self):
......
# 发送任务
if self.send_flag == True:
self.uart.write(self.send_quene)
self.send_flag = False
esp32则直接接收,修改reqLocation变量,执行http请求,具体见esp32的uart.c的rx_task()函数。
void rx_task(void *arg)
{
...................................
if (rxBytes > 0) {
data[rxBytes] = 0;
ESP_LOGI(RX_TASK_TAG, "Read %d bytes: '%s'", rxBytes, data);
ESP_LOG_BUFFER_HEXDUMP(RX_TASK_TAG, data, rxBytes, ESP_LOG_INFO);
if (data[0] == '+'){
//收到“+”开头,判断为wifi名
Wifi_ssid = &data[1]; //截取
strcpy (ssid,Wifi_ssid); //转存
}
else if(data[0] == '-'){
//收到“-”开头,判断为wifi密码
Wifi_password = &data[1]; //截取
strcpy (passwd,Wifi_password); //转存
ESP_LOGI(RX_TASK_TAG, "ssid %s password: '%s'", ssid, passwd);
wifi_init_sta(ssid,passwd); //wifi初始化
}else{
//一般字符串,城市名
reqLocation = data;
ESP_LOGI(RX_TASK_TAG, "Re: '%s'", reqLocation);
http_get_task(); //接受到立即发送请求
}
......................
}
(4)esp32错误消息发送给RP2040
esp32可能会出现两种错误:wifi连接失败和http请求失败,可以直接让RP2040读取判断,读取后在屏幕上显示相应信息,具体详见RP2040的weather_main.py的uart_task()函数中,如下。
async def uart_task(self):
self.receive_flag = self.uart.any()
...........
# wifi连接失败
if receiveStr == b'Connectfail':
self.drawing.draw_wificonnectfail()
await asyncio.sleep_ms(1500)
self.err = True
# http请求失败
elif receiveStr == b'httprequestfail':
self.drawing.draw_httprequestfail()
self.err = True
await asyncio.sleep_ms(1500)
..........................
3. 显示
RP2040显示主要使用st7789c库,来自(https://github.com/russhughes/st7789_mpy)或(https://github.com/picospuch/RP2040_Game_Kit),以下讨论的代码均在RP2040源代码的draw.py中。
该库的优势在于显示速度快而且能够显示jpg图片,所以可以参考心知天气平台的天气符号代码与符号对应关系(详见https://docs.seniverse.com/api/start/code.html),就能够根据显示官方的天气信息及符号。
根据官方文档可知,每一个天气代码对应一种天气现象,所以可以利用这个代码判断该画哪一张图,由于python没有switch语句而且循环判断程序的执行效率会很低,所以这里我采用了在类中定义不同的方法(方法名有一定的规则),然后通过getattr函数来进行实现判断,我在后面判断周几以及的键盘键位判断都用到了这个思路。具体详见RP2040的draw.py的weather类和weekday类,以及location.py的location类,具体如下。
class weather:
picture_big = "/weather_picture_big/Unknown.jpg"
picture_small = "/weather_picture_small/Unknown.jpg"
def weather0(self):
weather.picture_big = "/weather_picture_big/Sunny.jpg"
weather.picture_small = "/weather_picture_small/Sunny.jpg"
def weather1(self):
weather.picture_big = "/weather_picture_big/Clear.jpg"
weather.picture_small = "/weather_picture_small/Clear.jpg"
................
def Default(self):
weather.picture_big = "/weather_picture_big/Unknown.jpg"
weather.picture_small = "/weather_picture_small/Unknown.jpg"
def getweather(self, weather):
weather_name = "weather" + str(weather)
fun = getattr(self, weather_name, self.Default)
return fun()
class draw:
.........................
def draw_real_time_weather_picture(self,city_name,weather_current_code,weather_current_text,current_temperature):
self.display.init()
self.code.getweather(weather_current_code) #天气代码判断
................
self.display.jpg(self.code.picture_big ,0 , 0, st7789.FAST)
(1)天气实况显示
天气实况要显示的内容不多,但要注意心知天气平台返回的天气字段有些会很长(如Thundershower with Hail),就有可能影响显示,所以这里要先对部分长字段进行处理,经过观察后发现可以采用以下方式处理:
1.将有“Thunder”字段的换成"T","Thundershower"变为“Tshower”,这可以接受,有些天气平台就是这么表示的。
2.将有空格的字段分两行显示。
这样就能把一行显示的字符控制在10个以内,具体详见RP2040的draw.py中的draw_real_time_weather_picture()函数,如下。
def draw_real_time_weather_picture(self,city_name,weather_current_code,weather_current_text,current_temperature):
..................
if weather_current_text.find('Thunder') != -1:
weather_current_text = weather_current_text.replace('Thunder','T')
if weather_current_text.find(' ') != -1:
item = weather_current_text.split(' ',1)
self.display.text(font2,item[0],0,130)
self.display.text(font2,item[1],0,170)
else:
self.display.text(font2,weather_current_text,0,150)
...........................
(2)生活指数显示
由于返回的参数都是英文,生活指数部分字段长度不定,所以这里分两页来显示,具体详见draw.py中的draw_index_of_living()函数,如下。
def draw_index_of_living(self,index1,index2,index3,picture_index):
............................
# 由于6向指数很难在同一幅画面显示,所以分开显示
if picture_index == 2:
self.display.text(font3,"car_washing",60,0) #洗车指数
self.display.jpg("/index of living/car_washing.jpg" ,0 , 0, st7789.FAST)
self.display.text(font2,index1,60,20,st7789.BLUE)
self.display.text(font3,"dressing",0,81) #穿衣指数
self.display.jpg("/index of living/dressing.jpg" ,180 , 81, st7789.FAST)
self.display.text(font2,index2,0,100,st7789.RED)
self.display.text(font3,"flu",60,161) #流感指数
self.display.jpg("/index of living/flu.jpg" ,0 , 161, st7789.FAST)
self.display.text(font2,index3,60,180,st7789.GREEN)
if picture_index == 3:
self.display.text(font3,"sport",60,0) #运动指数
self.display.jpg("/index of living/sport.jpg" ,0 , 0, st7789.FAST)
self.display.text(font2,index1,60,20,st7789.BLUE)
self.display.text(font3,"travel",0,81) #旅游指数
self.display.jpg("/index of living/travel.jpg" ,180 , 81, st7789.FAST)
self.display.text(font2,index2,0,100,st7789.RED)
self.display.text(font3,"uv",60,161) #紫外线指数
self.display.jpg("/index of living/uv.jpg" ,0 , 161, st7789.FAST)
self.display.text(font2,index3,60,180,st7789.GREEN)
(3)天气预报显示
天气预报要显示的内容是最多的,因此如何合理安排布局并使数据直观是一个挑战。
这里的天气图标对应的是小版的,使用和上面一样的在类中定义不同的方法(方法名有一定的规则),然后通过getattr函数来进行实现判断的方式来实现。
在每日最高最低气温的显示上,我采用了比较简约的方法,用红色字体+H表示最高气温,用蓝色字体+L表示最低气温,具体代码详见draw_weather_forcast()函数。
def draw_weather_forcast(self,date0,date0_day_text,date0_day_code,date0_high_temperature,date0_low_temperature,date0_precip,date0_humidity,
date1,date1_day_text,date1_day_code,date1_high_temperature,date1_low_temperature,date1_precip,date1_humidity,
date2,date2_day_text,date2_day_code,date2_high_temperature,date2_low_temperature,date2_precip,date2_humidity):
.......................
self.display.text(font3,"H"+date0_high_temperature,5,120,st7789.RED) #最高气温
self.display.text(font3,"L"+date0_low_temperature,5,140,st7789.BLUE) #最低气温
self.display.text(font2,"C",43,125)
self.display.text(font1,"o",40,120)
.........................
而关于降水概率(POP)和相对湿度(HR)都是百分数,可以采用类似长度条的方式直观的表现其大小,而且占用空间小。
def draw_weather_forcast(self,date0,date0_day_text,date0_day_code,date0_high_temperature,date0_low_temperature,date0_precip,date0_humidity,
date1,date1_day_text,date1_day_code,date1_high_temperature,date1_low_temperature,date1_precip,date1_humidity,
date2,date2_day_text,date2_day_code,date2_high_temperature,date2_low_temperature,date2_precip,date2_humidity):
..........................
self.display.text(font3,"POP:",0,160,st7789.MAGENTA) #降雨概率POP
num_date0_precip = float(date0_precip)
length = int(num_date0_precip * 70)
self.display.fill_rect(5,183,length,5,st7789.MAGENTA) #颜色条显示,越长百分比越大
self.display.fill_rect(3,182,5,7,st7789.WHITE)
num_date0_precip = num_date0_precip * 100
num_date0_precip= int(num_date0_precip)
self.display.text(font3,str(num_date0_precip)+"%",50,160,st7789.MAGENTA)
self.display.text(font3,"HR:",0,190,st7789.CYAN) #相对湿度HR
num_date0_humidity= int(date0_humidity)
length = int(num_date0_humidity * 70 /100)
self.display.fill_rect(5,213,length,5,st7789.CYAN)
self.display.fill_rect(3,212,5,7,st7789.WHITE)
self.display.text(font3,str(num_date0_humidity)+"%",50,190,st7789.CYAN)
....................................
(4)实时时间显示
若要得到实时时间,可以通过RP2040的RTC(实时时钟)获得当前时间,调用也十分方便。不过要显示时间的话,则需要一个变量来记录时间的变化,时间数值变化了才刷新屏幕显示,这样就能在屏幕上实现时间变化的效果。本项目使用 self.last 来记录时间,具体实现代码详见weather_main.py的draw_times函数:
def draw_times(self):
if self.rtc.datetime() != self.last :
time_index = self.rtc.datetime()
# 只在实时天气界面显示时间
if self.picture_index == 1 :
self.drawing.draw_time(str(time_index[0]),str(time_index[1]),str(time_index[2]),str(time_index[3]),str(time_index[4]),str(time_index[5]),str(time_index[6]))
self.last = time_index
gc.collect()
由于时间的显示是需要实时更新的,所以这个函数在总进程中也要调用。
async def process(self):
self.hardware_init()
await self.initialise_wifi() #初始化界面
self.last_hour = self.rtc.datetime()[4]
self.last = self.rtc.datetime()
while True:
self.dir_select() #遥感检测
self.regular_update() #定时更新
self.draw_times() #更新时间
self.city_choose() #修改城市名
await self.uart_task() #串口任务
3.操作交互
本项目显示主要分为两个模式:一般模式和键盘模式。一般模式下主要显示天气信息,键盘模式下显示并修改城市名。
(1)一般模式
即接收到http报文后显示各类天气信息的模式。
基本操作:摇杆左右移动可切换显示内容,上下移动则无效,通过变量 self-picture-index 决定显示哪一个画面,具体内容请参考 weather_main.py中的 dir_select()函数,如下。
def dir_select(self):
xValue = self.xAxis.read_u16()
yValue = self.yAxis.read_u16()
if xValue <1000:
self.picture_index -= 1
if self.picture_index < 1 :
self.picture_index = 4
self.draw_picture() #遥感有动作时才更新画面
elif xValue >40000:
self.picture_index += 1
if self.picture_index > 4 :
self.picture_index = 1
self.draw_picture()
gc.collect()
该模式下B键用于刷新天气数据,即按下B键后就使发送标志生效,发送城市名给ESP32,让其发送http请求,具体详见weather_main的refresh_callback()函数,如下。
def refresh_callback(self, p):
print("k2 pressed")
self.send_flag = True
A键则用于打开键盘模式,具体详见weather_main的keyboard_callback()函数,如下。
def keyboard_callback(self, p):
print("kkk pressed")
self.keyboard_cw = True
(2)键盘模式
即显示一个虚拟的9键键盘,让使用者能修改城市名。
基本操作:参考我门平时听熟悉的9键键盘,其会把26个英文字母分成不同段安排在不同按键中,当我们选中按钮后会弹出一栏字母的分支选择,在分支栏中再进行一次选择才能把内容写入(当然这是以前的9键键盘,现在的可以模糊选择),所以基本的逻辑如下。
所以操作的流程为:按A打开键盘,用四项摇杆进行上下左右键位选择,最左边一栏为功能键,其余为字符键,按A选中,选择不同的功能键会有不同的效果,字符键分为有效字符和无效字符(.用 ">_<"表示),选择无效字符是无反应的,选择有效字符后最左侧的功能键栏会被替换为分支内容,此时只能上下移动摇杆,按A选中写入字母到发送序列,按B则回退到9键选择,在选中发送键"ENT”前会一直保留键盘界面,按“ENT”后才会退出并发送城市名给ESP32进行http请求。若http请求失败(城市名有误,网络断开),则会进入httprequestfail界面,若是城市名输入有误,此时按A键可重新打开键盘修改信息。
代码实现过程:
实现一个虚拟键盘本质上就是,使用按键进行信息操作,操作过程通过屏幕显示出来。
关于键位显示:移动光标的结果可以用x,y坐标表示,对应RP2040中的 self.locat_x和self.locat_y,关键在于每一个位置对应不同的功能和字段,因此我利用上文说到的在类中定义不同的方法(方法名有一定的规则),然后通过getattr函数来进行实现判断,相干内容在location.py的location类中,如下。
class location:
caps = 1 #大小写开关
number = 0 #数字开关
def location1_1(self):
return "123" #切换为数字
def location2_1(self):
if location.number == 1:
return "1 "
elif location.caps == 1:
return "abc"
else:
return "ABC"
def location3_1(self):
if location.number == 1:
return "2 "
elif location.caps == 1:
return "def"
else:
return "DEF"
def location4_1(self):
if location.number == 1:
return "3 "
elif location.caps == 1:
return "ghi"
else:
return "GHI"
def location1_2(self):
return "A/a" #切换大小写
def location2_2(self):
if location.number == 1:
return "4 "
elif location.caps == 1:
return "jkl"
else:
return "JKL"
def location3_2(self):
if location.number == 1:
return "5 "
elif location.caps == 1:
return "mno"
else:
return "MNO"
def location4_2(self):
if location.number == 1:
return "6 "
elif location.caps == 1:
return "pqrs"
else:
return "PQRS"
def location1_3(self):
return "DEL" #删除字符
def location2_3(self):
if location.number == 1:
return "7 "
elif location.caps == 1:
return "tuv"
else:
return "TUV"
def location3_3(self):
if location.number == 1:
return "8 "
elif location.caps == 1:
return "wxyz"
else:
return "WXYZ"
def location4_3(self):
if location.number == 1:
return "9 "
else:
return '>_<' #英文字母不需要这一位
def location1_4(self):
return "ENT" #发送字符
def location2_4(self):
return '>_<'
def location3_4(self):
if location.number == 1:
return "0 "
else:
return '>_<' #英文字母不需要这一位
def location4_4(self):
return '>_<'
def Default(self):
print("wrong")
def getlocation(self, locationx,locationy):
location_name = "location" + str(locationx)+"_"+str(locationy)
fun = getattr(self, location_name, self.Default)
return fun()
这样就将键位内容和键位联系起来了,就可以实现画键盘(draw.py的draw_keyboard()函数)和高亮按键(draw.py的draw_highlight()函数),如下:
def draw_keyboard(self):
a = location()
self.display.fill_rect(0,101,34,139,st7789.BLACK)
self.display.fill_rect(36,206,63,33,st7789.BLACK)
self.display.fill_rect(176,206,63,33,st7789.BLACK)
self.display.vline(35,100,140,st7789.YELLOW)
self.display.vline(105,100,140,st7789.YELLOW)
self.display.vline(175,100,240,st7789.YELLOW)
self.display.hline(0,100,240,st7789.YELLOW)
self.display.hline(0,135,240,st7789.YELLOW)
self.display.hline(0,170,240,st7789.YELLOW)
self.display.hline(0,205,240,st7789.YELLOW)
self.display.text(font2,a.getlocation(2,1),36,101 )
self.display.text(font2,a.getlocation(3,1),106,101 )
self.display.text(font2,a.getlocation(4,1),176,101 )
self.display.text(font2,a.getlocation(2,2),36,136 )
self.display.text(font2,a.getlocation(3,2),106,136 )
self.display.text(font2,a.getlocation(4,2),176,136 )
self.display.text(font2,a.getlocation(2,3),36,171 )
self.display.text(font2,a.getlocation(3,3),106,171 )
self.display.text(font2,a.getlocation(4,3),176,171 )
self.display.text(font2,a.getlocation(3,4),106,206 )
self.display.text(font3,a.getlocation(1,1),0,101 )
self.display.text(font3,a.getlocation(1,2),0,136 )
self.display.text(font3,a.getlocation(1,3),0,171 )
self.display.text(font3,a.getlocation(1,4),0,206 )
# 选中按键字体变成黄色表示高亮
def draw_highlight(self,x,y):
a = location()
locat_x = 36 + 70 * (x-2)
locat_y = 101 + 35 * (y-1)
# 功能键字体大小偏小需另外处理
if x == 1:
self.display.text(font3,a.getlocation(x,y),0,locat_y,st7789.YELLOW)
else:
self.display.text(font2,a.getlocation(x,y),locat_x,locat_y,st7789.YELLOW)
那么怎么判断功能键并实行相应功能呢?本项目把功能键设置成特定字段(123,A/a,DEL,ENT),并在键盘循环中判断相应字段是否对应即可,然后实现相应功能,具体详见weather_main的keyboard()函数,如下。
def keyboard(self):
# 画出键盘
self.drawing_keyboard()
self.drawing.draw_quene(self.send_quene)
# 修改按键AB的回调函数
self.k1 = button(game_kit.key_a, self.k1_callback)
self.k2 = button(game_kit.key_b, self.k2_callback)
# 保持键盘画面,在确认发送后退出画面
while self.keyboard_cw == True:
self.backup = False
xValue = self.xAxis.read_u16()
yValue = self.yAxis.read_u16()
sleep(0.2)
if xValue <1000:
self.locat_x -= 1
if self.locat_x < 1:
self.locat_x = 1
self.drawing_keyboard() #每次移动摇杆后更新画面
elif xValue >40000:
self.locat_x += 1
if self.locat_x > 4:
self.locat_x = 4
self.drawing_keyboard()
if yValue <1000:
self.locat_y -= 1
if self.locat_y < 1:
self.locat_y = 1
self.drawing_keyboard()
elif yValue >40000:
self.locat_y += 1
if self.locat_y > 4:
self.locat_y = 4
self.drawing_keyboard()
# 选中一格
if self.chosen :
self.chosen = False
a= location()
s_list=list(self.send_quene )#将字符串转换为列表
# 选中发送键
if a.getlocation(self.locat_x,self.locat_y) == "ENT":
self.keyboard_cw = False #关闭键盘退出循环
# 选中删除键
elif a.getlocation(self.locat_x,self.locat_y) == "DEL" :
# 判断是否已经全部删除完了
if len(s_list)!= 0 :
s_list.pop(-1)#pop掉列表最后一个值,返回被pop掉的值
self.send_quene = ''.join(s_list)#将pop之后的列表通过join()函数转换为字符串
self.drawing.draw_quene(self.send_quene) #更新已写入内容
# 选中有效区域
elif a.getlocation(self.locat_x,self.locat_y) != ">_<" :
if a.getlocation(self.locat_x,self.locat_y) == "123": #选中切换数字
location.number = 1
self.drawing_keyboard()
elif a.getlocation(self.locat_x,self.locat_y) == "A/a": #选中切换大小写
location.number = 0
location.caps = 1-location.caps
self.drawing_keyboard()
else:
self.branch_choose(self.locat_x,self.locat_y) #选中字符串
而选择有效字符后需要显示分支,按照键位信息中的内容显示在原功能栏即可,此时修改摇杆为只能上下移动,并利用按键AB实现选择或回退,逻辑部分详见weather_main.py的branch_choose()函数,画图部分详见draw.py的draw_branch()函数,如下。
/weather_main.py
def branch_choose(self,x,y):
index = 1
a = location()
index_max = len(a.getlocation(self.locat_x,self.locat_y)) #按键内容占格数
self.drawing_branch(index)
# 是否按下回退键,若按下则回到键位选择
while self.backup == False :
sleep(0.2)
yValue = self.yAxis.read_u16()
if yValue <1000:
index -= 1
if index < 1:
index = 1
self.drawing_branch(index)
elif yValue >40000:
index += 1
if index > index_max:
index = index_max
self.drawing_branch(index)
# 选中字符
if self.chosen :
self.chosen = False
self.send_quene = ''.join([self.send_quene,a.getlocation(self.locat_x,self.locat_y)[index-1]]) #将字符加入发送队列
self.backup =True #退出分支
self.drawing.draw_quene(self.send_quene) #更新写入内容
self.backup = False
self.drawing_keyboard()
/draw.py
def draw_branch(self,index,str):
self.display.fill_rect(0,101,34,33,st7789.BLACK)
self.display.fill_rect(0,136,34,33,st7789.BLACK)
self.display.fill_rect(0,171,34,33,st7789.BLACK)
self.display.fill_rect(0,206,34,33,st7789.BLACK)
locat_y = 101 + 35*(index -1)
for i in range(0,len(str)):
self.display.text(font2,str[i],0,101 + 35 * i)
self.display.text(font2,str[index-1],0,locat_y,st7789.YELLOW)
最后在发送数据退出键盘模式回到一般模式时,要还原现场,具体操作详见weather_main.py中的city_choose()函数,如下。
def city_choose(self):
if self.keyboard_cw == True:
self.drawing.clear() #清屏
self.drawing.draw_tip("city_name:")
self.keyboard()
self.picture_index = 0
self.drawing.draw_sending() #发送中画面
# 将按键回调函数修改回一般模式下的情况
self.k1 = button(game_kit.key_a, self.keyboard_callback)
self.k2 = button(game_kit.key_b, self.refresh_callback)
self.send_flag = True #可以发送
gc.collect()
self.last = self.rtc.datetime() #还原现场,保持时间更新
四、后记
这是我第二次用树莓派的rp 2040来开发项目了,这次的过程比上一次要复杂许多,虽然总的思路很清晰,但这一次新引入的esp32-s2模块给我带来了全新的挑战,网络编程和多机通信,很多都是我第一次接触的东西,比如esd32-idf的开发,不过最后我也充分体会到了多机互联的快乐。就本项目而言,未来还有值得提高的地方。
- 加入中文显示。有关这方面的知识我还不太了解,如果能有中文显示界面将可以进一步优化。
- 加入WiFi扫描与连接。既然有了键盘理论上就能通过操作游戏机来联网,这样使用起来将更加灵活。
- 利用Esp 32的 NVM储存WiFi的相关信息,这样在每次断电后再恢复供电时能自动连接WiFi。
由于时间原因以上两点尚未实现,但我相信在不久的将来定能实现。