前言
最近拜樱之诗所赐对Galgame又有了兴趣,但长时间高强度推Gal是很累的,所以在研究更优雅的解决方案。
一个明显的事实是,绝大部分Galgame只需要很少几个键,除去开始的Load和结束的Save,鼠标更是几乎完全用不到,所以如果可以做一个只有几个键位的蓝牙键盘,把手臂从桌面上解放出来,就能轻松很多。而如果做成机械键盘,用起来或许还蛮有趣的?
简单想下必要的键位有:
- Enter or Down : next
- Ctrl : skip
- Up : backlog
除此之外,我喜欢在有意思的情节或优秀的CG处截图,所以使用Snipaste配置的快捷键截图,并保存到指定目录的功能必不可少,因此需要一个键能一次完成下列键盘操作:
- Alt + Shift + S : 快捷键截图
- Enter:选择保存目录
- Enter:保存至指定目录
这样想来,只需要四个键位即可,刚好也还在一手能拿过来的范围内,就开搞吧。
主控
所谓的机械键盘,实际上就是一堆开关轴+PCB板,因此主要的难度在主控上。
基本逻辑其实很简单,单片机读取GPIO电平,当出现上升沿或下降沿时将对应键位的编码发送至上位机。实际的键盘主要技术在如何用较少的GPIO读取大量键位,也就是使用矩阵扫描来避免诸如鬼影之类的键盘问题。
当然,对于我这种如同手柄的键盘来讲,倒是不存在这个问题。
综上所述,问题的关键在于如何方便地让微处理器作为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需要下面这个:
要安装这个库,需要先在开发板管理器网址导入ESP32官方依赖URL:https://espressif.github.io/arduino-esp32/package_esp32_index.json
但很可惜,即便配置了这个URL,上面的库八成是仍然下不动的,原因很简单,乐鑫的依赖大多托管于Github,国内下很不方便(诡异的是,即便我配了全局代理仍然下不动),所以我在安装库时直接断网:
根据报错给出的URL一个一个把对应依赖下好,然后放在这个目录即可:
1 | C:\Users\用户名\AppData\Local\Arduino15\staging\packages |
然后点灯之类就很容易了。
ESP32蓝牙键盘库使用
这里再说下配置蓝牙键盘的外部依赖库,去上面的项目仓库下库文件压缩包,然后在项目-导入库-添加ZIP库即可,导入后能看到给出的蓝牙键盘例程:
1 | /** |
很简单明了的接口用法,实际上这个外部库是根据Arduino本身板子的USB HID库函数照着写的,所以直接去查Arudino官方的API文档就可以直接用。
然后依照这个例程,写个读取指定GPIO电平状态的功能模拟键盘也就很容易了:
1 | /** |
这里还顺手写了个连接成功蓝牙后常亮LED的功能,刚好买的ESP32最便宜开发板上LED是蓝色的,然后烧进代码测试,这里没按键就用杜邦线跳线了,发现很顺利地完成了按键检测和键盘操作,用来输锁屏密码之类的真是相当方便,消抖就直接用长时间delay了。
结果麻烦来了,这个优秀的蓝牙键盘库有个致命的问题:已经配对过也连接过的蓝牙,在重新上电后无法连接,只能删掉之前的配置再重新配置才能连接。
这下就很不妙了,如果每次连接都要删掉再重新配对,那实在太麻烦了,老实说每次烧录都重新配对还能忍受,每次上电都不行就太不优雅了。直接调API的麻烦来了,看不到底层,自己修改当然不靠谱。没办法只能去原项目看issues,果然有不少人和我一样遇到这个麻烦。仔细翻了下,居然发现有人给出了一个他修改过的项目fork,而且耐心指导开issue的开发者删去之前的库,安装他修改后的依赖。真是完美的开源精神!具体issue参见这里。
而且惊人的是,我按照这哥们的教程装上之后,居然完美解决了之前所有问题,真是非常厉害!
不过点进去一看,他的fork只有三个star,把几千star的项目最大bug解决,居然只有这么点star,让人唏嘘啊。
言归正传,修改后的程序代码其实差不太多:
1 | /** |
其实例程代码里还有一些其他设备电量之类的蓝牙设备配置,但没什么具体作用我就删去了。
硬件测试
至此软件部分就完全搞定了,非常简单而且效果很不赖。我觉得已经很完美,所以开始花功夫做硬件部分,我手头有一个废旧蓝牙耳机,拆下其中的电源部分拿到了600mAh/3.7V的锂电池,但3.7V不足以给ESP32供电,而且锂电池电压会随电量变化而不稳定,所以需要有个升压稳压的DC-DC电源模块。
电源设计在硬件中是最复杂的部分,我也花功夫从莎普蕾的视频学了下Bark电源和开关稳压电源设计,但贴片元件手焊太累,用嘉立创的SMT又太贵,综合考量最廉价方便的选择就是干脆买块电源板,锂电池充放一体升压稳压电源板在淘宝大概五块包邮,而且很小巧方便。
既然直接用了成品模块,我想干脆也不画PCB,直接用洞洞板焊锡走线好了。
焊接水平很差,而且因为九块九买的便宜烙铁太难用,最后还丢人的飞线了,成品大概这样:
按键是从捡来的荧光棒拆下来的,用来代替洞洞板插不上的机械键盘轴体,简单用万用表测下就知道四个引脚分别对应什么了。
简单测试下供电正常,也能正常用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舒服不少啊。
只有一个键位的话,CubeMX配置如上图。
其实用到的GPIO很少:
- 系统时钟
- STLink调试
- 蓝牙串口通信
- 按键中断
- LED
对了,有个地方忘记提到了,这里的STM32主控是用的学长那里继承来的核心板Black Pill:
虽说是最常见的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 | void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) |
main函数:
1 | while (1) |
比较奇怪的是,在没有下面的延时循环时,可以进入中断回调,但不会执行main函数里的逻辑代码。用ST-Link调试了才发现怎么都进入不到while循环里,问了下ChatGPT叫我加这个下面这个延时的循环,以避免被编译器优化掉,虽然没搞懂原因,但烧进代码去测试确实没问题了。
Python上位机开发
这个部分没什么技术含量:
1 | import pyautogui |
硬件测试
既然要用键盘轴,PCB打板实际上是必要的,我买了轴来才发现这个问题。
PCB设计没什么难度——本以为是这样,结果却出现了很多问题,下面是第一版PCB设计,走线布局虽然不怎么样,但看着也没什么问题,拿到板子焊接测试才发现搞错了重要的地方。
居然把STM32 black pill的两个排母画反了!导致左右两边的排针需要扭过来才能正确对应走线……无语了,画PCB的时候本以为对照着原理图画的,还是旋转元件的时候把自己搞晕了。
然而问题不止如此,还发现有排母虽然可以冷拔插开发板,但让整块板子变得很臃肿,而且蓝牙模块无论怎么排布都感觉放到不到稳定的位置上,虽然花了很多功夫,但最后还是决定放弃STM32主控的方案,这块板子也废弃了(嘉立创帮大忙了)。
CH573主控
最后还是轮到主角出场了,本以为不会到轮到你的。
纠结一番之后买的CH573F的WeAct Studio开发板,比想象的还要小很多,非常漂亮的板子:
不过想把代码烧进去电灯却比STM32不友好的多,给的例程代码不是裸板的,这也是我第一次见到RTOS的代码,确实比直接写复杂很多。
CH573f用的RTOS是TMOS,是任务驱动的轮询结构操作系统:
搞明白TMOS是如何运作的,花了我快一星期的时间。官方文档写的很严谨,但不怎么好懂:
对于我来说需要搞明白的主要是蓝牙键盘相关的例程,也就是下面这段代码:
1 | if(events & START_REPORT_EVT) |
注释写好就好搞懂了,其实是类似于递归的逻辑。这段代码实现的功能是每隔一段时间发送英文字母,且从a-z循环。
搞明白任务和事件注册之后,就是常规的外部按键中断了,详细消抖逻辑可以参考这篇质量很高的文章。