手搓蓝牙机械键盘——基于ESP32、STM32、CH573

EE
5.3k 词

前言

最近拜樱之诗所赐对Galgame又有了兴趣,但长时间高强度推Gal是很累的,所以在研究更优雅的解决方案。

一个明显的事实是,绝大部分Galgame只需要很少几个键,除去开始的Load和结束的Save,鼠标更是几乎完全用不到,所以如果可以做一个只有几个键位的蓝牙键盘,把手臂从桌面上解放出来,就能轻松很多。而如果做成机械键盘,用起来或许还蛮有趣的?

简单想下必要的键位有:

  • Enter or Down : next
  • Ctrl : skip
  • Up : backlog

除此之外,我喜欢在有意思的情节或优秀的CG处截图,所以使用Snipaste配置的快捷键截图,并保存到指定目录的功能必不可少,因此需要一个键能一次完成下列键盘操作:

  1. Alt + Shift + S : 快捷键截图
  2. Enter:选择保存目录
  3. Enter:保存至指定目录

这样想来,只需要四个键位即可,刚好也还在一手能拿过来的范围内,就开搞吧。

主控

所谓的机械键盘,实际上就是一堆开关轴+PCB板,因此主要的难度在主控上。

基本逻辑其实很简单,单片机读取GPIO电平,当出现上升沿或下降沿时将对应键位的编码发送至上位机。实际的键盘主要技术在如何用较少的GPIO读取大量键位,也就是使用矩阵扫描来避免诸如鬼影之类的键盘问题。

pCvGTqP.png

当然,对于我这种如同手柄的键盘来讲,倒是不存在这个问题。

综上所述,问题的关键在于如何方便地让微处理器作为HID设备与上位机做蓝牙通信,这就主要属于IoT的领域了,因此主控选择非常重要。

ESP32主控

那么主控该用什么呢?理所当然的,我一开始打算使用宇宙第一IoT芯片ESP32。ESP32名气很大,但对IoT一无所知的我是从莎普蕾的单片机教程才知道的。

选用ESP32的原因很简单,开发板自带蓝牙+WIFI,丰富良好的开源社区,低廉的价格,方便的烧录方案,以及使用老师的公费可以随便买相关模块。说起来最近我本打算玩树莓派Pico,却发现各方面都被ESP32吊打,完全提不起兴趣,真搞不懂树莓派基金会搞这个是为了什么。

当然还有一个更重要的原因,ESP32有个非常不错的开源蓝牙键盘库,即ESP32-BLE-Keyboard,而且兼容Arduino IDE。我之前没搞过单片机的时候觉得Arduino逼格很高,但玩过STM32之后对它不怎么感兴趣了,主要是不能看到底层代码有点麻烦。

但是蓝牙协议栈相当复杂,如果能封装为库直接调用接口,肯定要简单很多,因此打算用Arduino IDE作为ESP32的主要开发工具。其实ESP32官方开发工具是ESP-IDF,全命令行操作,功能非常强大,但不容易上手,方便起见就用Arduino IDE入门了。

环境搭建

Arduino IDE开发ESP32需要下载对于的配置包和依赖库,ESP32需要下面这个:

pCj0Ymd.png

要安装这个库,需要先在开发板管理器网址导入ESP32官方依赖URL:https://espressif.github.io/arduino-esp32/package_esp32_index.json

但很可惜,即便配置了这个URL,上面的库八成是仍然下不动的,原因很简单,乐鑫的依赖大多托管于Github,国内下很不方便(诡异的是,即便我配了全局代理仍然下不动),所以我在安装库时直接断网:

pCjBObj.png

根据报错给出的URL一个一个把对应依赖下好,然后放在这个目录即可:

1
C:\Users\用户名\AppData\Local\Arduino15\staging\packages

然后点灯之类就很容易了。

ESP32蓝牙键盘库使用

这里再说下配置蓝牙键盘的外部依赖库,去上面的项目仓库下库文件压缩包,然后在项目-导入库-添加ZIP库即可,导入后能看到给出的蓝牙键盘例程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* This example turns the ESP32 into a Bluetooth LE keyboard that writes the words, presses Enter, presses a media key and then Ctrl+Alt+Delete
*/
#include <BleKeyboard.h>


void setup() {
Serial.begin(115200);
Serial.println("Starting BLE work!");
Keyboard.begin();
}

void loop() {
if(bleDevice.isConnected()) {
Serial.println("Sending 'Hello world'...");
Keyboard.print("Hello world");

delay(1000);

Serial.println("Sending Enter key...");
Keyboard.write(KEY_RETURN);

delay(1000);

Serial.println("Sending Play/Pause media key...");
Keyboard.write(KEY_MEDIA_PLAY_PAUSE);

delay(1000);
}

Serial.println("Waiting 5 seconds...");
delay(5000);
}

很简单明了的接口用法,实际上这个外部库是根据Arduino本身板子的USB HID库函数照着写的,所以直接去查Arudino官方的API文档就可以直接用。

然后依照这个例程,写个读取指定GPIO电平状态的功能模拟键盘也就很容易了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/**
* This example turns the ESP32 into a Bluetooth LE keyboard that writes the words, presses Enter, presses a media key and then Ctrl+Alt+Delete
*/
#define USE_NIBLE
#include <BleKeyboard.h>
#define PIN 23
#define LED 2
BleKeyboard bleKeyboard;

void setup() {
pinMode(PIN,INPUT_PULLUP);
pinMode(LED, OUTPUT);
Serial.begin(115200);
Serial.println("Starting BLE work!");
bleKeyboard.begin();
}


void loop() {
digitalWrite(LED,LOW);
if(bleKeyboard.isConnected()){
digitalWrite(LED,HIGH);
if(!digitalRead(PIN)){
Serial.println("Start Sending...");
bleKeyboard.write(KEY_RETURN);
delay(1500);
bleKeyboard.print("*******");
delay(600);
Serial.println("Sending Enter key...");
bleKeyboard.write(KEY_RETURN);

delay(5000);
}
// Serial.println("Sending Play/Pause media key...");
// bleKeyboard.write(KEY_MEDIA_PLAY_PAUSE);

// delay(1000);

//
// Below is an example of pressing multiple keyboard modifiers
// which by default is commented out.
//
/* Serial.println("Sending Ctrl+Alt+Delete...");
bleKeyboard.press(KEY_LEFT_CTRL);
bleKeyboard.press(KEY_LEFT_ALT);
bleKeyboard.press(KEY_DELETE);
delay(100);
bleKeyboard.releaseAll();
*/
}
}

这里还顺手写了个连接成功蓝牙后常亮LED的功能,刚好买的ESP32最便宜开发板上LED是蓝色的,然后烧进代码测试,这里没按键就用杜邦线跳线了,发现很顺利地完成了按键检测和键盘操作,用来输锁屏密码之类的真是相当方便,消抖就直接用长时间delay了。

结果麻烦来了,这个优秀的蓝牙键盘库有个致命的问题:已经配对过也连接过的蓝牙,在重新上电后无法连接,只能删掉之前的配置再重新配置才能连接。

这下就很不妙了,如果每次连接都要删掉再重新配对,那实在太麻烦了,老实说每次烧录都重新配对还能忍受,每次上电都不行就太不优雅了。直接调API的麻烦来了,看不到底层,自己修改当然不靠谱。没办法只能去原项目看issues,果然有不少人和我一样遇到这个麻烦。仔细翻了下,居然发现有人给出了一个他修改过的项目fork,而且耐心指导开issue的开发者删去之前的库,安装他修改后的依赖。真是完美的开源精神!具体issue参见这里

而且惊人的是,我按照这哥们的教程装上之后,居然完美解决了之前所有问题,真是非常厉害!

不过点进去一看,他的fork只有三个star,把几千star的项目最大bug解决,居然只有这么点star,让人唏嘘啊。

言归正传,修改后的程序代码其实差不太多:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/**
* This example turns the ESP32 into a Bluetooth LE keyboard & mouse.
* Writes the words, presses Enter, presses a media key.
* In the end showcase the mouse functions.
*/
#include <BleKeyboard.h>
#define PIN 23

void setup() {
pinMode(PIN, INPUT_PULLUP);

bleDevice.setName("ESP32 Combo"); //call before any of the begin functions to change the device name.

Keyboard.begin();
delay(2000);
}

void loop() {
if (bleDevice.isConnected()) {
if (!digitalRead(PIN)) {
Keyboard.write(KEY_RETURN);
delay(500);
Keyboard.print("*******");
delay(600);
Keyboard.write(KEY_RETURN);
delay(5000);
}
}

// Serial.println("Left click");
// Mouse.click(MOUSE_LEFT);
// delay(500);

// Serial.println("Right click");
// Mouse.click(MOUSE_RIGHT);
// delay(500);

// Serial.println("Scroll wheel click");
// Mouse.click(MOUSE_MIDDLE);
// delay(500);

// Serial.println("Back button click");
// Mouse.click(MOUSE_BACK);
// delay(500);

// Serial.println("Forward button click");
// Mouse.click(MOUSE_FORWARD);
// delay(500);

// Serial.println("Click left+right mouse button at the same time");
// Mouse.click(MOUSE_LEFT | MOUSE_RIGHT);
// delay(500);

// Serial.println("Click left+right mouse button and scroll wheel at the same time");
// Mouse.click(MOUSE_LEFT | MOUSE_RIGHT | MOUSE_MIDDLE);

// unsigned long currentMillis = millis();
// if(BatteryLevel>=4 && currentMillis - previousMillisBattery >= 3000) { // gradual discharge of the battery
// previousMillisBattery = currentMillis;
// BatteryLevel = BatteryLevel - 1;
// bleDevice.setBatteryLevel(BatteryLevel);
// }
// }
}

其实例程代码里还有一些其他设备电量之类的蓝牙设备配置,但没什么具体作用我就删去了。

硬件测试

至此软件部分就完全搞定了,非常简单而且效果很不赖。我觉得已经很完美,所以开始花功夫做硬件部分,我手头有一个废旧蓝牙耳机,拆下其中的电源部分拿到了600mAh/3.7V的锂电池,但3.7V不足以给ESP32供电,而且锂电池电压会随电量变化而不稳定,所以需要有个升压稳压的DC-DC电源模块

电源设计在硬件中是最复杂的部分,我也花功夫从莎普蕾的视频学了下Bark电源和开关稳压电源设计,但贴片元件手焊太累,用嘉立创的SMT又太贵,综合考量最廉价方便的选择就是干脆买块电源板,锂电池充放一体升压稳压电源板在淘宝大概五块包邮,而且很小巧方便。

既然直接用了成品模块,我想干脆也不画PCB,直接用洞洞板焊锡走线好了。

pCvJERJ.jpg

焊接水平很差,而且因为九块九买的便宜烙铁太难用,最后还丢人的飞线了,成品大概这样:

pCvJKZ6.jpg

按键是从捡来的荧光棒拆下来的,用来代替洞洞板插不上的机械键盘轴体,简单用万用表测下就知道四个引脚分别对应什么了。

简单测试下供电正常,也能正常用type-c给锂电池充电,供电后很快就能连接上蓝牙,按下按键也能正常发送HID键盘数据,可以说一切正常相当不错。

但拿这玩意玩了会樱之诗之后,发现了致命的问题:ESP32发热惊人,功耗实在太高了。600mAh的锂电池充满电,用不了几十分钟就没电了,当然好消息是稳压模块确实供电没问题。

只能续航这么短时间,而且板子上几个IC烫的要命(简直让我怀疑开发板主控的ESP32是不是引脚有短路),实在让人汗颜,当然是不能接受的,硬件当然没办法改,但程序代码是可以优化的,实际上上面的代码简单粗暴,问题很多,改下说不定能改善。

最明显的问题是高频率(当然是我猜的高频率)调用digitalRead()这个函数,改下每次循环的延迟时间,明显感觉到发热有差别,大概这个读取电平状态的函数功耗非常高。但优化很容易,不如说读取按键的一般思路就是外部中断,但麻烦的地方又来了,写了半天不习惯的Arduino写法的外部中断,仍然和蓝牙键盘库不兼容,最后好不容易搞定了,感觉发热也没有明显改善,功耗也是依旧高得惊人。最后上Github又找了个别人编译好的ESP32蓝牙键盘固件烧进去,仍然是难以接受的高功耗。

没办法,如此高的功耗当然不行,只能放弃ESP32主控了。宇宙第一IoT MCU功耗这么拉跨,让人有点失望,当然也有可能是我买的便宜开发板的问题,总之只能换主控了。

STM32主控

HC-05化身蓝牙HID设备

放弃ESP32以后,我重新开始考虑之前最熟悉的MCU STM32。STM32设计之初就严谨地考虑了功耗问题,而且我几乎没感觉工作时IC有什么发热的问题。

刚好,之前搞远程开关灯的时候买过一个蓝牙模块,即HC-05,当时十几块不觉得有什么,现在想想比不少开发板还贵,放着吃灰也太浪费了。

然而上网查了下才发现,HC-05模块是串口蓝牙,其中没有内置HID协议,没办法直接拿来做蓝牙键盘,但手写实现蓝牙HID协议栈又太麻烦了点,所以本打算放弃了。

不过转念一想,如果能在上位机开一个监听串口的服务,然后通过解析串口数据在本地操作HID数据呢?

这样一来岂不是就把串口数据在上位机转为HID数据了?

听起来很不赖,刚好操作HID数据这个需求我马上想到了python的pyautogui库,可以完美操作键盘鼠标,非常强大。随手搜了下又发现python居然还有直接操作串口的库pySerial,不得不说python真是太好用了。直接pip安装即可:

1
pip3 install pyserial

具体的接口可以参见这个API简单介绍

MCU端开发

当然,在写python之前,得把STM32的功能实现才行。实际上也很容易,HC05的蓝牙协议对单片机来说,只是一个于一般UART完全一样的串口而已,直接写串口通信就可以。

此外,检测按键的外部中断也不难——本以为如此,但写了才发现消抖不像听起来那么容易。

软件消抖就是直接HAL_Delay(),听起来很容易,但延迟太高会阻塞浪费性能,太低又起不到消抖的作用,总之有点麻烦。

言归正传,又回到我熟悉的STM32CubeMX+Keil的开发环境,果然还是比Arduino IDE舒服不少啊。

pCvW3Hf.png

只有一个键位的话,CubeMX配置如上图。

其实用到的GPIO很少:

  • 系统时钟
  • STLink调试
  • 蓝牙串口通信
  • 按键中断
  • LED

对了,有个地方忘记提到了,这里的STM32主控是用的学长那里继承来的核心板Black Pill

pCvfhWQ.jpg

虽说是最常见的STM32F103C8T6,但板子上的丝印却很不清楚,整体布局也和网上搜到的原理图不同,例如板载LED不是PC13,试了半天也没找到到底哪个GPIO是连LED的,诸如此类的问题让人很迷惑。不过这板子相当小,比一般的Blue/Black Pill核心板还要小,倒也算有点优势吧。

言归正传,按键中断虽然简单,只要写好中断回调函数,配置好初始化函数即可。但消抖却不那么容易,简单想下MCU读取按键的逻辑:

  • 按下键盘轴
  • 对应GPIO电平拉底
  • 触发按键中断
  • 进入中断回调——此时需要关闭中断,防止因抖动导致多次进入中断回调,同时又需要在执行完发送键盘编码数据后再开中断
  • 发送对应键位的编码数据

最麻烦的地方在关中断,因为单片机执行速度很快,如果按下时出现抖动,很有可能导致多次触发中断,因此需要在软件层在出现中断时进入main函数执行具体逻辑代码,同时关闭中断。仔细想下果然需要用设置标志位的办法实现读取下降沿电平的逻辑:

在中断回调函数中,利用HAL_GPIO_ReadPin()rising_falling_flag进行赋值,从而判断触发中断的是上升沿还是下降沿。

在主循环中,首先通过边沿检测标志rising_falling_flag来判断按键是处于按下还是松开的边沿,如果是下降的边沿(rising_falling_flag == GPIO_PIN_RESET)则将LED灯熄灭,如果是如果是上升的边沿(rising_falling_flag == GPIO_PIN_SET)则将LED灯点亮。为了防止误触发,通过边沿检测的判断之后,程序还会再对电平进行一次读取,确认下降沿后跟随的是低电平或者上升沿后跟随的是高电平,如果不是则不切换LED状态。

使用exit_flag来实现主循环和中断回调函数之间的互斥,保证中断处理函数中的功能(判断上升/下降沿)只在主循环完成判断之后进行,或者主循环的判断只在中断处理函数运行(即检测到了一次上升沿或者下降沿)之后再进行。

逻辑想明白,代码实现就很清晰了:

中断回调函数:

1
2
3
4
5
6
7
8
9
10
11
12
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == GPIO_PIN_0)
{
if (exit_flag == 0)
{
exit_flag = 1;
// 关中断
rising_falling_flag = HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_0);
}
}
}

main函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
while (1)
{
/* USER CODE END WHILE */

/* USER CODE BEGIN 3 */
if (exit_flag == 1)
{
exit_flag = 2;
if (rising_falling_flag == GPIO_PIN_RESET)
// 当进入回调逻辑代码时,读取的电平需要为低,否则为抖动
{
// 消抖
HAL_Delay(20);
if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_0) == GPIO_PIN_RESET)
// 此时再次读取电平,也需要为低
{
printf("0001");
}
}
exit_flag = 0;
}
// 增加一个简单的延时操作,以确保编译器不会优化掉整个循环(?)
for (volatile uint32_t i = 0; i < 1000; ++i)
{
__NOP();
}
}

比较奇怪的是,在没有下面的延时循环时,可以进入中断回调,但不会执行main函数里的逻辑代码。用ST-Link调试了才发现怎么都进入不到while循环里,问了下ChatGPT叫我加这个下面这个延时的循环,以避免被编译器优化掉,虽然没搞懂原因,但烧进代码去测试确实没问题了。

Python上位机开发

这个部分没什么技术含量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
import pyautogui
import serial
import serial.tools.list_ports
import keyboard,time
import bluetooth
# import ctypes

# whnd = ctypes.windll.kernel32.GetConsoleWindow()
# if whnd != 0:
# ctypes.windll.user32.ShowWindow(whnd, 0)
# ctypes.windll.kernel32.CloseHandle(whnd)

def list_serial():
print("正在连接蓝牙串口.........")
ports_list = list(serial.tools.list_ports.comports())
if len(ports_list) <= 0:
print("无串口设备。")
else:
print("可用的串口设备如下:")
for comport in ports_list:
print(list(comport)[0], list(comport)[1])


def discover_bluetooth_devices():
nearby_devices = bluetooth.discover_devices(duration=8, lookup_names=True, lookup_class=False, device_id=-1)
return nearby_devices

# 获取附近的蓝牙设备和名称
bluetooth_devices = discover_bluetooth_devices()

# 打印设备名称和MAC地址
for addr, name in bluetooth_devices:
print(f"设备名称: {name}, MAC地址: {addr}")


def get_ble_key():
ser = serial.Serial("COM14", 9600, 5)
if ser.isOpen(): # 判断串口是否成功打开
print("打开串口成功!")
print(ser.name) # 输出串口号
while True:
com_input = ser.read(4)
if com_input: # 如果读取结果非空,则输出
send_key(com_input)
else:
print("打开串口失败。")


def convert_scan_code_to_key(scan_code):
keys = keyboard.key_to_scan_codes(scan_code)
if keys:
return keys[0]
return None


def send_key(key):
print(key)
# pyautogui.keyDown('ctrl')
# pyautogui.keyDown('c')
# pyautogui.keyUp('c')
# pyautogui.keyUp('ctrl')
if key == b'0001':
pyautogui.keyDown('alt')
pyautogui.keyDown('shift')
pyautogui.keyDown('s')
pyautogui.keyUp('alt')
pyautogui.keyUp('shift')
pyautogui.keyUp('s')

pyautogui.press("enter")
pyautogui.press("enter")


if __name__ == '__main__':
list_serial()
while True:
try:
get_ble_key()
except:
pass

硬件测试

既然要用键盘轴,PCB打板实际上是必要的,我买了轴来才发现这个问题。

PCB设计没什么难度——本以为是这样,结果却出现了很多问题,下面是第一版PCB设计,走线布局虽然不怎么样,但看着也没什么问题,拿到板子焊接测试才发现搞错了重要的地方。

pPgNk40.png

居然把STM32 black pill的两个排母画反了!导致左右两边的排针需要扭过来才能正确对应走线……无语了,画PCB的时候本以为对照着原理图画的,还是旋转元件的时候把自己搞晕了。

然而问题不止如此,还发现有排母虽然可以冷拔插开发板,但让整块板子变得很臃肿,而且蓝牙模块无论怎么排布都感觉放到不到稳定的位置上,虽然花了很多功夫,但最后还是决定放弃STM32主控的方案,这块板子也废弃了(嘉立创帮大忙了)。

CH573主控

最后还是轮到主角出场了,本以为不会到轮到你的。

纠结一番之后买的CH573F的WeAct Studio开发板,比想象的还要小很多,非常漂亮的板子:

pPgNwVA.jpg

不过想把代码烧进去电灯却比STM32不友好的多,给的例程代码不是裸板的,这也是我第一次见到RTOS的代码,确实比直接写复杂很多。

CH573f用的RTOS是TMOS,是任务驱动的轮询结构操作系统:

pPgNUDH.png

搞明白TMOS是如何运作的,花了我快一星期的时间。官方文档写的很严谨,但不怎么好懂:

pPgN6xS.png

对于我来说需要搞明白的主要是蓝牙键盘相关的例程,也就是下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if(events & START_REPORT_EVT)
{
/********重要!*********/
//发送键盘数据的实际函数
hidEmuSendKbdReport(send_char);
send_char++;
if(send_char >= 30)
// Restart to the begin--a
send_char = 4;
hidEmuSendKbdReport(0x00);
// End of the key(否则会造成类似于未消抖的效果)
tmos_set_event(hidEmuTaskId, START_REPORT_EVT);
////这行代码实际上类似于递归调用
// 使用 tmos_start_task 函数重新启动启动报告事件定时器,间隔为 2000 毫秒。这样可以周期性地触发启动报告事件,以模拟连续的键盘输入
return (events ^ START_REPORT_EVT);
// 将已处理的启动报告事件从事件位图中移除,即更新事件队列
}

注释写好就好搞懂了,其实是类似于递归的逻辑。这段代码实现的功能是每隔一段时间发送英文字母,且从a-z循环。

搞明白任务和事件注册之后,就是常规的外部按键中断了,详细消抖逻辑可以参考这篇质量很高的文章

留言