幺幺八点六の记事本

串口与屏幕与ESP32

前言

本篇内容预计主要围绕着我最近的一个项目,我暂且称为桌面快捷方式。本意是为了更快速地打开各种快捷方式而不需要切入切出。功能不复杂, 硬件上主要就是ESP32-S2(主控)接16位并口驱动屏幕显示,同时由于16位并口占用过多GPIO,采用了一个MCP23017去扩展GPIO,检测屏幕周围按钮的按下。与电脑之间通过USB虚拟串口通讯,电脑端运行上位机控制。虽说东西不多,但是对于我拿Arduino写的过程中,还是踩了一些坑。打算记录一下一些技术细节,以便翻阅。

硬件部分

虽说硬件部分简单,但是还是有一点坑。

MCP23017的连接

MCP23017的16个GPIO被分成了两组,每组8个。芯片主要通过IIC方式和MCU通信,这里面有INTA和INTB,实际使用中可以合为一个,进一步节省GPIO。

这里面的RESET接到了MCU的EN上,但是这里面的问题是,如果通过主控软件进行复位,EN引脚不会产生变化,此时MCU数据复位但是MCP23017的没有,如果复位发生在中断的过程中,很有可能造成后续MCP23017不能正常响应中断,因为之前的中断还没有被处理。通过软件是可以避免这种情况,但是如果在出现bug,溢出等重启时,需要按下EN或者重新插拔电源,造成不便。

与屏幕的连接

这个项目中,屏幕采用的是3.5英寸,16位并口,ILI9488驱动的屏幕。在我最初画的板子中,屏幕的RES与EN也是接在一起的。不知道是手册没提到还是我没看仔细,在RES复位屏幕的时候,D0上会产生一个低电平。而此时我的DB0与GPIO0是接在一起的,这会导致MCU与屏幕同时复位,并下拉GPIO0,导致芯片进入下载模式,再次按下EN,结果是相同的,导致芯片无法运行。之后将RES改为与其他GPIO相连由软件控制复位,解决问题。

按钮的选择

我是希望这个按钮按下去有比较好的反馈,对于某些固定操作,应该是可以不看屏幕完成的。同时我的屏幕是固定在与按钮同一块板子上的,按钮就需要比较薄。所以选用了这款轻触按钮。不过我认为这个按钮设计时并不是打算在上面放键帽之类的东西,所以它的行程比较短,键帽只能夹在前面板与按钮之间,带来了一定的麻烦。不过其他方面倒是相当合适。

硬件上有很多可以改的地方,有些GPIO检查过后实际上是可以取消的,加上空闲的并利用矩阵键盘可以不使用MCP23017完成,按钮型号也可以继续挑选。

下个版本一定

MCU软件部分

这个部分我希望能讲一讲整个代码的实现,虽说结构不一定优秀格式不一定工整,但是至少一些坑是踩过去了。

屏幕相关

首先这个项目最先做的是初始化了屏幕的对象。驱动的库文件在这里选用的是ArduinoGFX,示例中给出了ESP32S2的选项,16个数据位直接接gpio0-15,直接用这个。其他的使用方法可以参考官方GitHub,不过我依旧怀疑自己查找资料的能力,我没有找到这个库关于函数用法的说明,在写这个程序的过程中是翻源代码解决了很大一部分。

Arduino_DataBus *bus = new Arduino_ESP32S2PAR16(DC, CS, WR, RD);
Arduino_GFX *gfx = new Arduino_ILI9488(
  bus, RST, 0 /* rotation */, false /* IPS */);

定义完相关参数以后,进入SETUP函数,根据示例,进行相关初始化操作。这里我们需要显示中文要开启这个UTF8的输出功能,否则无法使用。

gfx->begin();
gfx->fillScreen(BLACK);
gfx->setUTF8Print(true);

在主页面的绘制过程中,我希望能自定义我需要的基准点,不过据我所知这个库文件里并没有提供相关的函数,好在他提供了一个获取边界的函数,所以简单写了以下的的函数。这里面的s是size,仅支持以整数倍缩放。c是color,RGB565的格式。

/*
绘制文字,由于在库文件中并未找到直接绘制的相关内容,在下面函数中首先根据基准点信息计算左下(BL)坐标位置,之后设置游标绘制
T:Top
B:Bottom
C:Center
L: Left
R:Right
各种组合
*/
void drawString(String str,int16_t x, int16_t y,uint8_t s,uint8_t DATUM,uint16_t c,const uint8_t *font){
  gfx->setTextColor(c);
  gfx->setFont(font);
  gfx->setTextSize(s);
  int16_t mx = 0,my = 0;
  uint16_t w = 0,h = 0;
  gfx->getTextBounds(str,x,y,&mx,&my,&w,&h);//获取文字绘制的边界
  int16_t DisplayX = x,DisplayY = y;
  if(DATUM == TL_DATUM){
    DisplayX = x;
    DisplayY = y+h;
  }else if(DATUM == TC_DATUM){
    DisplayX = x-(w/2);
    DisplayY = y+h;
  }else if(DATUM == TR_DATUM){
    DisplayX = x-w;
    DisplayY = y+h;
  }else if(DATUM == CL_DATUM){
    DisplayX = x;
    DisplayY = y+(h/2);
  }else if(DATUM == CC_DATUM){
    DisplayX = x-(w/2);
    DisplayY = y+(h/2);
  }else if(DATUM == CR_DATUM){
    DisplayX = x-w;
    DisplayY = y+(h/2);
  }else if(DATUM == BL_DATUM){
    DisplayX = x;
    DisplayY = y;
  }else if(DATUM == BC_DATUM){
    DisplayX = x-(w/2);
    DisplayY = y;
  }else if(DATUM == BR_DATUM){
    DisplayX = x-w;
    DisplayY = y;
  }
  gfx->setCursor(DisplayX, DisplayY);
  gfx->print(str);
}

这里面设置字体大小主要是需要自定义字体,这里面可以直接使用U8G2的字体文件和自定义办法,我项目里设置的是20px,思源黑的字体。

扩展IO-MCP23017

之后需要初始化MCP23017,这个也有库文件,Adafruit_MCP23X17.h。使用Wire.begin(SDA,SCL)初始化IIC总线,后续参考库文件示例即可。我这边是直接初始化了16个中断,在23017中产生中断之后会输出给一个低电平到中断引脚,触发arduino的中断。MCP23017有两个中断引脚,实际使用中可以合并两个引脚。每次在arduino中触发中断的时候会触发中断函数,中断函数中为了节省时间只是将一个Flag设置为true,在loop函数中检测这个flag如果为true,则将它交给处理按钮按下的函数处理。在处理完中断之后,需要重置Flag以及mcp.clearInterrupts();来重置MCP23017的中断。

pinMode(INT_PIN, INPUT);
attachInterrupt(digitalPinToInterrupt(INT_PIN), ButtonInterrupt, FALLING);
mcp.setupInterrupts(true, false, LOW);

USB串口

ESP32-S2自带了一个USB控制器,支持USBCDC设备,所以相较于之前的设计可以省下一个USB转串口。主要参考的示例就是自带的USB Serial设备示例。首先初始化USB串口与USB控制器,设置事件发生时的中断

  USBSerial.begin();
  USB.begin();
  USBSerial.onEvent(usbEventCallback);

在触发USB事件以后,需要判断触发的是哪个事件。这里因为发生过缓冲区溢出的问题,所以处理了接收数据和接收缓冲区溢出事件。同样,如果在中断里时间过长会导致错误,在此仅存储字符串不作处理。这里的SerialData是一个全局变量。

void usbEventCallback(void* arg, esp_event_base_t event_base, int32_t event_id, void* event_data){
  arduino_usb_cdc_event_data_t * data = (arduino_usb_cdc_event_data_t*)event_data;
  if(event_id == ARDUINO_USB_CDC_RX_EVENT){
    //Serial.printf("CDC RX [%u]:", data->rx.len);
    uint8_t buf[data->rx.len];
    size_t len = USBSerial.read(buf, data->rx.len);
    SerialData += String(buf,len);
  }else if(event_id == ARDUINO_USB_CDC_RX_OVERFLOW_EVENT){
    Serial.printf("CDC RX Overflow of %d bytes", data->rx_overflow.dropped_bytes);
  }
}

在loop函数中会检测变量是否为空,如果是空则使用SerialData.indexOf(str)>=0判断是否存在一个特定的字符串,用来检测命令的类型。该函数在不存在时会返回-1,根据命令类型取出中间数据交给相应函数处理。

最开始是直接采用检测缓冲区字节数,不为0则读取进字符串,之后处理。由于上位机发送的数据比较大,可能直接导致缓冲区溢出,数据丢失。所以采用了事件的方式,目前来看没有再触发缓冲区溢出的情况出现。

数据存储

由于需要保存相关菜单文件,所以需要利用ESP32的文件系统存储数据,这次选用的是FFAT。我这边买的模块是N16R2的,有16M的flash可以用,不过需要重写分区表。参考官方和platformio的文档,添加一行ffat, data, fat,,10M,,将10M换成所需的FFAT分区大小。注意本身可能有SPIFFS或者FFAT的分区,删除即可,我们新建一个就不需要原来的了。记得检查总大小不要超过你的flash大小。同时,我所用的platformio需要手动写一个配置文件,更改该型号的ESP32的flash大小。建议照猫画虎,更改maximum_size即可。

代码部分,首次运行时可能需要format一下文件系统,之后正常初始化即可。读写文件的函数参考官方示例,这块变数不大。

if (FORMAT_FFAT) FFat.format();
if(!FFat.begin()){
    Serial.println("FFat Mount Failed");
    return;
}

运行流程

讲一点前提:我们存储这些菜单是基于一个树形结构存储,除去包含节点所需数据,另外添加id,指向父节点的指针Parent,一个列表去保存子节点children。

首先进入setup函数,完成所有的初始化工作并给屏幕重置,之后从文件系统读取菜单树。这棵树每个节点为一行按照DFS的顺序存储至文件,不需要存储子节点列表。读取时首先按照分隔符读取每个节点,再按照分隔符分成每个参数,每个参数根据位置生成一个节点。每个生成出来的节点会进入一个列表,平行存储了每一个节点。遍历这个列表并按照ID找到它的父节点,把自己的指针加入父节点的子节点列表中,同时自己的parent指向父节点。由于生成平行列表的需要,我们首先需要创建一个根节点,并把它推入列表。这在setup函数读取文件之前及全局变量中实现。

逐行读取完文件后,树应该建立完成,采用一个指向当前所有节点的列表的指针保存当前显示的所有菜单。首先将根节点的子节点的列表的指针存储到之前的变量中,之后交给显示菜单的函数,读取所有菜单并按照位置显示出来。到此,setup函数执行完成。

之后循环执行loop函数,首先判断中断是否被触发,并拿到触发中断的按钮。把这个按钮编号交给相关函数,函数会读取当前层级的菜单列表并根据显示位置找到对应的按钮,读取它的类型,命令,数据,通过串口发送给上位机执行。之后重置状态和芯片。

然后判断串口数据是否为空,如果不空则判断数据是否包含命令字符串,如果匹配上则取出数据,这边主要定义了几个命令,设置菜单,获取设备名称,心跳包。设置菜单直接保存数据至文件系统,之后清空列表和根节点的子节点,并重新从文件系统读取显示。获取设备名称是为了上位机识别设备,返回特定字符串即可。心跳包用于检测上位机是否正常存在,如果是则记录当前时间。如果接收到心跳包且当前正处于息屏状态,则重新显示屏幕内容。

串口数据处理完成后,判断当前时间与上一个心跳包的时间差距,如果超出阈值则息屏并设置相关Flag。到此,loop也执行完成,开始下个循环。

其他

在流程中,由于数据交换均采用字符串,所以为了防止字符串中的内容干扰分隔符,对内容进行了base64处理,采用了Base64.h。对于列表,采用了Vector.h

上位机部分

上位机部分比较简单,没有应用MVVM之类的模型,直接使用wpf写了应用。UI采用了HandyControl

运行流程

完成相关初始化工作之后,首先弹出选择串口的界面。串口选择沿用了之前写的自动选择串口,打开每个串口发送数据,如果返回值不正确或者超时未收到数据,则关闭检测下一个。需要注意的是,在收到数据以后,我延时了50ms让数据完整。对于这个项目应用的USB虚拟串口,是需要有流控的。方式是DTR/CTS。我简单地直接打开了DTR:DevicePort.DtrEnable = true;如果不这样,你会发现你的串口发送给MCU正常,但是你收不到MCU的数据。(正常来说还是该检测一下CTS的状态,偷懒暂时没啥问题倒是)
找到正确的串口之后,写入文件并返回。如果没有找到,则弹出提示并关闭窗口。

回到主界面,直接读取之前写入的文件并打开串口,同样需要打开DTR。与MCU的运行过程一致,读取菜单文件并生成树。之后将菜单显示到TreeView中。启动发送心跳包的计时器。

编辑菜单就是基本的增查删改,出于偷懒,我选择每次更改发送全量数据。

如果接收到MCU的数据,则判断命令并执行。OpenFile这个命令直接执行了System.Diagnostics.Process.Start(str);可以打开很多类型的东西。

软件会在关闭按下时取消并隐藏到托盘,同样可以通过双击托盘唤出主菜单。

最后

这个项目还有很多改进空间,也欢迎前来交流:QQ312980373。并未公开完整源代码,不过有需要可以找我要,我会开放GitHub。

当前页面是本站的「Google AMP」版。查看和发表评论请点击:完整版 »