项目介绍
本项目是基于2023寒假一起练平台(5)- 基于ESP32 WiFi 的综合应用完成的项目5- 实现一个USB键盘鼠标设备,使用IO扩展板上的一个用X、Y二轴电位计制作的游戏手柄,并且此芯片支持USB通信。实现一个USB鼠标&键盘复合设备,摇动游戏手柄实现鼠标的移动,一个按键实现左键点击,另一个按键按下实现键盘敲入一串字符"eetree.cn"
简单的硬件介绍 板卡详情
-
ESP32-S2-MINI-1模块:ESP32 S2 开发板除了ESP32wifi模组之外还集成了USB TYPE -C接口,两个按键,一个电源指示灯,一个用户LED灯,2排10pin的排针,将重要IO引出。使用USB供电或通过排针3.3V供电。ESP32-S2 是一款高度集成、高性价比、低功耗、主打安全的单核 Wi-Fi SoC,具备强大的功能和丰富的 IO 接口。
-
输入、输出扩展板:由于本次做的项目只需要使用旋转编码器/按键、摇杆两处IO扩展板外设,所以简单列一下这里的外设:
-
按键、旋转编码器输入 - 以模拟信号的方式
-
双电位计控制输入/摇杆 - 以数字信号的方式
-
功能分析
由于本次做的项目只需要使用旋转编码器/按键、摇杆两处IO扩展板外设,所以简单分析一下这两处的电路:
- IO扩展板卡上的旋转编码器和按键被接入了一组电阻网络:
-
在这部分电阻网络当中,将旋转编码器顺时针和逆时针转动时,A、B脚产生的相位不同脉冲信号引入到电阻网络当中,通过电阻分压,实现了将PWN信号转换为模拟信号的功能。
-
当转动旋转编码器时,可以将A、B两脚发出的PWM波,视为加载R16和R17电阻上的电压在不同时刻交替变化,产生的A_Out使用ESP32采样之后,可以看到电平锯齿状变化,其中下降后保持的电平不同,此时使用状态机可以进行判断。
-
同理,按下旋转编码器后,S2产生低电平,被加载到C7附近,A_Out被降低至一个更低的值。此时,A_Out为一个稳定的,较低的电压值。
-
同样,当K1被按下时,电压被降低至最低值。
-
-
IO扩展板卡上的摇杆器件FJ08K与一个四运放LMV324的网络相连如图所示:
-
此时,将4运放分开来看:(这部分看的不是很细,可能说错了一些)
-
U2D的作用为将电压分压之后,实现为X+的输出提供积分(借助C2),以及为U2A的V+以及X-进行电压传递的任务。——总体来说起一个提供电压基准的工作
-
U2A的作用为实现一个R-C迟滞-震荡电路。U2A的输出端会产生一个震荡信号。
-
U2B的V+与Y的输出相连,Y的输出为一个在Vcc_GND之间的分压;U2B的V-与震荡信号相连。可以看出,V2B被当做一个比较器使用,产生的信号为VCC-GND的PWM信号。
-
U2C没有被使用。
-
总结一下: 在该电路当中,将2组分压信号输入转化为一个PWM信号。
-
-
设计思路
在前文已经分析过了两处重要的外设电路,所以在这里不再分析电路,直接说代码思路。
-
-
总体思路:
-
使用ARDUINO-IDE进行开发:在ESP-IDF当中,下载/调试使用的USB-CDC和键盘外设存在一定冲突(堆栈问题),而ARDUINO-IDE对于USB处理有着另外的方法,因此这里使用ARDUINO-IDE进行项目的开发,以方便调试值的打印和USB外设功能能够两全其美。
-
使用定时器完成外设扫描、更新任务:由于本次项目需要频繁采集模拟值、PWM占空比等值,因此在获取信号时,如果全部加入loop当中,对于模拟量和数字量的采集也不用那么频繁,必然添加延时语句,会造成处理逻辑的阻塞等不良情况,因此采用ESP32S2的硬件定时器完成轮询功能。
hw_timer_t* timer_analog = NULL; hw_timer_t* timer_joystick = NULL; hw_timer_t* timer_led_fade = NULL;
-
使用USB库: ARDUINO的ESP库提供了良好的硬件外设支持,键盘、鼠标等只需要导入HID库即可:
#include "USB.h" #include "USBHIDKeyboard.h" #include "USBHIDMouse.h"
-
使用状态机判断模拟按键状态: 由于模拟电压不一定保持稳定,读取时有出现噪声和毛刺的可能,因此在轮询模拟电压时,采用状态机的方式再三确认按键被按下,同时也避免了枯燥的
count
。typedef enum key_status { IDLE, PrePressed, OnPressed, Pressed, AfterPressed, Released }key_status;
-
在主程序中才进行一些外设的调用: 可能是ARDUINO外设支持的问题,extern的变量可能不会在定时任务中被正确更新(比如TFT屏幕),因此在外设支持当中,只传递值,而较少直接调用第三方外设防止出现意外问题。主循环例子如下:
if (akeys.key == EncoderPressed && akeys.status == Pressed) { if (!Mouse.isPressed(MOUSE_LEFT)) { Mouse.press(MOUSE_LEFT); digitalWrite(LED1_PIN,HIGH); } } else { Mouse.release(MOUSE_LEFT); } if (x_dis != 0 || y_dis != 0) { digitalWrite(LED2_PIN,HIGH); Mouse.move(x_dis, y_dis, 0); }
-
-
所有模拟值和PWM的值都是在特定环境下测试的,如果需要迁移代码,请自行重新打表!!!!
- 旋转编码器和按钮部分:由于这里主要使用模拟采样,因此使用结构体保存每次采样的结果,同时加入按键状态机和判断函数,对于获取的电压值进行处理、判断按键状态。
-
因为需要获取模拟量,因此直接使用ARDUINO的内置函数实现:
akeys.analogValue = analogRead(1);
其中,akeys是所有按键的结构体,analogRead为获取一个0-4095之间的模拟值(此时的模拟方案设置为:analogReadResolution(12);
,1为连接IO扩展板的引脚编号) -
在定时器中,每次判断按键是否合理后,即进入更新状态的阶段,只有当按键被确认为是
Pressed
的状态之后,才在主程序当中进行更新。(实例程序如上) -
为了防止模拟电压的噪声,在状态机中采用冗余设计,通过反复采集模拟量再更新状态,代码实现如:
/** *@brief 更新传入的状态 * * @param status_old 上一次的状态 * @return key_status 本次的状态 */ key_status ModifyKey(key_status status_old) { switch (status_old) { case PrePressed: return OnPressed; break; //省略一些状态判定,作为冗余设计,最后才判定为Pressed case Released: //Release不单独更新 return Released; break; default: return IDLE; break; } }
-
为保证每次状态更新的正确性,在“打表”算出每个按键的模拟量之余,采用宏来设置一个合理的阈值,防止采入其他量,打表和阈值(ACCURACY宏)如图:
#define CLOCKWISE_V 3400 #define ANTICLOCKWISE_V 3300 #define ENCODER_PRESSED_V 2800 #define KEY_V 1000 #define ACCURACY 1000
-
由于两种旋转当中的“电压台阶”过于相近,因此最后的代码当中,我选择放宽电压阈值,忽略这两种状态——并未使用正转和反转这两种行为,而是仅仅将:
-
旋转编码器按下: 设置为左键
-
按钮按下: 设置为打印项目要求的“eetree.cn”字符串。
-
-
-
摇杆部分:
-
使用Arduino的内置函数
pulseIN(<pin>,<level>)
进行采集,采集到的信号为电平为<level>
时在<pin>
引脚上产生的脉冲计数。因为摇杆位置不同时,X-Y之间产生的脉冲高电平时长不同,这里暂且使用这个高电平的值作为判断摇杆方向的依据。打表如下,被封装为一个枚举类型:typedef enum joyStickDest { Up = 1178, Up_Right = 1111, Right = 1384, Down_Right = 1815, Down = 2850, Down_Left = 3000, Left = 2640, Up_Left = 1650, CENTER = 2000 } joyStickDest;
-
但是其实相当难按出对应打表的值,因此添加一个容差,让我们更方便按出这些值,同时,斜上方的值更难按出,因此,添加一个容差系数,代码如下:
// 定义容差和容差系数OBLIQUE #define RANGE 1 //每次鼠标的运动范围 #define ACCURACY 40 #define OBLIQUE 4 //省略调试和过程代码,进入扫描函数 //pulsein被保存为duration变量 if (duration > Up_Left - ACCURACY * OBLIQUE && duration < Up_Left + ACCURACY * OBLIQUE) { return Up_Left; } else if (duration > Up - ACCURACY && duration < Up + ACCURACY) { return Up; } else if //之后的代码省略
- 此时再对方向进行值更新的封装(多层封装是因为为了测试能否在.h之中就操作外设,结果是不行,但是保留了这个利于解耦合的分层结构):
//解耦函数 void MoveMouse(joyStickDest joystick, int *x_dis, int *y_dis) { switch (joystick) { case Up: *x_dis = 0; *y_dis = -RANGE; break; case Up_Right: *x_dis = RANGE; *y_dis = -RANGE; break; //省略其他case //主程序业务代码 if (x_dis != 0 || y_dis != 0) { digitalWrite(LED2_PIN,HIGH); Mouse.move(x_dis, y_dis, 0); }
-
-
屏幕显示和其他杂项:
-
屏幕显示一开始想做的,也引入了
#include <Adafruit_ST7735.h>
这个库,但是发现,由于没有连接ESP32S2的SPI外设,而采用软件SPI的刷新速度特别慢,因此这个方案被放弃了。本来预期的显示效果如下:-
将屏幕取而代之的是使用指示灯来表示是否有操作,同时在程序开开始将屏幕刷新为黑屏 来判断是否正确进入主程序(部分时候,电脑并不会识别外设,因此屏幕默认是白色的)。
-
-
指示灯:使用定时器操作ESP32S2模组上的指示灯(即LED_GREEN,被分配在40脚),定时器更新时关闭指示灯,有操作时开启指示灯。(本来预计用扩展板上的灯,结果发现被硬件串口占了,就没使用)
void IRAM_ATTR led_fade_callback(){ digitalWrite(LED1_PIN,LOW); digitalWrite(LED2_PIN,LOW); //后来未使用串口灯,两个灯的宏被定义为同一个 }
- 具体效果为:有按键操作时,指示灯轻微亮一下;当操作摇杆时,灯常亮
-
引入DEBUG宏: 串口发送是相当占时间的,同时由于在Linux下开发,缺乏良好的串口绘图软件,因此希望一次只检测一个串口值,使用DEBUG宏控制各个文件当中监测量的输出,如:
#define DEBUG_JOYSTICK //在功能函数当中 #ifdef DEBUG_JOYSTICK Serial.printf("%ld\n", duration); //打印当前获取的脉冲数 #endif
-
框图和软件流程图 框图
软件流程图
实现的功能及图片展示 实现功能
-
可以根据摇杆输入实现鼠标的8相移动(斜上方和斜下方可能比较难按,但是正方向绝对没问题)。
-
旋转编码器按下与鼠标左键的功能等同,每次按下只持续一次点击。
-
按键按下可以在光标选定区域打印:
eetree.cn
图片展示
详情请看视频,图片静态效果比较差
鼠标残影拍的比较差,抱歉抱歉
打印EETREE
主要代码片段及说明
项目主程序:
#include "AnalogKeys.h"
#include "JoyStick.h"
#include "displayStatus.h"
#include "USB.h"
#include "USBHIDKeyboard.h"
#include "USBHIDMouse.h"
#include <Adafruit_GFX.h> // Core graphics library
#include <Adafruit_ST7735.h> // Hardware-specific library for ST7735
#define ARDUINO_FEATHER_ESP32
#define TFT_CS 13
#define TFT_RST 18
#define TFT_DC 17
#define TFT_MOSI 21 // Data out
#define TFT_SCLK 41 // Clock out
Adafruit_ST7735 tft = Adafruit_ST7735(TFT_CS, TFT_DC, TFT_MOSI, TFT_SCLK, TFT_RST);
#define LED1_PIN 40
#define LED2_PIN 40
void IRAM_ATTR led_fade_callback();
USBHIDMouse Mouse;
#define DEBUG_MAIN
USBHIDKeyboard Keyboard;
hw_timer_t* timer_analog = NULL;
hw_timer_t* timer_joystick = NULL;
hw_timer_t* timer_led_fade = NULL;
int count = 0;
AnalogReadKeys akeys;
void IRAM_ATTR analog_key_read();
int x_dis, y_dis;
void ARDUINO_ISR_ATTR joystick_read();
extern unsigned long duration;
joyStickDest joystickdst;
void setup() {
#ifdef DEBUG_MAIN
Serial.begin(115200);
#endif
//set the resolution to 12 bits (0-4096)
analogReadResolution(12);
Mouse.begin();
Keyboard.begin();
USB.begin();
pinMode(JOY_STICK_PIN, INPUT);
//调试灯
pinMode(LED1_PIN,OUTPUT);
pinMode(LED2_PIN,OUTPUT);
tft.initR(INITR_144GREENTAB); // Init ST7735R chip, green tab
tft.fillScreen(ST77XX_BLACK);
timer_analog = timerBegin(0, 80, true); //设置定时器0,80分频,定时器是否上下计数
timerAttachInterrupt(timer_analog, &analog_key_read, true); //定时器地址指针,中断函数名称,中断边沿触发类型
timerAlarmWrite(timer_analog, 4000, true); //操作那个定时器,定时时长单位us,是否自动重装载
timerAlarmEnable(timer_analog); //打开那个定时器
//摇杆控制函数
timer_joystick = timerBegin(1, 80, true);
timerAttachInterrupt(timer_joystick, &joystick_read, true);
timerAlarmWrite(timer_joystick, 7000, true);
timerAlarmEnable(timer_joystick);
timer_led_fade = timerBegin(2,80,true);
timerAttachInterrupt(timer_led_fade,&led_fade_callback,true);
timerAlarmWrite(timer_led_fade,10000,true);
timerAlarmEnable(timer_led_fade);
}
void loop() {
if (akeys.key == KEY && akeys.status == Pressed) {
count++;
// type out a message
digitalWrite(LED1_PIN,HIGH);
Keyboard.print("eetree.cn");
}
if (akeys.key == EncoderPressed && akeys.status == Pressed) {
if (!Mouse.isPressed(MOUSE_LEFT)) {
Mouse.press(MOUSE_LEFT);
digitalWrite(LED1_PIN,HIGH);
}
} else {
Mouse.release(MOUSE_LEFT);
}
if (x_dis != 0 || y_dis != 0) {
digitalWrite(LED2_PIN,HIGH);
Mouse.move(x_dis, y_dis, 0);
}
}
void IRAM_ATTR analog_key_read() {
akeys.analogValue = analogRead(1);
ScanKeys(&akeys);
}
void IRAM_ATTR joystick_read() {
joystickdst = scanJoyStick();
MoveMouse(joystickdst, &x_dis, &y_dis);
}
void IRAM_ATTR led_fade_callback(){
digitalWrite(LED1_PIN,LOW);
digitalWrite(LED2_PIN,LOW);
}
按键输入控制:
/*
* @Description:
* @Author: MALossov
* @Date: 2023-03-10 23:04:41
* @LastEditTime: 2023-03-12 19:58:18
* @LastEditors: MALossov
* @Reference:
*/
#ifndef _ANALOGKEYS_H_
#define _ANALOGKEYS_H_
// #define DEBUG_ANALOGKEYS
#define CLOCKWISE_V 3400
#define ANTICLOCKWISE_V 3300
#define ENCODER_PRESSED_V 2800
#define KEY_V 1000
#define ACCURACY 1000
typedef enum keys {
ClockWise,
AntiClockWise,
EncoderPressed,
KEY
}keys;
typedef enum key_status {
IDLE,
PrePressed,
OnPressed,
Pressed,
AfterPressed,
Released
}key_status;
typedef struct AnalogReadKeys {
int analogValue;
keys key;
key_status status;
}AnalogReadKeys;
key_status ModifyKey(key_status status_old);
void ScanKeys(AnalogReadKeys* akey);
/**
*@brief 更新传入的状态
*
* @param status_old 上一次的状态
* @return key_status 本次的状态
*/
key_status ModifyKey(key_status status_old) {
switch (status_old) {
case PrePressed:
return OnPressed;
break;
case OnPressed:
return Pressed;
break;
case Pressed:
return AfterPressed;
break;
case AfterPressed:
return AfterPressed;
break;
case Released:
return Released;
break;
default:
return IDLE;
break;
}
}
void ScanKeys(AnalogReadKeys* akey) {
if (akey->analogValue > 3600 && (akey->status == AfterPressed || akey->status == Pressed))
{
akey->status = Released;
}
if (akey->analogValue > CLOCKWISE_V && akey->analogValue < CLOCKWISE_V + ACCURACY) {
if (akey->key == ClockWise)
akey->status = ModifyKey(akey->status);
else
{
akey->key = ClockWise;
akey->status = PrePressed;
}
}
else if (akey->analogValue > ANTICLOCKWISE_V && akey->analogValue < ANTICLOCKWISE_V + ACCURACY) {
if (akey->key == AntiClockWise)
akey->status = ModifyKey(akey->status);
else
{
akey->key = AntiClockWise;
akey->status = PrePressed;
}
}
else if (akey->analogValue > ENCODER_PRESSED_V && akey->analogValue < ENCODER_PRESSED_V + ACCURACY) {
if (akey->key == EncoderPressed)
akey->status = ModifyKey(akey->status);
else
{
akey->key = EncoderPressed;
akey->status = PrePressed;
}
}
else if (akey->analogValue > KEY_V && akey->analogValue < KEY_V + ACCURACY) {
if (akey->key == KEY)
akey->status = ModifyKey(akey->status);
else
{
akey->key = KEY;
akey->status = PrePressed;
}
}
#ifdef DEBUG_ANALOGKEYS
Serial.println(akey->analogValue);
// if (akey->status == Pressed) {
// Serial.println(akey->key);
// }
#endif // DEBUG
}
#endif
摇杆输入控制:
#include <Arduino.h>
#ifndef _JOYSTICK_H_
#define _JOYSTICK_H_
#define JOY_STICK_PIN 2
#define RANGE 1
#define ACCURACY 40
#define OBLIQUE 4
#define DEBUG_JOYSTICK
typedef enum joyStickDest {
Up = 1178,
Up_Right = 1111,
Right = 1384,
Down_Right = 1815,
Down = 2850,
Down_Left = 3000,
Left = 2640,
Up_Left = 1650,
CENTER = 2000
} joyStickDest;
static unsigned long duration;
joyStickDest scanJoyStick() {
duration = pulseIn(JOY_STICK_PIN, HIGH);
#ifdef DEBUG_JOYSTICK
// if (joystick != CENTER) {
// Serial.print("joystick: ");
Serial.printf("%ld\n", duration);
// }
#endif
//判断duration的值与joyStickDest的值(误差允许为ACCURACY)
if (duration > Up_Left - ACCURACY * OBLIQUE && duration < Up_Left + ACCURACY * OBLIQUE) {
return Up_Left;
} else if (duration > Up - ACCURACY && duration < Up + ACCURACY) {
return Up;
} else if (duration > Up_Right - ACCURACY * OBLIQUE && duration < Up_Right + ACCURACY * OBLIQUE) {
return Up_Right;
} else if (duration > Right - ACCURACY && duration < Right + ACCURACY) {
return Right;
} else if (duration > Down_Right - ACCURACY * OBLIQUE && duration < Down_Right + ACCURACY * OBLIQUE) {
return Down_Right;
} else if (duration > Down - ACCURACY && duration < Down + ACCURACY) {
return Down;
} else if (duration > Down_Left - ACCURACY * OBLIQUE && duration < Down_Left + ACCURACY * OBLIQUE) {
return Down_Left;
} else if (duration > Left - ACCURACY && duration < Left + ACCURACY) {
return Left;
} else {
return CENTER;
}
}
void MoveMouse(joyStickDest joystick, int *x_dis, int *y_dis) {
switch (joystick) {
case Up:
*x_dis = 0;
*y_dis = -RANGE;
break;
case Up_Right:
*x_dis = RANGE;
*y_dis = -RANGE;
break;
case Right:
*x_dis = RANGE;
*y_dis = 0;
break;
case Down_Right:
*x_dis = RANGE;
*y_dis = RANGE;
break;
case Down:
*x_dis = 0;
*y_dis = RANGE;
break;
case Down_Left:
*x_dis = -RANGE;
*y_dis = RANGE;
break;
case Left:
*x_dis = -RANGE;
*y_dis = 0;
break;
case Up_Left:
*x_dis = -RANGE;
*y_dis = -RANGE;
break;
default:
*x_dis = 0;
*y_dis = 0;
break;
}
}
#endif
遇到的主要难题及解决方法
-
模拟量的获取和PWM脉冲的捕获都需要打表,打表后结果不准。
-
解决方法:重新打表,添加允许的阈值。
-
-
使用ESP-IDF开发发现,CDC和USB-HID库难以共存。
-
解决方法:换ARDUINO,技术能力暂时无法驾驭ESP-IDF。
-
-
调试多文件时串口结果难以判断是谁发出的。
-
解决方法:引入
DEBUG
宏,分别观看每个文件的情况。
-
-
模拟量按键容易出现一次按下,被判定按下多次的情况:
-
解决方法:引入状态机,定时任务获取模拟量。
-
本次开发当中,主要的困难和挑战在于技术路线的选择、开发工具的选择、校准一些需要打表的值。极度增进了我对于结构体、枚举类型的认知以及串口调试的能力。
当然,目前的打表肯定是一个折中的方案,妥协的产物,在未来,肯定需要继续精进对于模拟电路的认知,为摇杆的PWM方案给出更为科学合理的计算方案,而不是只能针对8个方向打表(还打的不准);同时在ANALOG-KEY当中也舍弃了正转和反转的输出,这些需要算法的加持。
未来的规划便是等能力足够之后,使用ESP-IDF重新开发本项目——精进PWM量的获取与摇杆关系的对应,不再使用打表而是使用计算,实现更好的鼠标移动效果;通过算法能够判断旋转编码器的正转与反转,而不是舍弃这一功能;同时解决一下USB-CDC和USB-HID无法公用的问题(自己创建USB协议栈)。
可以说,本次项目仅仅是完成了基础的功能而已,而没有对于这块板卡的潜力有着更好的开发,同时也照见了自己的很多不足——模拟电路分析能力差,算法功底不行,对于单片机内部存储结构理解有所欠缺。
未来,希望能够一一补上这些遗憾!