20260508更新:

       一、经过这段时间的优化,抖音控制及纯空鼠功能已基本完善,后续计划中的功能也基本都实现。但因不同手机品牌、型号、系统对蓝牙HID设备的支持程度,以及屏幕尺寸、比例、分辨率都不同,基于抖音控制功能实现的原理所限,需要自己修改的参数较多。

       二、蓝牙空鼠方面,已适配IOS系统,但需打开辅助触控功能。

       三、目前正在测试续航时间,经过不断修改,功能和代码量不断增加,已经通过各种方式控制功耗。这段时间凭感觉估算,350mAh的锂电池,可用6-8小时左右。(仅抖音控制模式开机使用时间)

       四、PCB布局和走线还不合理,是下一步优化方向。

       五、对于以上优化后的全功能设备,将新开一贴来说明。敬请关注本人后续文章(但更新时间无法保证,我想一次性把抖音控制功能在iphone上的适配、续航延长、PCB优化等问题解决后再说)

20260318更新:

       经测试esp32-c3、esp32-c6均无法作为接收端,因为其无原生USB-OTG。只能作为发送端,所以接收端目前还得使用esp32-s3。

一、说明

       上接《ESP32-S3模拟无线空鼠(一)——蓝牙刷手机抖音篇》,实现了ESP32-S3模拟空鼠与手机等设备的蓝牙连接及刷抖音功能。

       本篇针对没有蓝牙的设备,利用两块ESP32+ESPNOW来实现无线空鼠功能。其中1个用于接收端接入被控设备(如电视、台式电脑等),相当于适配器。另一个用于发送端,当做无线空鼠。

       为什么接收端不用蓝牙连接,而是采用ESPNOW来实现?因为接收端的蓝牙+USBHID功能我没有测试成功,多次修改代码,蓝牙能收到发送端的数据,模拟USBHID也能控制电脑鼠标移动,但将两者一结合,就无效了。问了下AI,答复是要实现能够接受蓝牙HID设备的主机HID功能,目前ESP32相关蓝牙库中均无此协议栈,要实现得自己现编,复杂且容量巨大。可能esp32制造之初也没有想着其作为主机连接其他蓝牙HID设备吧,也没有必要。这个问题不再探索。

       之所以使用ESP32-S3,只是因为我手头刚好有几片、并且集成了蓝牙和wifi,可以极大减小成品的体积,其实这么简单的功能使用它有点浪费了。

二、前期失败的探索

       探索1:一开始的方案是用1块ESP32当作蓝牙适配器连接电视、台式电脑等无蓝牙的设备(我家电视比较老,只能插鼠标,没有蓝牙功能),用之前的刷抖音神器当作蓝牙空鼠,两个配对连接。但是使用arduino框架开发了接收端的程序,始终都无法成功连接蓝牙,我也懒得研究ESP-IDF能否实现了,就想换一种实现方式

       探索2:空鼠通过wifi连接局域网内需要控制的设备,(我家电视有wifi,台式电脑有无线网卡)。但是经测试,一是电视和电脑等设备需要专门开发接收端程序,二是延迟极高超过10秒,多次修改代码都没有改善,放弃

       后面才想起来既然使用的是ESP32,为何不使用其自有的ESPNOW来实现呢

三、硬件功能

       所需硬件和《ESP32-S3模拟无线空鼠(一)——蓝牙刷手机抖音篇》基本一致,就是需要多一块ESP32-S3-zero,开发环境的区别就是少装一个蓝牙库。

       1块ESP32-S3-zero开发板:用作接收端,相当与普通无线键鼠的适配器

       1块之前开发过的蓝牙刷手机抖音的蓝牙空鼠:硬件无变化可直接复用做发送端

四、开发环境

       参照《ESP32-S3模拟无线空鼠(一)——蓝牙刷手机抖音篇》搭建,步骤完全一致。如果无蓝牙需求,可以不安装蓝牙库。不过还是建议装上,万一后面想开发一个蓝牙、ESPNOW通用的空鼠呢?

五、接收端代码

       基本流程:单独的那块ESP32模拟成为USBHID设备,先通过wifi及ESPNOW协议接受发送端的数据,再将接受的数据转换为鼠标动作

       注意1:先将接收端代码编译、上传,然后按下复位键、打开串口监视器,查看并记录接收端MAC地址,因为此地址需要写入发送端中

       注意2:发送端、接收端的代码,需要分两个单独项目,因为arduino框架只允许一个项目包含一个同名的ino文件

       注意3:具体代码怎么编译、下载,还是参照《ESP32-S3模拟无线空鼠(一)——蓝牙刷手机抖音篇》

/*
 * ESP32-S3 空鼠系统 - 接收端
 * 
 * 功能:
 * 1. 通过 ESP-NOW 接收来自发送端的鼠标控制数据
 * 2. 将接收到的数据转换为 USB HID 鼠标指令
 * 3. 实现无线鼠标控制电脑的功能
 * 
 * 硬件连接:
 * - ESP32-S3 开发板
 * - 连接到电脑的 USB 端口
 * 
 * 依赖库:
 * - esp_now.h: ESP-NOW 通信库
 * - WiFi.h: WiFi 功能库
 * - USB.h: USB 功能库
 * - USBHIDMouse.h: USB HID 鼠标功能库
 */

#include <esp_now.h>
#include <WiFi.h>
#include <USB.h>
#include <USBHIDMouse.h>

// 定义 USB HID 鼠标对象
USBHIDMouse Mouse;

/*
 * 数据结构定义:
 * 用于 ESP-NOW 通信的鼠标控制数据
 */
typedef struct struct_message {
  int16_t moveX;     // X轴移动量(-32768 到 32767)
  int16_t moveY;     // Y轴移动量(-32768 到 32767)
  uint8_t buttons;   // 按键状态(位掩码)
                     // 位0: 左键 (0x01)
                     // 位1: 右键 (0x02)
                     // 位2: 中键 (0x04)
                     // 位3: 向上滚动 (0x08)
                     // 位4: 向下滚动 (0x10)
} struct_message;

// 全局变量:存储接收到的鼠标数据
struct_message mouseData;

// 全局变量:存储上一次按键状态,用于检测状态变化
bool prevLeft = false;
bool prevRight = false;
bool prevMiddle = false;

/*
 * ESP-NOW 数据接收回调函数
 * 当接收到发送端数据时自动调用
 */
void OnDataRecv(const esp_now_recv_info * info, const uint8_t *incomingData, int len) {
  // 将接收到的数据复制到全局变量
  memcpy(&mouseData, incomingData, sizeof(mouseData));
  
  // 调试信息:打印接收到的鼠标数据
  Serial.print("Received data - X:");
  Serial.print(mouseData.moveX);
  Serial.print(" Y:");
  Serial.print(mouseData.moveY);
  Serial.print(" Buttons:");
  Serial.println(mouseData.buttons);
  
  // 处理鼠标移动数据
  if (mouseData.moveX != 0 || mouseData.moveY != 0) {
    Mouse.move(mouseData.moveX, mouseData.moveY, 0);
  }
  
  // 解析按键状态
  bool left = (mouseData.buttons & 0x01) != 0;
  bool right = (mouseData.buttons & 0x02) != 0;
  bool middle = (mouseData.buttons & 0x04) != 0;
  bool scrollUp = (mouseData.buttons & 0x08) != 0;
  bool scrollDown = (mouseData.buttons & 0x10) != 0;
  
  // 处理左键状态变化
  if (left != prevLeft) {
    if (left) 
      Mouse.press(MOUSE_LEFT);  // 按下左键
    else 
      Mouse.release(MOUSE_LEFT); // 释放左键
    prevLeft = left;
  }
  
  // 处理右键状态变化
  if (right != prevRight) {
    if (right) 
      Mouse.press(MOUSE_RIGHT); // 按下右键
    else 
      Mouse.release(MOUSE_RIGHT); // 释放右键
    prevRight = right;
  }
  
  // 处理中键状态变化
  if (middle != prevMiddle) {
    if (middle) 
      Mouse.press(MOUSE_MIDDLE); // 按下中键
    else 
      Mouse.release(MOUSE_MIDDLE); // 释放中键
    prevMiddle = middle;
  }
  
  // 处理滚动事件
  if (scrollUp) 
    Mouse.move(0, 0, 3);    // 向上滚动(正值)
  if (scrollDown) 
    Mouse.move(0, 0, -3);   // 向下滚动(负值)
}

/*
 * 初始化函数
 * 系统启动时执行一次
 */
void setup() {
  // 第一步:初始化串口通信
  Serial.begin(115200);
  
  // 等待串口初始化完成
  delay(2000);
  
  // 测试串口是否工作正常
  Serial.println("Serial port initialized!");
  
  // 第二步:初始化 WiFi(用于 ESP-NOW 通信)
  Serial.println("Initializing WiFi...");
  WiFi.mode(WIFI_STA);  // 设置为 Station 模式
  
  // 等待 WiFi 初始化完成,最多尝试5次
  for (int i = 0; i < 5; i++) {
    delay(500);
    String mac = WiFi.macAddress();
    if (mac != "00:00:00:00:00:00") {
      break; // MAC地址获取成功,跳出循环
    }
    Serial.print(".");
  }
  
  // 打印设备 MAC 地址(用于发送端配置)
  Serial.print("\nAdapter MAC: ");
  String mac = WiFi.macAddress();
  if (mac == "00:00:00:00:00:00") {
    Serial.println("Failed to get MAC address!");
    Serial.println("Please check WiFi module and try again.");
  } else {
    Serial.println(mac);
  }
  
  // 第三步:初始化 ESP-NOW 通信
  Serial.println("Initializing ESP-NOW...");
  if (esp_now_init() != ESP_OK) {
    Serial.println("Error initializing ESP-NOW");
    return;
  }
  
  // 注册 ESP-NOW 数据接收回调函数
  if (esp_now_register_recv_cb(OnDataRecv) != ESP_OK) {
    Serial.println("Error registering callback");
    return;
  }
  
  // 第四步:初始化 USB HID 鼠标功能
  Serial.println("Initializing USB HID...");
  
  // 直接初始化鼠标功能
  // 注意:在某些库版本中,Mouse.begin() 会自动处理 USB 初始化
  Mouse.begin();
  
  // 短暂延迟确保初始化完成
  delay(500);
  
  // 初始化完成提示信息
  Serial.println("ESP-NOW USB HID Adapter ready!");
  Serial.println("Connect this ESP32 to PC via USB");
  Serial.println("Then update sender MAC address in esp32_airmouse_sender.ino");
}

/*
 * 主循环函数
 * 系统运行时持续执行
 */
void loop() {
  // 本系统中,所有工作都在 ESP-NOW 接收回调中完成
  // loop() 函数保持为空
}

代码运行后如图:记下MAC地址(我也不知道为啥用vscode的时候串口有时候不显示,所以用的arduinoIDE的串口监视器)

六、发送端代码

       注意:发送端代码中需要将接收端MAC地址填入,具体位置

// 接收端MAC地址(需要根据实际接收设备修改)
uint8_t receiverMAC[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
/*
 * ESP32 S3 空中鼠标发送器
 * 基于MPU6050传感器获取姿态数据,通过ESP-NOW无线通信发送鼠标控制指令
 * 可实现鼠标移动、左键、右键、中键点击以及滚轮上下滚动功能
 */

// 引入必要的库文件
#include <Wire.h>              // I2C通信库,用于与MPU6050通信
#include <esp_now.h>           // ESP-NOW无线通信库
#include <WiFi.h>              // WiFi功能库
#include "I2Cdev.h"            // I2C设备通信库
#include "MPU6050_6Axis_MotionApps20.h"  // MPU6050 DMP库,用于姿态解算

// 创建MPU6050对象
MPU6050 mpu;

// 定义I2C通信引脚
#define I2C_SDA 4              // SDA引脚,用于I2C数据传输
#define I2C_SCL 5              // SCL引脚,用于I2C时钟信号

// 定义鼠标按键引脚(使用内部上拉电阻)
const int PIN_LEFT = 7;        // 左键引脚
const int PIN_RIGHT = 8;       // 右键引脚
const int PIN_MIDDLE = 9;      // 中键引脚
const int PIN_SCROLL_UP = 12;  // 滚轮向上引脚
const int PIN_SCROLL_DOWN = 13;// 滚轮向下引脚

// MPU6050 DMP相关变量
bool DMPReady = false;         // DMP是否准备就绪
uint8_t devStatus;             // DMP初始化状态
uint16_t packetSize;           // DMP数据包大小
uint8_t FIFOBuffer[64];        // FIFO缓冲区,用于存储DMP输出数据

// 姿态数据变量
Quaternion q;                  // 四元数,用于表示设备姿态
VectorFloat gravity;           // 重力向量
float ypr[3];                  // 欧拉角:偏航角(yaw)、俯仰角(pitch)、滚转角(roll),单位为弧度

// 上一次的姿态角度
float lastYaw = 0.0;           // 上一次的偏航角
float lastRoll = 0.0;          // 上一次的滚转角

// 鼠标灵敏度参数
#define MOUSE_SENSITIVITY 2.8  // 鼠标移动灵敏度系数
#define ANGLE_THRESHOLD 0.1    // 角度变化阈值,小于此值则不移动鼠标

// 按键状态变量
bool leftPressed = false;      // 左键是否按下
bool rightPressed = false;     // 右键是否按下
bool middlePressed = false;    // 中键是否按下
bool scrollUpPressed = false;  // 滚轮向上是否按下
bool scrollDownPressed = false;// 滚轮向下是否按下

// 定义ESP-NOW通信数据结构
typedef struct struct_message {
  int16_t moveX;               // X轴移动量
  int16_t moveY;               // Y轴移动量
  uint8_t buttons;             // 按键状态(位掩码)
} struct_message;

struct_message mouseData;      // 鼠标数据结构体实例

// 接收端MAC地址(需要根据实际接收设备修改)
uint8_t receiverMAC[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};

/**
 * @brief 检查所有鼠标按键状态
 * 读取各个按键引脚的状态,更新按键状态变量
 */
void checkButtons() {
  leftPressed = (digitalRead(PIN_LEFT) == LOW);       // 读取左键状态
  rightPressed = (digitalRead(PIN_RIGHT) == LOW);     // 读取右键状态
  middlePressed = (digitalRead(PIN_MIDDLE) == LOW);   // 读取中键状态
  scrollUpPressed = (digitalRead(PIN_SCROLL_UP) == LOW); // 读取滚轮向上状态
  scrollDownPressed = (digitalRead(PIN_SCROLL_DOWN) == LOW); // 读取滚轮向下状态
}

/**
 * @brief 通过ESP-NOW发送鼠标数据
 * 将按键状态编码为位掩码,并发送鼠标移动和按键数据到接收端
 */
void sendData() {
  // 初始化按键状态位掩码
  mouseData.buttons = 0;
  
  // 设置各个按键对应的位(使用位或运算)
  if (leftPressed) mouseData.buttons |= 0x01;        // 左键对应第0位
  if (rightPressed) mouseData.buttons |= 0x02;       // 右键对应第1位
  if (middlePressed) mouseData.buttons |= 0x04;      // 中键对应第2位
  if (scrollUpPressed) mouseData.buttons |= 0x08;    // 滚轮向上对应第3位
  if (scrollDownPressed) mouseData.buttons |= 0x10;  // 滚轮向下对应第4位
  
  // 发送数据到接收端
  esp_err_t result = esp_now_send(receiverMAC, (uint8_t *) &mouseData, sizeof(mouseData));
  
  // 检查发送结果
  if (result == ESP_OK) {
    // Serial.println("Data sent successfully");  // 发送成功(注释掉以减少串口输出)
  } else {
    Serial.print("Error sending data: ");  // 发送失败,打印错误信息
    Serial.println(result);
  }
}

/**
 * @brief 初始化函数
 * 配置引脚、初始化MPU6050和DMP、设置ESP-NOW通信
 */
void setup() {
  // 初始化串口通信
  Serial.begin(115200);
  
  // 配置按键引脚为输入模式,启用内部上拉电阻
  pinMode(PIN_LEFT, INPUT_PULLUP);
  pinMode(PIN_RIGHT, INPUT_PULLUP);
  pinMode(PIN_MIDDLE, INPUT_PULLUP);
  pinMode(PIN_SCROLL_UP, INPUT_PULLUP);
  pinMode(PIN_SCROLL_DOWN, INPUT_PULLUP);
  
  // 初始化I2C通信
  #if I2CDEV_IMPLEMENTATION == I2CDEV_ARDUINO_WIRE
    Wire.begin(I2C_SDA, I2C_SCL);  // 使用指定的引脚初始化I2C
    Wire.setClock(400000);          // 设置I2C时钟频率为400kHz
  #elif I2CDEV_IMPLEMENTATION == I2CDEV_BUILTIN_FASTWIRE
    Fastwire::setup(400, true);     // 使用Fastwire库初始化I2C
  #endif
  
  // 初始化MPU6050
  Serial.println("Initializing MPU6050...");
  mpu.initialize();
  
  // 检查MPU6050连接
  if (!mpu.testConnection()) {
    Serial.println("MPU6050 connection failed!");
    while (1);  // 连接失败,进入死循环
  }
  
  // 初始化DMP(数字运动处理器)
  devStatus = mpu.dmpInitialize();
  
  // 设置MPU6050校准偏移量(可根据实际校准结果调整)
  mpu.setXGyroOffset(0);
  mpu.setYGyroOffset(0);
  mpu.setZGyroOffset(0);
  mpu.setXAccelOffset(0);
  mpu.setYAccelOffset(0);
  mpu.setZAccelOffset(0);
  
  // 检查DMP初始化状态
  if (devStatus == 0) {
    // DMP初始化成功,进行校准
    Serial.println("Calibrating...");
    mpu.CalibrateAccel(6);  // 校准加速度计(6次)
    mpu.CalibrateGyro(6);   // 校准陀螺仪(6次)
    Serial.println("Calibration done!");
    
    // 启用DMP
    mpu.setDMPEnabled(true);
    DMPReady = true;
    packetSize = mpu.dmpGetFIFOPacketSize();  // 获取DMP数据包大小
    Serial.println("DMP ready!");
  } else {
    // DMP初始化失败,打印错误代码
    Serial.print("DMP init failed: ");
    Serial.println(devStatus);
    while (1);  // 初始化失败,进入死循环
  }
  
  // 设置WiFi模式为STA(Station)
  WiFi.mode(WIFI_STA);
  
  // 初始化ESP-NOW
  if (esp_now_init() != ESP_OK) {
    Serial.println("Error initializing ESP-NOW");
    return;
  }
  
  // 配置接收端信息
  esp_now_peer_info_t peerInfo = {};
  memcpy(peerInfo.peer_addr, receiverMAC, 6);  // 复制接收端MAC地址
  peerInfo.channel = 0;                        // 使用当前WiFi信道
  peerInfo.encrypt = false;                    // 不使用加密
  peerInfo.ifidx = WIFI_IF_STA;                // 使用STA接口
  
  // 添加接收端到ESP-NOW对等列表
  esp_err_t addStatus = esp_now_add_peer(&peerInfo);
  if (addStatus == ESP_OK) {
    Serial.println("Peer added successfully");
  } else {
    Serial.print("Error adding peer: ");
    Serial.println(addStatus);
  }
  
  // 初始化完成
  Serial.println("ESP-NOW AirMouse Sender ready!");
  Serial.print("My MAC: ");
  Serial.println(WiFi.macAddress());  // 打印发送端MAC地址
}

/**
 * @brief 主循环函数
 * 读取MPU6050姿态数据,计算鼠标移动量,检查按键状态,发送数据
 */
void loop() {
  // 检查DMP是否就绪,并获取最新的FIFO数据包
  if (DMPReady && mpu.dmpGetCurrentFIFOPacket(FIFOBuffer)) {
    // 从FIFO数据中获取四元数
    mpu.dmpGetQuaternion(&q, FIFOBuffer);
    // 计算重力向量
    mpu.dmpGetGravity(&gravity, &q);
    // 从四元数和重力向量计算欧拉角(偏航角、俯仰角、滚转角)
    mpu.dmpGetYawPitchRoll(ypr, &q, &gravity);
    
    // 将弧度转换为角度
    ypr[0] *= 180 / M_PI;  // yaw
    ypr[1] *= 180 / M_PI;  // pitch
    ypr[2] *= 180 / M_PI;  // roll
    
    // 计算角度变化量
    float yawDiff = ypr[0] - lastYaw;   // 偏航角变化量(左右移动)
    float rollDiff = ypr[2] - lastRoll; // 滚转角变化量(上下移动)
    
    // 检查角度变化是否超过阈值,避免微小抖动
    if (abs(yawDiff) > ANGLE_THRESHOLD || abs(rollDiff) > ANGLE_THRESHOLD) {
      // 计算鼠标移动量(乘以灵敏度系数)
      mouseData.moveX = (int16_t)(yawDiff * MOUSE_SENSITIVITY * 5);
      mouseData.moveY = (int16_t)(-rollDiff * MOUSE_SENSITIVITY * 5);
    } else {
      // 角度变化过小,不移动鼠标
      mouseData.moveX = 0;
      mouseData.moveY = 0;
    }
    
    // 更新上一次的角度值
    lastYaw = ypr[0];
    lastRoll = ypr[2];
    
    // 检查按键状态
    checkButtons();
    
    // 发送鼠标数据
    sendData();
  }
  
  // 短暂延迟,减少CPU占用
  delay(10);
}

七、调试、测试

       其实编译、上传完接收端、发送端代码,无线空鼠功能已经可以使用了,剩下的就是微调参数提高空鼠的稳定性和灵敏度了

// 鼠标灵敏度参数
#define MOUSE_SENSITIVITY 2.8  // 鼠标移动灵敏度系数
#define ANGLE_THRESHOLD 0.1    // 角度变化阈值,小于此值则不移动鼠标

     

八、后续计划

       1. 整合蓝牙刷抖音功能、纯蓝牙空鼠功能和ESPNOW无线空鼠功能,通过增加开关按钮、显示屏等方式实现功能按需切换

       2. 探索接收端、发送端配对功能,即无需先得到接收端MAC地址并写入发送端代码中,通过配对按钮自动完成两端的连接

       3. 进一步降低成本,采用更便宜的ESP32模组或开发板,如ESP32-C3等

       4. 接收端增加type-C to USB-A的转换器,用于和电视、台式电脑连接。实际上下载程序用的数据线也能当做转换器

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐