ESP32-S3实现KVM远控+云玩功能 完整方案

ESP32-S3实现KVM远控+云玩功能 完整方案

一、系统整体架构

1.键鼠事件捕获
2.MQTT发送键鼠指令
3.转发至公网
4.推送指令
5.虚拟USB键鼠指令
6.FFmpeg推流(屏幕画面)
7.转发流媒体
心跳包
心跳包
本地Python客户端
PyQt6 GUI + pyautogui
FRP网络穿透
EMQX MQTT服务器
ESP32-S3
游戏服务器主机
Python客户端流媒体显示

核心模块说明

模块 功能 技术栈
MQTT服务器 收发键鼠指令、心跳包,实现客户端与ESP32的双向通信 EMQX(开源MQTT服务器)
网络穿透 解决内网ESP32/游戏服务器的公网访问问题 FRP(开源内网穿透工具)
ESP32-S3 虚拟USB HID键鼠、接收MQTT键鼠指令、解析并模拟键鼠操作 Arduino框架 + USB HID + PubSubClient
Python客户端 GUI界面、捕获本地键鼠事件、MQTT发送指令、接收并显示流媒体画面 PyQt6 + paho-mqtt + OpenCV + pyautogui
游戏服务器流媒体 捕获屏幕画面,通过FFmpeg推流至公网 FFmpeg(跨平台音视频处理工具)

二、环境准备

硬件清单

  • ESP32-S3开发板(需支持USB OTG/Device模式)
  • 游戏服务器主机(Windows/Linux,需开启USB调试、允许屏幕捕获)
  • 本地控制端电脑(Windows/macOS/Linux)
  • USB数据线(ESP32-S3 ↔ 游戏服务器主机)

软件依赖

设备/模块 依赖安装命令/步骤
本地Python环境 pip install PyQt6 paho-mqtt opencv-python pyautogui ffmpeg-python numpy
ESP32开发环境 Arduino IDE中安装ESP32板库(版本≥2.0.10),安装PubSubClient库(版本≥2.8)
服务器(Linux) 安装EMQX:sudo apt install emqx;安装FRP:下载对应版本解压至/usr/local/frp
游戏服务器 安装FFmpeg:Windows下载解压并配置环境变量;Linux:sudo apt install ffmpeg

三、MQTT服务器搭建(EMQX)

1. 服务器端部署(Linux云服务器)

bash 复制代码
# 1. 安装EMQX(以Ubuntu 20.04为例)
curl -s https://assets.emqx.com/scripts/install-emqx-deb.sh | sudo bash
# 2. 启动EMQX服务
sudo systemctl start emqx
# 3. 设置开机自启
sudo systemctl enable emqx
# 4. 验证启动(默认端口1883,管理端口18083)
sudo systemctl status emqx

2. EMQX配置(可选,基础配置即可用)

  • 访问http://服务器IP:18083,默认账号admin,密码public
  • 无需修改默认配置,确保服务器安全组开放1883(MQTT)、18083(管理)端口

四、FRP网络穿透搭建(解决内网访问)

1. 云服务器端(FRP服务端)配置

(1)下载并解压FRP
bash 复制代码
# 下载对应版本(以amd64为例)
wget https://github.com/fatedier/frp/releases/download/v0.51.3/frp_0.51.3_linux_amd64.tar.gz
tar -zxvf frp_0.51.3_linux_amd64.tar.gz
mv frp_0.51.3_linux_amd64 /usr/local/frp
cd /usr/local/frp
(2)修改FRP服务端配置文件frps.ini
ini 复制代码
[common]
bind_port = 7000  # FRP核心通信端口
token = 12345678  # 认证令牌,需与客户端一致
dashboard_port = 7500  # 管理面板端口
dashboard_user = admin  # 管理面板账号
dashboard_pwd = 123456  # 管理面板密码
(3)启动FRP服务端
bash 复制代码
# 后台启动
nohup ./frps -c ./frps.ini > frps.log 2>&1 &
# 开机自启(可选)
echo "/usr/local/frp/frps -c /usr/local/frp/frps.ini" >> /etc/rc.local

2. 游戏服务器端(FRP客户端,内网穿透ESP32和流媒体)

(1)下载FRP(Windows版本)并修改frpc.ini
ini 复制代码
[common]
server_addr = 云服务器IP  # 替换为你的云服务器IP
server_port = 7000        # FRP服务端bind_port
token = 12345678          # 与服务端一致

# MQTT转发(ESP32的MQTT通信)
[mqtt_esp32]
type = tcp
local_ip = 127.0.0.1
local_port = 1883  # 若游戏服务器本地有MQTT,可转发;若无则跳过
remote_port = 18830 # 公网访问端口

# 流媒体转发(FFmpeg推流端口)
[stream_game]
type = tcp
local_ip = 127.0.0.1
local_port = 8000  # 流媒体监听端口
remote_port = 80000 # 公网访问端口

# ESP32串口转发(可选,调试用)
[esp32_serial]
type = tcp
local_ip = 游戏服务器本地IP(如192.168.1.100)
local_port = 23  # ESP32串口端口
remote_port = 2300
(2)启动FRP客户端(Windows)
bat 复制代码
# 新建start_frp.bat,内容如下
@echo off
frpc.exe -c frpc.ini
pause

五、ESP32-S3代码实现(虚拟USB键鼠+MQTT)

1. 核心依赖库

  • USBHID:ESP32 Arduino内置,用于模拟USB HID键鼠
  • PubSubClient:MQTT通信库(Arduino库管理器安装)
  • WiFi:ESP32 WiFi连接库

2. 完整代码(Arduino IDE)

cpp 复制代码
#include <WiFi.h>
#include <PubSubClient.h>
#include <USBHID.h>
#include <HIDReports.h>
#include <HIDTypes.h>

// ===================== 配置参数 =====================
// WiFi配置
const char* WIFI_SSID = "你的WiFi名称";
const char* WIFI_PWD = "你的WiFi密码";
// MQTT配置(云服务器+FRP穿透)
const char* MQTT_SERVER = "云服务器IP";
const uint16_t MQTT_PORT = 18830; // FRP转发的MQTT端口
const char* MQTT_USER = "";       // EMQX默认无账号
const char* MQTT_PWD = "";
const char* MQTT_CLIENT_ID = "ESP32-S3-KVM";
// MQTT主题
const char* TOPIC_KEYBOARD = "kvm/keyboard";  // 键盘指令主题
const char* TOPIC_MOUSE = "kvm/mouse";        // 鼠标指令主题
const char* TOPIC_HEARTBEAT = "kvm/heartbeat";// 心跳主题
// USB HID配置
USBHID usb_hid;
HIDReportDescriptor hid_report_descriptor = {
  // 键鼠复合报告描述符(标准HID协议)
  0x05, 0x01,        // USAGE_PAGE (Generic Desktop)
  0x09, 0x06,        // USAGE (Keyboard)
  0xA1, 0x01,        // COLLECTION (Application)
  0x05, 0x07,        //   USAGE_PAGE (Keyboard/Keypad)
  0x19, 0xE0,        //   USAGE_MINIMUM (Keyboard Left Control)
  0x29, 0xE7,        //   USAGE_MAXIMUM (Keyboard Right GUI)
  0x15, 0x00,        //   LOGICAL_MINIMUM (0)
  0x25, 0x01,        //   LOGICAL_MAXIMUM (1)
  0x95, 0x08,        //   REPORT_COUNT (8)
  0x75, 0x01,        //   REPORT_SIZE (1)
  0x81, 0x02,        //   INPUT (Data,Var,Abs)
  0x95, 0x01,        //   REPORT_COUNT (1)
  0x75, 0x08,        //   REPORT_SIZE (8)
  0x81, 0x03,        //   INPUT (Cnst,Var,Abs)
  0x95, 0x06,        //   REPORT_COUNT (6)
  0x75, 0x08,        //   REPORT_SIZE (8)
  0x15, 0x00,        //   LOGICAL_MINIMUM (0)
  0x25, 0x65,        //   LOGICAL_MAXIMUM (101)
  0x05, 0x07,        //   USAGE_PAGE (Keyboard/Keypad)
  0x19, 0x00,        //   USAGE_MINIMUM (Reserved)
  0x29, 0x65,        //   USAGE_MAXIMUM (Keyboard Application)
  0x81, 0x00,        //   INPUT (Data,Array)
  0x05, 0x01,        //   USAGE_PAGE (Generic Desktop)
  0x09, 0x02,        //   USAGE (Mouse)
  0xA1, 0x02,        //   COLLECTION (Logical)
  0x09, 0x01,        //     USAGE (Pointer)
  0xA1, 0x00,        //     COLLECTION (Physical)
  0x05, 0x09,        //       USAGE_PAGE (Button)
  0x19, 0x01,        //       USAGE_MINIMUM (Button 1)
  0x29, 0x03,        //       USAGE_MAXIMUM (Button 3)
  0x15, 0x00,        //       LOGICAL_MINIMUM (0)
  0x25, 0x01,        //       LOGICAL_MAXIMUM (1)
  0x95, 0x03,        //       REPORT_COUNT (3)
  0x75, 0x01,        //       REPORT_SIZE (1)
  0x81, 0x02,        //       INPUT (Data,Var,Abs)
  0x95, 0x01,        //       REPORT_COUNT (1)
  0x75, 0x05,        //       REPORT_SIZE (5)
  0x81, 0x03,        //       INPUT (Cnst,Var,Abs)
  0x05, 0x01,        //       USAGE_PAGE (Generic Desktop)
  0x09, 0x30,        //       USAGE (X)
  0x09, 0x31,        //       USAGE (Y)
  0x15, 0x81,        //       LOGICAL_MINIMUM (-127)
  0x25, 0x7F,        //       LOGICAL_MAXIMUM (127)
  0x75, 0x08,        //       REPORT_SIZE (8)
  0x95, 0x02,        //       REPORT_COUNT (2)
  0x81, 0x06,        //       INPUT (Data,Var,Rel)
  0xC0,              //     END_COLLECTION
  0xC0,              //   END_COLLECTION
  0xC0               // END_COLLECTION
};
// 键鼠报告结构体
typedef struct {
  uint8_t modifier;   // 修饰键(Ctrl/Shift/Alt等)
  uint8_t reserved;   // 保留位
  uint8_t keys[6];    // 按键码(最多6个同时按下)
  uint8_t mouse_buttons; // 鼠标按键(1=左键,2=右键,4=中键)
  int8_t x;           // 鼠标X偏移
  int8_t y;           // 鼠标Y偏移
} HIDReport;
HIDReport hid_report = {0};

// ===================== 全局对象 =====================
WiFiClient wifi_client;
PubSubClient mqtt_client(wifi_client);

// ===================== 函数声明 =====================
void wifi_connect();                // WiFi连接
void mqtt_reconnect();              // MQTT重连
void mqtt_callback(char* topic, byte* payload, unsigned int length); // MQTT回调
void send_hid_report();             // 发送HID报告
void handle_keyboard_cmd(byte* payload, unsigned int length); // 处理键盘指令
void handle_mouse_cmd(byte* payload, unsigned int length);     // 处理鼠标指令
void send_heartbeat();              // 发送心跳包

// ===================== 初始化函数 =====================
void setup() {
  Serial.begin(115200);
  delay(100);

  // 初始化USB HID
  usb_hid.begin(hid_report_descriptor, sizeof(hid_report_descriptor));
  while(!usb_hid.ready()) {
    delay(10);
  }
  Serial.println("USB HID Ready!");

  // 连接WiFi
  wifi_connect();

  // 配置MQTT
  mqtt_client.setServer(MQTT_SERVER, MQTT_PORT);
  mqtt_client.setCallback(mqtt_callback);
  mqtt_client.setCredentials(MQTT_USER, MQTT_PWD);

  // 初始化HID报告
  memset(&hid_report, 0, sizeof(HIDReport));
  send_hid_report();
  Serial.println("ESP32-S3 KVM Init Complete!");
}

// ===================== 主循环 =====================
void loop() {
  // MQTT重连
  if (!mqtt_client.connected()) {
    mqtt_reconnect();
  }
  mqtt_client.loop();

  // 每5秒发送一次心跳包
  static unsigned long last_heartbeat = 0;
  if (millis() - last_heartbeat > 5000) {
    send_heartbeat();
    last_heartbeat = millis();
  }

  delay(10);
}

// ===================== 函数实现 =====================
/**
 * @brief WiFi连接函数
 * @details 连接指定WiFi,失败则重试
 */
void wifi_connect() {
  Serial.printf("Connecting to %s...\n", WIFI_SSID);
  WiFi.begin(WIFI_SSID, WIFI_PWD);
  
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  
  Serial.println("\nWiFi Connected!");
  Serial.print("IP Address: ");
  Serial.println(WiFi.localIP());
}

/**
 * @brief MQTT重连函数
 * @details 断开后自动重连,并订阅键鼠主题
 */
void mqtt_reconnect() {
  while (!mqtt_client.connected()) {
    Serial.print("MQTT Connecting...");
    // 尝试连接
    if (mqtt_client.connect(MQTT_CLIENT_ID)) {
      Serial.println("Connected!");
      // 订阅键鼠指令主题
      mqtt_client.subscribe(TOPIC_KEYBOARD);
      mqtt_client.subscribe(TOPIC_MOUSE);
      Serial.println("Subscribed to: " TOPIC_KEYBOARD " & " TOPIC_MOUSE);
    } else {
      Serial.print("Failed, rc=");
      Serial.print(mqtt_client.state());
      Serial.println(" Retry in 5 seconds...");
      delay(5000);
    }
  }
}

/**
 * @brief MQTT回调函数
 * @details 接收MQTT消息,分发至键鼠处理函数
 * @param topic 消息主题
 * @param payload 消息载荷
 * @param length 载荷长度
 */
void mqtt_callback(char* topic, byte* payload, unsigned int length) {
  Serial.print("Received Topic: ");
  Serial.println(topic);
  Serial.print("Payload: ");
  for (int i=0; i<length; i++) {
    Serial.print((char)payload[i]);
  }
  Serial.println();

  // 处理键盘指令
  if (strcmp(topic, TOPIC_KEYBOARD) == 0) {
    handle_keyboard_cmd(payload, length);
  }
  // 处理鼠标指令
  else if (strcmp(topic, TOPIC_MOUSE) == 0) {
    handle_mouse_cmd(payload, length);
  }
}

/**
 * @brief 处理键盘指令
 * @details 解析MQTT接收的键盘指令,更新HID报告并发送
 * @param payload 指令载荷(格式:modifier,key1,key2,...key6)
 * @param length 载荷长度
 */
void handle_keyboard_cmd(byte* payload, unsigned int length) {
  // 清空原有按键
  memset(&hid_report.modifier, 0, sizeof(hid_report.modifier));
  memset(hid_report.keys, 0, sizeof(hid_report.keys));

  // 将payload转为字符串
  char cmd[length+1];
  memcpy(cmd, payload, length);
  cmd[length] = '\0';

  // 解析指令(示例:"2,4,0,0,0,0,0" → modifier=2(Shift), key1=4(a))
  char* token = strtok(cmd, ",");
  if (token != NULL) {
    hid_report.modifier = atoi(token); // 修饰键
  }
  for (int i=0; i<6; i++) {
    token = strtok(NULL, ",");
    if (token != NULL) {
      hid_report.keys[i] = atoi(token); // 按键码
    } else {
      hid_report.keys[i] = 0;
    }
  }

  // 发送HID报告
  send_hid_report();
  Serial.println("Keyboard Cmd Executed!");
}

/**
 * @brief 处理鼠标指令
 * @details 解析MQTT接收的鼠标指令,更新HID报告并发送
 * @param payload 指令载荷(格式:buttons,x,y)
 * @param length 载荷长度
 */
void handle_mouse_cmd(byte* payload, unsigned int length) {
  // 清空原有鼠标状态
  hid_report.mouse_buttons = 0;
  hid_report.x = 0;
  hid_report.y = 0;

  // 将payload转为字符串
  char cmd[length+1];
  memcpy(cmd, payload, length);
  cmd[length] = '\0';

  // 解析指令(示例:"1,5,-3" → 左键按下,X+5,Y-3)
  char* token = strtok(cmd, ",");
  if (token != NULL) {
    hid_report.mouse_buttons = atoi(token); // 鼠标按键
  }
  token = strtok(NULL, ",");
  if (token != NULL) {
    hid_report.x = atoi(token); // X偏移
  }
  token = strtok(NULL, ",");
  if (token != NULL) {
    hid_report.y = atoi(token); // Y偏移
  }

  // 发送HID报告
  send_hid_report();
  Serial.println("Mouse Cmd Executed!");
}

/**
 * @brief 发送HID报告至游戏服务器
 * @details 将键鼠状态封装为HID报告,通过USB发送
 */
void send_hid_report() {
  while (!usb_hid.ready()) {
    delay(10);
  }
  usb_hid.sendReport((uint8_t*)&hid_report, sizeof(HIDReport));
  delay(1); // 避免报告发送过快
}

/**
 * @brief 发送心跳包
 * @details 向MQTT服务器发送心跳,标识ESP32在线状态
 */
void send_heartbeat() {
  char heartbeat_msg[32];
  snprintf(heartbeat_msg, sizeof(heartbeat_msg), "ESP32_ONLINE_%lu", millis());
  mqtt_client.publish(TOPIC_HEARTBEAT, heartbeat_msg);
  Serial.print("Heartbeat Sent: ");
  Serial.println(heartbeat_msg);
}

3. 代码关键函数说明

函数名 功能说明
wifi_connect() 连接指定WiFi,循环重试直到连接成功,打印本地IP
mqtt_reconnect() MQTT断开后自动重连,重连成功后订阅键鼠指令主题
mqtt_callback() MQTT消息回调,根据主题分发指令至键盘/鼠标处理函数
handle_keyboard_cmd() 解析键盘指令(修饰键+6个按键),更新HID报告并发送
handle_mouse_cmd() 解析鼠标指令(按键+X/Y偏移),更新HID报告并发送
send_hid_report() 将HID报告通过USB发送至游戏服务器,模拟真实键鼠操作
send_heartbeat() 每5秒发送一次心跳包,用于客户端检测ESP32在线状态

六、游戏服务器端流媒体推流(FFmpeg)

1. 推流命令(Windows)

新建start_stream.bat,内容如下(推流至本地8000端口,通过FRP转发至公网):

bat 复制代码
@echo off
:: 屏幕推流(采集整个屏幕,音频可选)
ffmpeg -f gdigrab -framerate 30 -i desktop^
 -vcodec libx264 -preset ultrafast -tune zerolatency^
 -f hls -hls_time 1 -hls_list_size 3 -hls_flags delete_segments^
 http://127.0.0.1:8000/stream.m3u8
pause

2. 简易HTTP流媒体服务器(Python)

游戏服务器端需启动一个简易HTTP服务器,用于Python客户端拉流:

python 复制代码
# stream_server.py
import http.server
import socketserver

PORT = 8000  # 与FRP配置的local_port一致
Handler = http.server.SimpleHTTPRequestHandler

# 允许跨域访问
class CORSHandler(Handler):
    def end_headers(self):
        self.send_header('Access-Control-Allow-Origin', '*')
        super().end_headers()

with socketserver.TCPServer(("", PORT), CORSHandler) as httpd:
    print(f"Streaming Server running on port {PORT}")
    httpd.serve_forever()

启动命令:python stream_server.py

七、Python客户端完整实现(模仿ToDesk)

1. 核心功能

  • GUI界面(窗口、流媒体显示、控制按钮)
  • 本地键鼠事件捕获(避免捕获自身窗口)
  • MQTT发送键鼠指令至ESP32
  • 流媒体拉流并实时显示
  • 心跳包检测ESP32在线状态

2. 完整代码

python 复制代码
import sys
import json
import time
import threading
import pyautogui
import paho.mqtt.client as mqtt
import cv2
import numpy as np
from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QWidget, QVBoxLayout, 
    QHBoxLayout, QPushButton, QLabel, QLineEdit,
    QStatusBar, QMessageBox
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer
from PyQt6.QtGui import QImage, QPixmap

# ===================== 配置参数 =====================
# MQTT配置
MQTT_SERVER = "云服务器IP"
MQTT_PORT = 18830  # FRP转发的MQTT端口
MQTT_USER = ""
MQTT_PWD = ""
MQTT_CLIENT_ID = "Python-KVM-Client"
TOPIC_KEYBOARD = "kvm/keyboard"
TOPIC_MOUSE = "kvm/mouse"
TOPIC_HEARTBEAT = "kvm/heartbeat"
# 流媒体配置
STREAM_URL = "http://云服务器IP:80000/stream.m3u8"  # FRP转发的流媒体端口
# 键鼠配置
pyautogui.PAUSE = 0.01  # 降低键鼠响应延迟
pyautogui.FAILSAFE = False  # 关闭安全保护

# ===================== MQTT客户端类 =====================
class MQTTClient:
    def __init__(self):
        self.client = mqtt.Client(MQTT_CLIENT_ID)
        self.client.username_pw_set(MQTT_USER, MQTT_PWD)
        self.client.on_connect = self.on_connect
        self.client.on_message = self.on_message
        self.client.on_disconnect = self.on_disconnect
        self.connected = False
        self.esp32_online = False

    def connect(self):
        """连接MQTT服务器"""
        try:
            self.client.connect(MQTT_SERVER, MQTT_PORT, 60)
            # 启动MQTT循环(后台线程)
            self.client.loop_start()
            return True
        except Exception as e:
            print(f"MQTT Connect Error: {e}")
            return False

    def on_connect(self, client, userdata, flags, rc):
        """MQTT连接回调"""
        if rc == 0:
            self.connected = True
            print("MQTT Connected Successfully!")
            # 订阅心跳主题(检测ESP32在线)
            self.client.subscribe(TOPIC_HEARTBEAT)
        else:
            self.connected = False
            print(f"MQTT Connect Failed, rc={rc}")

    def on_message(self, client, userdata, msg):
        """MQTT消息回调"""
        if msg.topic == TOPIC_HEARTBEAT:
            # 收到ESP32心跳,标记在线
            self.esp32_online = True
            print(f"ESP32 Heartbeat: {msg.payload.decode()}")

    def on_disconnect(self, client, userdata, rc):
        """MQTT断开回调"""
        self.connected = False
        print("MQTT Disconnected!")

    def send_keyboard_cmd(self, modifier, keys):
        """
        发送键盘指令
        :param modifier: 修饰键(0=无,1=Ctrl,2=Shift,4=Alt,8=Win)
        :param keys: 6个按键码列表(如[4,0,0,0,0,0] → 按下a键)
        """
        if not self.connected:
            return False
        # 构造指令字符串:modifier,key1,key2,...key6
        cmd = f"{modifier}," + ",".join(map(str, keys))
        self.client.publish(TOPIC_KEYBOARD, cmd)
        return True

    def send_mouse_cmd(self, buttons, x, y):
        """
        发送鼠标指令
        :param buttons: 鼠标按键(1=左键,2=右键,4=中键)
        :param x: X偏移(-127~127)
        :param y: Y偏移(-127~127)
        """
        if not self.connected:
            return False
        # 构造指令字符串:buttons,x,y
        cmd = f"{buttons},{x},{y}"
        self.client.publish(TOPIC_MOUSE, cmd)
        return True

    def disconnect(self):
        """断开MQTT连接"""
        self.client.loop_stop()
        self.client.disconnect()

# ===================== 流媒体拉流线程 =====================
class StreamThread(QThread):
    frame_signal = pyqtSignal(np.ndarray)  # 帧信号
    error_signal = pyqtSignal(str)         # 错误信号

    def __init__(self, stream_url):
        super().__init__()
        self.stream_url = stream_url
        self.running = False

    def run(self):
        """拉流并发送帧信号"""
        self.running = True
        cap = cv2.VideoCapture(self.stream_url)
        if not cap.isOpened():
            self.error_signal.emit("无法打开流媒体链接!")
            return

        while self.running:
            ret, frame = cap.read()
            if ret:
                self.frame_signal.emit(frame)
            else:
                self.error_signal.emit("流媒体帧读取失败!")
                time.sleep(0.1)
        cap.release()

    def stop(self):
        """停止拉流"""
        self.running = False

# ===================== 键鼠捕获线程 =====================
class InputThread(QThread):
    keyboard_signal = pyqtSignal(int, list)  # 键盘信号:modifier, keys
    mouse_signal = pyqtSignal(int, int, int) # 鼠标信号:buttons, x, y

    def __init__(self, mqtt_client, parent_widget):
        super().__init__()
        self.mqtt_client = mqtt_client
        self.parent_widget = parent_widget
        self.running = False
        self.last_x, self.last_y = pyautogui.position()

    def run(self):
        """捕获键鼠事件并发送信号"""
        self.running = True
        while self.running:
            # 捕获鼠标事件
            self.capture_mouse()
            # 捕获键盘事件(简化版,可扩展)
            self.capture_keyboard()
            time.sleep(0.01)

    def capture_mouse(self):
        """捕获鼠标移动和按键"""
        if not self.mqtt_client.esp32_online:
            return
        # 获取当前鼠标位置
        curr_x, curr_y = pyautogui.position()
        # 计算偏移
        dx = curr_x - self.last_x
        dy = curr_y - self.last_y
        # 限制偏移范围(-127~127)
        dx = max(-127, min(127, dx))
        dy = max(-127, min(127, dy))
        # 获取鼠标按键
        buttons = 0
        if pyautogui.mouseDown(button='left'):
            buttons |= 1
        if pyautogui.mouseDown(button='right'):
            buttons |= 2
        if pyautogui.mouseDown(button='middle'):
            buttons |= 4
        # 发送鼠标指令
        if dx != 0 or dy != 0 or buttons != 0:
            self.mouse_signal.emit(buttons, dx, dy)
            self.mqtt_client.send_mouse_cmd(buttons, dx, dy)
        # 更新最后位置
        self.last_x, self.last_y = curr_x, curr_y

    def capture_keyboard(self):
        """捕获键盘按键(简化版,仅示例)"""
        if not self.mqtt_client.esp32_online:
            return
        # 示例:捕获a键(可扩展为全键盘)
        modifier = 0
        keys = [0]*6
        if pyautogui.keyDown('shift'):
            modifier |= 2
        if pyautogui.keyDown('a'):
            keys[0] = 4  # a键的HID码
            self.keyboard_signal.emit(modifier, keys)
            self.mqtt_client.send_keyboard_cmd(modifier, keys)
        # 释放按键
        if pyautogui.keyUp('a'):
            keys[0] = 0
            self.keyboard_signal.emit(modifier, keys)
            self.mqtt_client.send_keyboard_cmd(modifier, keys)

    def stop(self):
        """停止捕获"""
        self.running = False

# ===================== 主窗口类 =====================
class KVMClientWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("ESP32-S3 KVM云玩客户端")
        self.setGeometry(100, 100, 1280, 720)

        # 初始化MQTT客户端
        self.mqtt_client = MQTTClient()
        # 初始化流媒体线程
        self.stream_thread = StreamThread(STREAM_URL)
        self.stream_thread.frame_signal.connect(self.update_stream)
        self.stream_thread.error_signal.connect(self.show_error)
        # 初始化键鼠捕获线程
        self.input_thread = InputThread(self.mqtt_client, self)

        # 创建UI
        self.create_ui()

        # 心跳检测定时器
        self.heartbeat_timer = QTimer()
        self.heartbeat_timer.timeout.connect(self.check_esp32_status)
        self.heartbeat_timer.start(5000)  # 每5秒检测一次

    def create_ui(self):
        """创建UI界面"""
        # 中心部件
        central_widget = QWidget()
        self.setCentralWidget(central_widget)

        # 布局
        main_layout = QVBoxLayout(central_widget)
        control_layout = QHBoxLayout()
        stream_layout = QVBoxLayout()

        # 控制按钮
        self.connect_btn = QPushButton("连接MQTT")
        self.connect_btn.clicked.connect(self.toggle_mqtt)
        control_layout.addWidget(self.connect_btn)

        self.start_stream_btn = QPushButton("启动流媒体")
        self.start_stream_btn.clicked.connect(self.toggle_stream)
        self.start_stream_btn.setEnabled(False)
        control_layout.addWidget(self.start_stream_btn)

        self.start_input_btn = QPushButton("启动键鼠控制")
        self.start_input_btn.clicked.connect(self.toggle_input)
        self.start_input_btn.setEnabled(False)
        control_layout.addWidget(self.start_input_btn)

        # 流媒体显示标签
        self.stream_label = QLabel()
        self.stream_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.stream_label.setText("流媒体未启动")
        stream_layout.addWidget(self.stream_label)

        # 添加布局
        main_layout.addLayout(control_layout)
        main_layout.addLayout(stream_layout)

        # 状态栏
        self.status_bar = QStatusBar()
        self.setStatusBar(self.status_bar)
        self.status_bar.showMessage("未连接MQTT")

    def toggle_mqtt(self):
        """切换MQTT连接状态"""
        if not self.mqtt_client.connected:
            success = self.mqtt_client.connect()
            if success:
                self.connect_btn.setText("断开MQTT")
                self.start_stream_btn.setEnabled(True)
                self.status_bar.showMessage("MQTT已连接")
            else:
                self.show_error("MQTT连接失败!")
        else:
            self.mqtt_client.disconnect()
            self.connect_btn.setText("连接MQTT")
            self.start_stream_btn.setEnabled(False)
            self.start_input_btn.setEnabled(False)
            self.status_bar.showMessage("MQTT已断开")

    def toggle_stream(self):
        """切换流媒体状态"""
        if not self.stream_thread.isRunning():
            self.stream_thread.start()
            self.start_stream_btn.setText("停止流媒体")
            self.start_input_btn.setEnabled(True)
            self.status_bar.showMessage("流媒体已启动")
        else:
            self.stream_thread.stop()
            self.stream_thread.wait()
            self.start_stream_btn.setText("启动流媒体")
            self.start_input_btn.setEnabled(False)
            self.stream_label.setText("流媒体已停止")
            self.status_bar.showMessage("流媒体已停止")

    def toggle_input(self):
        """切换键鼠捕获状态"""
        if not self.input_thread.isRunning():
            self.input_thread.start()
            self.start_input_btn.setText("停止键鼠控制")
            self.status_bar.showMessage("键鼠控制已启动")
        else:
            self.input_thread.stop()
            self.input_thread.wait()
            self.start_input_btn.setText("启动键鼠控制")
            self.status_bar.showMessage("键鼠控制已停止")

    def update_stream(self, frame):
        """更新流媒体显示"""
        # 转换颜色空间(BGR→RGB)
        frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
        # 调整尺寸适配窗口
        h, w, ch = frame_rgb.shape
        bytes_per_line = ch * w
        qt_image = QImage(frame_rgb.data, w, h, bytes_per_line, QImage.Format.Format_RGB888)
        pixmap = QPixmap.fromImage(qt_image).scaled(
            self.stream_label.size(), 
            Qt.AspectRatioMode.KeepAspectRatio,
            Qt.TransformationMode.SmoothTransformation
        )
        self.stream_label.setPixmap(pixmap)

    def check_esp32_status(self):
        """检测ESP32在线状态"""
        if self.mqtt_client.connected and not self.mqtt_client.esp32_online:
            self.status_bar.showMessage("警告:ESP32离线!")
            self.mqtt_client.esp32_online = False  # 重置状态
        elif self.mqtt_client.connected and self.mqtt_client.esp32_online:
            self.status_bar.showMessage("ESP32在线,系统正常")

    def show_error(self, msg):
        """显示错误提示"""
        QMessageBox.critical(self, "错误", msg)

    def closeEvent(self, event):
        """窗口关闭事件"""
        # 停止所有线程
        if self.stream_thread.isRunning():
            self.stream_thread.stop()
            self.stream_thread.wait()
        if self.input_thread.isRunning():
            self.input_thread.stop()
            self.input_thread.wait()
        # 断开MQTT
        if self.mqtt_client.connected:
            self.mqtt_client.disconnect()
        event.accept()

# ===================== 主函数 =====================
if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = KVMClientWindow()
    window.show()
    sys.exit(app.exec())

3. 代码关键类/函数说明

类/函数名 功能说明
MQTTClient MQTT客户端封装,处理连接、消息收发、键鼠指令发送
StreamThread 流媒体拉流线程,异步拉取FFmpeg推流并发送帧信号至主窗口
InputThread 键鼠捕获线程,实时捕获本地键鼠事件,通过MQTT发送至ESP32
KVMClientWindow 主窗口类,负责UI渲染、按钮事件处理、流媒体显示、状态检测
update_stream() 将OpenCV帧转换为Qt Pixmap,更新流媒体显示标签
capture_mouse()/capture_keyboard() 捕获鼠标移动/按键、键盘按键,转换为HID指令并发送
check_esp32_status() 定时检测ESP32心跳,更新状态栏显示在线状态

八、系统部署与测试步骤

1. 部署顺序

  1. 云服务器部署EMQX + FRP服务端,开放对应端口;
  2. 游戏服务器部署FRP客户端 + 流媒体服务器 + FFmpeg推流;
  3. ESP32-S3烧录代码,连接游戏服务器USB口,配置WiFi/MQTT后上电;
  4. 本地电脑运行Python客户端,连接MQTT→启动流媒体→启动键鼠控制。

2. 测试要点

  • MQTT通信:客户端发送指令后,ESP32串口打印"Keyboard/Mouse Cmd Executed!";
  • 虚拟USB:游戏服务器识别到ESP32为"USB输入设备";
  • 流媒体:客户端能实时显示游戏服务器屏幕;
  • 键鼠控制:本地操作键鼠,游戏服务器同步响应。

九、总结

核心关键点回顾

  1. ESP32-S3核心:通过USB HID库模拟键鼠,MQTT接收指令并解析执行,实现"网络指令→USB键鼠"的转换;
  2. 网络穿透:FRP解决内网ESP32/游戏服务器的公网访问问题,确保跨网络控制;
  3. 流媒体传输:FFmpeg推流+Python简易HTTP服务器+OpenCV拉流,实现低延迟屏幕共享;
  4. Python客户端:PyQt6构建GUI,pyautogui捕获键鼠,paho-mqtt发送指令,完成ToDesk式交互。

优化方向

  • 键鼠捕获:扩展为全键盘映射(HID码表),支持组合键;
  • 低延迟优化:流媒体改用RTSP/RTMP协议,降低延迟;
  • 安全性:MQTT添加账号密码认证,FRP增加访问白名单;
  • 稳定性:增加断线自动重连、指令重发机制。
相关推荐
治愈系科普2 小时前
数字化种植牙企业
大数据·人工智能·python
AI数据皮皮侠2 小时前
中国植被生物量分布数据集(2001-2020)
大数据·人工智能·python·深度学习·机器学习
a程序小傲2 小时前
京东Java面试被问:基于Gossip协议的最终一致性实现和收敛时间
java·开发语言·前端·数据库·python·面试·状态模式
小二·2 小时前
Python Web 开发进阶实战:AI 原生应用商店 —— 在 Flask + Vue 中构建模型即服务(MaaS)与智能体分发平台
前端·人工智能·python
tqs_123452 小时前
Spring Boot 的自动装配机制和 Starter 的实现原理
开发语言·python
重生之绝世牛码2 小时前
Linux软件安装 —— PostgreSQL集群安装(主从复制集群)
大数据·linux·运维·数据库·postgresql·软件安装·postgresql主从集群
好好学习啊天天向上2 小时前
conda pip更新安装路径,解决C盘容易不够的问题
python·conda·pip·2025yfb3003605
~kiss~2 小时前
多头注意力中的张量重塑
pytorch·python·深度学习
laplace01232 小时前
PPO到GRPO自己话总结
人工智能·python·大模型·agent·rag