1. 项目缘起从“鼠标轨迹”到硬件交互的灵感最近在折腾一些桌面自动化的小玩意儿发现一个挺有意思的现象无论是写脚本模拟鼠标点击还是用一些远程控制工具我们总是把鼠标当成一个“黑盒”——只知道它移动了、点击了但它在屏幕上具体划过了哪些路径这些路径背后又有什么数据我们很少去关注。网上搜“鼠标轨迹”大多也是些软件层面的屏幕录制或绘画工具。这让我萌生了一个想法能不能把鼠标的物理移动实时地、可视化地在一个独立的硬件屏幕上显示出来就像给鼠标装上一个“足迹记录仪”。这个想法听起来有点“脱裤子放屁”毕竟电脑屏幕上光标本身就在动。但它的价值在于剥离与解耦。想象一下这些场景你在做UI自动化测试想直观地看到脚本执行时鼠标的移动路径是否符合预期但又不想被测试机本身的屏幕干扰或者你在设计一个体感控制器想将手势动作映射为屏幕上的轨迹进行调试甚至你只是想做一个极客风格的桌面摆件实时显示你手腕的“运动量”。这时一个独立于主显示系统之外的轨迹显示设备就很有用了。我手头正好有一块DFRobot的FireBeetle ESP32开发板它集成了一块小巧的OLED屏幕。ESP32强大的处理能力和无线功能让它成为实现这个想法的绝佳载体。我们的目标很明确让FireBeetle成为一个“鼠标移动显示器”实时接收来自电脑鼠标的移动数据并将其轨迹绘制在自己的OLED屏幕上。2. 核心方案选型为什么是“虚拟串口”“相对坐标”要实现这个目标首先要解决通信问题。鼠标数据如何从电脑传到FireBeetle通常有几种思路蓝牙HID直连让FireBeetle模拟成一个蓝牙鼠标接收器。这需要ESP32实现复杂的HID主机协议稳定性要求高开发难度较大且一对一的连接方式不够灵活。无线网络Wi-Fi电脑和FireBeetle接入同一局域网通过SocketTCP/UDP传输数据。这种方式灵活但需要配置网络在无网络或网络复杂的环境下不便。有线串口USB CDC通过USB线直接连接FireBeetle在电脑上识别为一个串行通信端口COM口。这是最稳定、最简单、延迟最低的方式。我选择了第三种USB虚拟串口。ESP32的芯片支持USB OTG可以通过编程使其在连接电脑时枚举成一个CDC通信设备类设备也就是一个虚拟的COM口。这样我们就能在电脑端用一个简单的程序读取鼠标数据然后通过这个“串口”发送给FireBeetle。方案简单粗暴但极其有效。接下来是数据协议。我们不需要鼠标的按键状态、滚轮信息只需要相对移动量ΔX, ΔY。在鼠标协议中每次报告都会包含这两个有符号的短整型数据代表自上次报告以来在X和Y方向上的移动距离。我们的协议可以设计得非常精简[起始符] [X高字节] [X低字节] [Y高字节] [Y低字节] [校验和]例如用0xAA作为起始符后面紧跟两个字节的X位移和两个字节的Y位移采用小端序最后加一个简单的异或校验和。这样一个数据包只有6个字节传输效率极高。为什么不用绝对坐标因为我们的OLED屏幕尺寸通常128x64和电脑屏幕分辨率如1920x1080完全不同且FireBeetle不知道电脑屏幕的边界。使用相对位移我们可以在FireBeetle内部维护一个“虚拟光标”的坐标不断根据收到的ΔX和ΔY进行累加并实现屏幕边缘的环绕或反弹逻辑这更简单也更有趣。3. 硬件准备与FireBeetle环境搭建工欲善其事必先利其器。首先来看看需要的硬件清单FireBeetle ESP32开发板带OLED款这是核心。它板载的SSD1306 OLED屏幕128x64像素是我们的显示窗口。注意要确认是I2C接口的0.96寸OLED屏。Micro-USB数据线用于供电和程序烧录同时也将作为数据通信的通道。电脑一台Windows, macOS 或 Linux 均可用于运行鼠标数据抓取程序和给FireBeetle编程。软件环境搭建主要在FireBeetle端安装Arduino IDE与ESP32开发板支持从Arduino官网下载并安装Arduino IDE。打开IDE进入“文件”-“首选项”在“附加开发板管理器网址”中添加https://espressif.github.io/arduino-esp32/package_esp32_index.json打开“工具”-“开发板”-“开发板管理器”搜索“esp32”安装由“Espressif Systems”提供的开发板支持包。安装完成后在“工具”-“开发板”中选择“DFRobot FireBeetle ESP32”。安装必要的库U8g2库这是驱动OLED屏的利器支持大量显示器型号图形功能强大。在“项目”-“加载库”-“管理库”中搜索“U8g2”并安装。ESP32USB库如果需要Arduino ESP32核心默认已包含USB CDC功能但为了更便捷地使用可能需要安装ESP32-USB-Software相关的库具体取决于核心版本。通常我们直接使用Serial对象即可因为当开发板通过USB连接时Serial默认指向的就是USB CDC虚拟串口。注意FireBeetle的OLED屏通常通过I2C驱动。使用U8g2库时需要根据具体接线选择正确的构造函数。对于FireBeetle Board-ESP32I2C引脚通常是 GPIO 21 (SDA), GPIO 22 (SCL)。初始化代码类似U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, /* reset*/ U8X8_PIN_NONE);4. FireBeetle端程序设计解析、绘图与显示逻辑FireBeetle端的固件是整个项目的大脑负责通信、数据处理和图形显示。其工作流程可以分解为以下几个核心环节4.1 串口数据接收与协议解析首先我们需要在setup()函数中初始化串口通信。这里的关键是设置一个较高的波特率以减少数据传输延迟。115200 或 921600 都是不错的选择。void setup() { Serial.begin(921600); // 使用USB CDC虚拟串口波特率设置高一些 // ... 初始化OLED屏幕 ... }在loop()函数中我们需要持续检查串口缓冲区是否有足够的数据我们的数据包是6字节。解析逻辑需要稳健能处理数据流中的错误和干扰。void loop() { // 寻找数据包起始符 while (Serial.available() 0 Serial.peek() ! 0xAA) { Serial.read(); // 丢弃非起始字节直到找到0xAA } if (Serial.available() 6) { // 确保有足够的数据 uint8_t packet[6]; Serial.readBytes(packet, 6); // 验证起始符和校验和 if (packet[0] 0xAA) { uint8_t checksum packet[1] ^ packet[2] ^ packet[3] ^ packet[4]; if (checksum packet[5]) { // 解析位移数据 (小端序) int16_t deltaX (packet[2] 8) | packet[1]; int16_t deltaY (packet[4] 8) | packet[3]; processMouseMovement(deltaX, deltaY); // 处理移动 } } } // ... 其他逻辑如屏幕刷新 ... }这里使用了一个简单的“滑动窗口”式解析器。它先对齐到帧头然后读取固定长度进行验证。对于可能的数据错位通过丢弃非帧头字节来重新同步这在串口通信中是一种常见且有效的策略。4.2 虚拟光标坐标更新与边界处理解析出deltaX和deltaY后我们需要更新内部维护的光标坐标cursorX和cursorY。int16_t cursorX 64; // 初始位置在屏幕中心 int16_t cursorY 32; const int16_t maxX 127; const int16_t maxY 63; void processMouseMovement(int16_t dx, int16_t dy) { cursorX dx; cursorY dy; // 边界处理方案1环绕 if (cursorX 0) cursorX maxX; if (cursorX maxX) cursorX 0; if (cursorY 0) cursorY maxY; if (cursorY maxY) cursorY 0; // 边界处理方案2反弹更接近物理感觉 // if (cursorX 0 || cursorX maxX) dx -dx; // if (cursorY 0 || cursorY maxY) dy -dy; // cursorX constrain(cursorX, 0, maxX); // cursorY constrain(cursorY, 0, maxY); }边界处理是体现“趣味性”的关键。简单的环绕像经典游戏《吃豆人》的屏幕实现起来容易。反弹逻辑则更模拟真实物理碰撞但需要额外记录速度矢量实现稍复杂。我这里选择了环绕因为它代码简洁且轨迹效果连续。实操心得直接累加鼠标原始数据会导致光标移动过快因为鼠标的DPI通常很高。必须引入一个“缩放因子”。例如将deltaX和deltaY除以一个系数如8或16再进行累加。这个系数需要根据你的鼠标DPI和想要的轨迹灵敏度反复调试。可以在协议中预留一个可配置的字段或者直接在FireBeetle代码里定义一个常量#define SENSITIVITY_DIVISOR 8。4.3 基于U8g2的轨迹绘制与屏幕管理有了光标坐标接下来就是在OLED上把它画出来。我们不仅要画当前的点还要保留历史轨迹形成一条“尾巴”。方案一点阵队列法。在内存中维护一个固定长度的坐标队列。每次更新光标位置就将新坐标入队并擦除最旧的坐标点画背景色然后绘制整个队列。这种方法轨迹长度固定内存占用可控。#define TRAIL_LENGTH 50 Point trail[TRAIL_LENGTH]; // Point是一个包含x,y的结构体 int trailIndex 0; void drawTrail() { u8g2.firstPage(); do { // 先清屏如果希望有拖尾效果也可以不清只擦除最旧的点 // u8g2.setDrawColor(0); // u8g2.drawBox(0, 0, 128, 64); u8g2.setDrawColor(1); for (int i 0; i TRAIL_LENGTH; i) { int idx (trailIndex i) % TRAIL_LENGTH; if (trail[idx].x 0) { // 有效点 // 可以根据i来设置点的大小或亮度实现渐变效果 u8g2.drawPixel(trail[idx].x, trail[idx].y); // 或者画小圆 // u8g2.drawDisc(trail[idx].x, trail[idx].y, 1); } } // 绘制当前光标一个更大的点或十字 u8g2.drawDisc(cursorX, cursorY, 2); } while (u8g2.nextPage()); }方案二帧缓冲差分法。利用U8g2的页面绘制模式在内存中保存上一帧的整个屏幕缓冲区当前帧只修改变化的部分擦除旧点绘制新点。这种方法适合轨迹实时变化且不需要保留很长历史的情况实现更复杂。我选择了方案一因为它概念简单效果直观且通过调整TRAIL_LENGTH可以轻松控制轨迹长度。为了视觉效果更好我让轨迹点的大小或亮度随着“年龄”衰减越旧的点越小或越暗这只需要在绘制循环中根据i计算一个大小值即可。4.4 性能优化与稳定性保障FireBeetle ESP32的主频高达240MHz处理这点图形和串口数据绰绰有余。但仍有优化点串口读取非阻塞使用Serial.available()和Serial.readBytes()的组合是阻塞的如果数据包不完整会等待超时。更好的做法是使用非阻塞方式将读取到的字节存入一个环形缓冲区然后在主循环中解析这个缓冲区。Arduino的Serial库底层有缓冲区但对于自定义协议自己管理一个环形缓冲区会更稳健。双缓冲绘制U8g2库的firstPage()/nextPage()循环本身是一种软件双缓冲可以避免屏幕闪烁。确保所有绘制操作都在这个循环内完成。降低刷新率OLED全屏刷新不需要太快。可以设置一个定时器每50ms刷新一次屏幕而不是每次收到数据都刷新。这能平衡流畅度和CPU占用。错误恢复在协议解析部分增加超时机制。如果收到起始符后在一定时间内如100ms没有收齐整个包则清空缓冲区重新开始寻找起始符防止解析状态机卡死。5. 电脑端数据抓取与发送程序实现FireBeetle准备好了现在需要电脑上的一个“桥梁”程序它负责抓取系统级的鼠标移动事件然后通过串口发送给我们定义好的协议。这里有一个关键选择如何抓取全局鼠标事件不同操作系统有不同的方法。Windows可以使用SetWindowsHookEx设置一个低级鼠标钩子WH_MOUSE_LL。这个钩子可以拦截系统所有的鼠标输入消息获取到MOUSEMOVE事件中的相对位移数据。macOS需要使用CGEventTapCreate来创建事件点击Event Tap监听kCGEventMouseMoved事件。Linux可以通过读取/dev/input/event*设备文件来获取原始输入事件但需要root权限。更通用的方法是使用X11库如Xlib或更高层的框架。为了跨平台和开发简便我强烈推荐使用Python结合PyAutoGUI和pyserial库。但需要注意PyAutoGUI的position()函数返回的是绝对坐标我们需要自己计算差值。不过有更底层的库如pynput可以直接监听鼠标移动事件并获取相对位移。下面是一个基于Pythonpynput和pyserial的示例程序框架import sys import struct from pynput import mouse import serial import serial.tools.list_ports # 查找FireBeetle的串口 def find_firebeetle_port(): ports list(serial.tools.list_ports.comports()) for port in ports: # 根据描述或PID/VID来识别FireBeetle ESP32通常显示为“USB Serial Device”或“CP210x” if CP210 in port.description or USB Serial in port.description: return port.device return None # 计算校验和 def calculate_checksum(x, y): # 将16位整数拆分为两个8位字节 x_bytes struct.pack(h, x) # 小端序 y_bytes struct.pack(h, y) checksum x_bytes[0] ^ x_bytes[1] ^ y_bytes[0] ^ y_bytes[1] return checksum # 鼠标移动事件回调 def on_move(x, y): # 注意pynput的on_move给的也是绝对坐标(x,y)。 # 我们需要自己计算相对位移。这里需要保存上一次的位置。 global last_x, last_y if last_x is not None and last_y is not None: delta_x x - last_x delta_y y - last_y # 应用缩放因子防止位移过大 delta_x int(delta_x / 8) delta_y int(delta_y / 8) if delta_x ! 0 or delta_y ! 0: # 打包数据 packet struct.pack(BhhB, 0xAA, delta_x, delta_y, 0) # 计算并替换校验和 checksum calculate_checksum(delta_x, delta_y) packet packet[:-1] struct.pack(B, checksum) # 发送数据 ser.write(packet) # print(fSent: dX{delta_x}, dY{delta_y}) last_x, last_y x, y if __name__ __main__: port find_firebeetle_port() if not port: print(未找到FireBeetle设备) sys.exit(1) ser serial.Serial(port, 921600, timeout1) print(f已连接到 {port}) last_x, last_y None, None # 开始监听鼠标事件 with mouse.Listener(on_moveon_move) as listener: try: listener.join() except KeyboardInterrupt: print(\n程序退出。) ser.close()踩坑实录使用pynput时最大的坑在于权限。在macOS和Linux上监听全局输入事件需要辅助功能权限Accessibility或root权限。在macOS上你需要到“系统偏好设置”-“安全性与隐私”-“隐私”-“辅助功能”中给你的终端或Python解释器加上勾选。否则程序会运行但收不到任何事件。更优的方案对于追求更低延迟和更精确控制的场景可以考虑用C/C编写本地程序直接调用系统API。例如在Windows上一个简单的控制台程序调用GetCursorPos并在循环中计算差值然后通过Windows的串口APICreateFile、WriteFile发送延迟可以做到极低10ms。但这牺牲了跨平台性。6. 功能扩展与创意玩法基础功能跑通后这个项目还有巨大的扩展空间轨迹可视化效果增强颜色/亮度渐变根据移动速度改变轨迹点的亮度或大小快速移动时轨迹更粗更亮。粒子效果将轨迹点视为粒子给它们一个衰减的生命周期和随机的消散方向。多种绘制模式除了点还可以画线、画圆甚至用Bresenham算法画直线来连接历史点。交互模式扩展按键切换模式在FireBeetle上接一个按钮单击切换显示模式如轨迹/清屏/显示统计信息。手势识别在FireBeetle端实现简单的算法识别连续的移动模式如画圈、画三角并通过串口反馈给电脑触发特定动作。无线化启用ESP32的蓝牙功能让电脑通过蓝牙串口SPP发送数据彻底摆脱线缆束缚。数据统计与反馈在OLED屏上分区域显示实时移动速度、总移动距离像素、光标当前坐标。统计单位时间内的“活动量”像一个简单的桌面健身指示器。与其他设备联动通过ESP32的Wi-Fi将轨迹数据发送到服务器或另一台设备实现多屏同步显示。当检测到长时间无移动用户离开时自动让屏幕进入低功耗睡眠模式。7. 项目总结与避坑指南回顾整个项目从构思到实现最关键的是理解数据流系统鼠标事件 - 电脑端程序抓取并格式化 - 串口传输 - FireBeetle解析并绘制。每个环节都有需要注意的细节。避坑指南串口通信乱码或丢包首要检查波特率确保电脑端和FireBeetle端的波特率设置完全一致。921600这样的高速率虽然好但有些USB转串口芯片或线材质量不佳可能导致不稳定可尝试降至115200。检查流控制在Serial.begin()和Python的serial.Serial()中确保都禁用硬件流控制RTS/CTS即设置为None。打包发送在电脑端不要一个字节一个字节地发送而是将整个数据包packet一次性写入串口。ser.write(packet)是原子性的能减少协议错位的概率。光标移动跳跃或卡顿缩放因子不对这是最常见的原因。鼠标DPI越高deltaX/Y绝对值越大。如果缩放因子太小光标一步就跨越大半个屏幕。需要根据你的鼠标在电脑端或FireBeetle端调整这个除数。屏幕刷新太慢如果FireBeetle端drawTrail()函数执行太慢比如绘制了太多复杂的图形会导致显示跟不上数据更新。优化绘图代码或降低数据发送频率。电脑端程序阻塞确保电脑端的鼠标事件回调函数执行速度很快不要在里面做复杂的计算或IO操作否则会拖慢整个事件循环导致发送延迟。FireBeetle程序上传失败确认开发板型号和端口选择正确。在上传时FireBeetle可能需要处于下载模式。通常需要按住板上的“BOOT”按钮再按一下“RST”按钮然后释放“BOOT”进入下载模式。有些新版FireBeetle可能支持自动下载。检查USB线是否只供电不传数据换一根可靠的数据线。轨迹显示异常如反向移动检查坐标系的处理。在OLED屏幕上原点(0,0)通常在左上角Y轴向下为正。而鼠标数据通常也是Y向下为正。但如果感觉移动反向可能是解析数据时的字节序大端/小端弄反了或者缩放因子用了负数。这个项目本身代码量不大但完整地串联了硬件交互、数据通信、图形显示和跨平台编程的几个关键知识点。它像一个微型的“物联网”应用原型感知鼠标、处理电脑程序、传输串口、执行与显示FireBeetle。当你看到OLED屏上的光点随着你手中的鼠标翩翩起舞时那种连接虚拟与现实的成就感正是硬件编程最大的乐趣所在。你可以基于这个框架轻松地替换“鼠标”为其他传感器如陀螺仪、操纵杆或者替换“OLED显示”为其他执行器如舵机、LED矩阵创造出更多有趣的互动装置。