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. 部署顺序
- 云服务器部署EMQX + FRP服务端,开放对应端口;
- 游戏服务器部署FRP客户端 + 流媒体服务器 + FFmpeg推流;
- ESP32-S3烧录代码,连接游戏服务器USB口,配置WiFi/MQTT后上电;
- 本地电脑运行Python客户端,连接MQTT→启动流媒体→启动键鼠控制。
2. 测试要点
- MQTT通信:客户端发送指令后,ESP32串口打印"Keyboard/Mouse Cmd Executed!";
- 虚拟USB:游戏服务器识别到ESP32为"USB输入设备";
- 流媒体:客户端能实时显示游戏服务器屏幕;
- 键鼠控制:本地操作键鼠,游戏服务器同步响应。
九、总结
核心关键点回顾
- ESP32-S3核心:通过USB HID库模拟键鼠,MQTT接收指令并解析执行,实现"网络指令→USB键鼠"的转换;
- 网络穿透:FRP解决内网ESP32/游戏服务器的公网访问问题,确保跨网络控制;
- 流媒体传输:FFmpeg推流+Python简易HTTP服务器+OpenCV拉流,实现低延迟屏幕共享;
- Python客户端:PyQt6构建GUI,pyautogui捕获键鼠,paho-mqtt发送指令,完成ToDesk式交互。
优化方向
- 键鼠捕获:扩展为全键盘映射(HID码表),支持组合键;
- 低延迟优化:流媒体改用RTSP/RTMP协议,降低延迟;
- 安全性:MQTT添加账号密码认证,FRP增加访问白名单;
- 稳定性:增加断线自动重连、指令重发机制。