
1. 项目概述从想法到现实最近在折腾一个挺有意思的小项目起因是工作室里那台主力渲染和编译的PC机。机器性能是够的但每次想看看它在跑什么任务、CPU温度多少、内存占用率如何都得切到任务管理器或者打开一堆监控软件界面一多屏幕就乱打断工作流。我就琢磨着能不能搞一个独立的、物理形态的监控屏像汽车仪表盘一样把关键信息实时、直观地展示出来而且最好能无线连接不占USB口摆放也自由。这个想法让我立刻想到了ESP32这块“万能”的芯片。它自带Wi-Fi和蓝牙性能对于处理传感器数据和驱动屏幕绰绰有余关键是开发社区生态极其丰富。于是“基于ESP32制造系统监控屏支持与PC蓝牙连接”这个项目就成型了。本质上它就是一个运行在ESP32上的客户端通过蓝牙从PC端的一个服务程序获取系统状态数据如CPU/GPU占用率、温度、内存、网络流量等然后在一块小屏幕上以仪表、图表等形式美观地展示出来。这玩意儿适合谁呢我觉得三类朋友可能会感兴趣一是像我一样的DIY爱好者和极客享受自己动手打造专属工具的乐趣二是对PC硬件状态有实时监控需求的用户比如超频玩家、视频渲染工作者或者游戏主播三是想学习ESP32蓝牙通信、嵌入式UI开发以及PC端服务编写的开发者。它不只是一个成品更是一个融合了硬件、嵌入式软件、桌面应用和通信协议的综合学习项目。2. 核心方案设计与硬件选型2.1 整体架构拆解整个系统的核心是数据流PC端采集数据 - 通过蓝牙发送 - ESP32接收并解析 - 屏幕渲染显示。因此方案设计需要围绕这条数据链路展开。为什么选择蓝牙而非Wi-Fi这是一个关键决策点。Wi-Fi和蓝牙这里特指经典蓝牙SPP协议都能实现数据传输。Wi-Fi的优势是带宽高、传输距离远但连接过程相对复杂需要配网功耗也更高。对于我们这个监控场景数据量很小每秒几KB到几十KB足矣传输距离要求不高通常就在电脑桌附近更重要的是追求连接的稳定性和简便性。蓝牙配对一次后基本可以做到开机自连ESP32作为从机PC作为主机连接逻辑清晰可靠。因此蓝牙SPP串口协议是更轻量、更直接的选择。系统工作流程如下PC端运行一个后台服务如用Python或C#编写周期性地例如每秒调用系统API如psutil库获取CPU、内存、磁盘、网络、传感器温度等数据。数据格式化将采集到的数据打包成一个结构化的字符串或JSON格式例如{“cpu”: 45.2, “mem”: 76.5, “gpu_temp”: 68}。蓝牙发送PC通过已配对的蓝牙虚拟串口COM口将格式化后的数据字符串发送出去。ESP32端通过蓝牙串口接收数据解析JSON或自定义协议提取出各项数值。屏幕渲染ESP32根据解析出的数据调用图形库如LVGL、TFT_eSPI的函数在屏幕上绘制进度条、数字、仪表盘或简易折线图。2.2 硬件清单与选型理由硬件是项目的骨架选型直接决定了成本、显示效果和开发难度。1. 主控芯片ESP32开发板型号选择ESP32-S3或ESP32-C3是较新的选择性能更好且性价比高。但对于本项目最经典且资料最多的ESP32-WROOM-32D/E完全够用。我手头正好有NodeMCU-32S基于ESP32就用了它。选型理由集成双核处理器、蓝牙4.2 BR/EDR和BLE、Wi-Fi内存520KB SRAM足够驱动UI和缓冲数据。引脚丰富可轻松连接屏幕。庞大的Arduino和ESP-IDF社区支持遇到问题几乎都能找到答案。2. 显示屏幕TFT液晶屏接口选择SPI接口的屏幕是首选因为它占用的GPIO口少通常只需4-6个驱动简单虽然刷新率比并行接口如ILI9341的8位并行低但对于刷新率要求不高的监控数据1-2秒更新一次完全足够且成本更低。尺寸与分辨率1.3寸、1.54寸或2寸的IPS屏是热门选择。我选用了一块1.54寸的IPS屏分辨率240x240。这个尺寸放在桌面不占地方分辨率足以清晰显示多个数据 widget。IPS屏的可视角度广从侧面看也不会偏色。具体型号我用的屏幕驱动芯片是ST7789这是一款非常流行的SPI TFT驱动芯片有成熟的Arduino库TFT_eSPI支持。3. 其他必要材料杜邦线母对母用于连接ESP32和屏幕。USB数据线用于给ESP32供电和烧录程序。可选-外壳3D打印一个外壳能让成品看起来更精致也能保护电路。可以在Thingiverse等网站搜索“ESP32 TFT case”找到很多现成模型。注意屏幕选购陷阱。网上很多“ESP32 屏幕套装”卖的是并口屏幕虽然可能附带转接板但会占用大量GPIO可能超过10个对于GPIO资源紧张或想接其他传感器的项目不友好。购买时一定要确认是“SPI接口”的TFT屏幕。3. 软件环境搭建与核心库配置3.1 PC端数据采集服务搭建PC端我们需要一个常驻后台的程序负责采集并发送数据。Python因其简洁和强大的库生态成为首选。1. 安装Python与环境依赖确保你的PC安装了Python 3.6以上版本。然后通过pip安装核心库pip install psutil pybluezpsutil跨平台的系统监控库能轻松获取CPU、内存、磁盘、网络、传感器信息。pybluezPython的蓝牙库用于创建蓝牙串口服务。在Windows上安装pybluez可能需要一点技巧有时使用pip install pybluez2或寻找预编译的whl文件更顺利。也可以考虑使用bleak库专注于BLE但经典蓝牙SPP更通用。2. 编写数据采集与发送脚本核心是创建一个循环每秒采集一次数据并通过蓝牙串口发送。这里假设你已经将ESP32与PC配对并在Windows的“设备与打印机”中创建了传入的蓝牙虚拟串口例如COM5。import psutil import time import json import serial # 使用pyserial库需安装pip install pyserial # 配置蓝牙虚拟串口号和波特率 bluetooth_port COM5 # 请根据你的实际端口修改 baud_rate 115200 def get_system_stats(): 采集系统状态数据 stats { cpu_percent: psutil.cpu_percent(intervalNone), # 非阻塞式获取 mem_percent: psutil.virtual_memory().percent, mem_used_gb: round(psutil.virtual_memory().used / (1024**3), 2), disk_percent: psutil.disk_usage(C:/).percent, # 监控C盘 } # 尝试获取CPU温度Windows可能需要额外支持如OpenHardwareMonitor try: temps psutil.sensors_temperatures() if coretemp in temps: stats[cpu_temp] temps[coretemp][0].current else: stats[cpu_temp] 0 except: stats[cpu_temp] 0 # 网络流量每秒字节数 net_before psutil.net_io_counters() time.sleep(1) net_after psutil.net_io_counters() stats[net_sent_kbps] (net_after.bytes_sent - net_before.bytes_sent) / 1024 stats[net_recv_kbps] (net_after.bytes_recv - net_before.bytes_recv) / 1024 return stats def main(): print(f尝试连接到 {bluetooth_port}...) try: # 初始化串口连接 ser serial.Serial(bluetooth_port, baudratebaud_rate, timeout1) print(蓝牙串口连接成功) except serial.SerialException as e: print(f连接失败: {e}) print(请检查1. ESP32是否已上电并进入蓝牙等待模式2. 电脑蓝牙是否已配对并创建了COM口3. COM口号是否正确。) return try: while True: data get_system_stats() # 将数据转换为JSON字符串并发送 data_str json.dumps(data) \n # 加换行符作为数据包分隔符 ser.write(data_str.encode(utf-8)) print(f已发送: {data_str.strip()}) time.sleep(1) # 每秒发送一次 except KeyboardInterrupt: print(\n程序被用户中断。) finally: ser.close() print(串口已关闭。) if __name__ __main__: main()实操心得Windows蓝牙串口稳定性。有时Windows的蓝牙虚拟串口会不稳定或连接失败。一个更可靠的方法是让Python脚本直接通过Socket与ESP32的蓝牙通信但这需要更底层的蓝牙操作。对于初学者先确保能用系统自带的“蓝牙串口”功能连接成功是关键。如果遇到问题可以尝试重新配对或使用第三方蓝牙串口工具如Tera Term先测试连通性。3.2 ESP32端开发环境与编程ESP32端我们使用Arduino IDE进行开发因为它对新手友好库管理方便。1. Arduino IDE环境配置安装Arduino IDE1.8.x或2.0版本均可。在“文件”-“首选项”的“附加开发板管理器网址”中添加ESP32的板支持网址https://espressif.github.io/arduino-esp32/package_esp32_index.json。打开“工具”-“开发板”-“开发板管理器”搜索“esp32”安装“Espressif Systems”提供的ESP32开发板包。安装完成后在“工具”-“开发板”中选择你的ESP32型号如“NodeMCU-32S”。2. 安装必要的库打开“工具”-“管理库...”搜索并安装以下库TFT_eSPI这是驱动SPI TFT屏幕的绝佳库支持众多芯片ST7789, ILI9341等且经过高度优化。ArduinoJson用于解析从PC端发送过来的JSON格式数据必不可少。3. 配置TFT_eSPI库这是关键一步很多新手在这里卡住。安装完TFT_eSPI库后你需要到Arduino的库文件夹下找到它并编辑其用户配置文件。路径通常类似C:\Users\[你的用户名]\Documents\Arduino\libraries\TFT_eSPI。找到User_Setup.h或User_Setup_Select.h文件。更简单的方法是在TFT_eSPI库文件夹内找到一个叫User_Setups的文件夹里面有很多现成的针对不同屏幕的配置文件。找到与你屏幕最匹配的例如Setup24_ST7789.h对应240x240的ST7789。然后回到库根目录打开User_Setup_Select.h文件注释掉其他所有的#include取消注释或添加指向你屏幕配置文件的那一行。例如//#include User_Setup.h // Default setup is root library folder #include User_Setups/Setup24_ST7789.h // Setup file for ESP32 and ST7789 240x240接着打开你选择的那个具体配置文件如Setup24_ST7789.h根据屏幕引脚定义和你的接线修改以下关键参数#define TFT_CS 15 // 屏幕片选引脚 #define TFT_DC 2 // 数据/命令选择引脚 #define TFT_RST 4 // 复位引脚如果屏幕有否则接ESP32的EN或NC #define TFT_MOSI 23 // SPI数据输入主出从入 #define TFT_SCLK 18 // SPI时钟 // 背光控制引脚如果有 #define TFT_BL 12 // 背光控制 #define TFT_BACKLIGHT_ON HIGH // 背光点亮电平务必根据你的实际接线修改这些引脚号错误的引脚定义会导致白屏或花屏。4. ESP32端程序编写与解析4.1 蓝牙通信与数据解析ESP32端的核心任务是建立蓝牙串口服务接收数据解析并驱动显示。#include BluetoothSerial.h #include ArduinoJson.h #include TFT_eSPI.h TFT_eSPI tft TFT_eSPI(); BluetoothSerial SerialBT; // 定义屏幕布局和颜色 #define SCREEN_WIDTH 240 #define SCREEN_HEIGHT 240 #define CHAR_WIDTH 12 // 大约字符宽度 #define CHAR_HEIGHT 16 // 大约字符高度 // 全局变量存储接收到的数据 float cpuPercent 0; float memPercent 0; float cpuTemp 0; float netSent 0; float netRecv 0; void setup() { Serial.begin(115200); tft.init(); tft.setRotation(1); // 根据屏幕方向调整0-3 tft.fillScreen(TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setTextSize(2); // 初始化蓝牙串口设备名可自定义 if (!SerialBT.begin(ESP32-System-Monitor)) { Serial.println(蓝牙初始化失败); while (1); // 停止执行 } Serial.println(蓝牙已启动等待连接...); tft.drawString(BT: Waiting..., 10, 10); // 绘制静态UI框架 drawStaticUI(); } void loop() { // 检查蓝牙是否有数据可读 if (SerialBT.available()) { String receivedData SerialBT.readStringUntil(\n); // 以换行符为分隔读取一行 receivedData.trim(); Serial.println(Received: receivedData); // 解析JSON数据 DynamicJsonDocument doc(512); // 根据数据大小调整缓冲区 DeserializationError error deserializeJson(doc, receivedData); if (!error) { // 提取数据到全局变量 cpuPercent doc[cpu_percent]; memPercent doc[mem_percent]; cpuTemp doc[cpu_temp]; netSent doc[net_sent_kbps]; netRecv doc[net_recv_kbps]; // 更新屏幕显示 updateDisplay(); } else { Serial.print(JSON解析错误: ); Serial.println(error.c_str()); } } delay(100); // 短暂延迟避免过于频繁的循环 } void drawStaticUI() { tft.fillScreen(TFT_BLACK); tft.setTextSize(2); tft.setTextColor(TFT_CYAN, TFT_BLACK); tft.drawString(System Monitor, 20, 5); tft.setTextSize(1); tft.setTextColor(TFT_WHITE, TFT_BLACK); // 绘制静态标签 tft.drawString(CPU:, 20, 40); tft.drawString(%, 180, 40); tft.drawString(MEM:, 20, 70); tft.drawString(%, 180, 70); tft.drawString(TEMP:, 20, 100); tft.drawString(C, 180, 100); tft.drawString(NET UP:, 20, 130); tft.drawString(KB/s, 180, 130); tft.drawString(NET DN:, 20, 160); tft.drawString(KB/s, 180, 160); // 绘制进度条背景框 tft.drawRect(60, 38, 110, 18, TFT_DARKGREY); // CPU进度条框 tft.drawRect(60, 68, 110, 18, TFT_DARKGREY); // MEM进度条框 } void updateDisplay() { tft.setTextSize(2); // 更新CPU百分比和进度条 drawProgressBar(60, 38, 110, 18, cpuPercent, TFT_GREEN, TFT_DARKGREY); tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setCursor(140, 40); tft.printf(%2.0f, cpuPercent); // 更新内存百分比和进度条 drawProgressBar(60, 68, 110, 18, memPercent, TFT_BLUE, TFT_DARKGREY); tft.setCursor(140, 70); tft.printf(%2.0f, memPercent); // 更新温度 tft.setCursor(140, 100); tft.setTextColor(getTempColor(cpuTemp), TFT_BLACK); tft.printf(%2.0f, cpuTemp); // 更新网络速度 tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setCursor(140, 130); tft.printf(%3.0f, netSent); tft.setCursor(140, 160); tft.printf(%3.0f, netRecv); } // 绘制进度条函数 void drawProgressBar(uint16_t x, uint16_t y, uint16_t w, uint16_t h, float percent, uint32_t color, uint32_t bgColor) { uint16_t barWidth (percent / 100.0) * w; barWidth constrain(barWidth, 0, w); // 限制宽度不超过总宽 tft.fillRect(x, y, w, h, bgColor); // 清空背景 tft.fillRect(x, y, barWidth, h, color); // 绘制填充部分 } // 根据温度返回颜色低温绿高温红 uint32_t getTempColor(float temp) { if (temp 50) return TFT_GREEN; else if (temp 70) return TFT_YELLOW; else return TFT_RED; }4.2 显示优化与UI设计上面的代码实现了基本功能但UI比较简陋。我们可以利用TFT_eSPI库的强大功能进行优化。1. 使用Sprite提升刷新效率直接在全屏上反复绘制文字和图形即使区域很小也可能导致闪烁。TFT_eSPI的Sprite精灵功能可以创建一块内存中的画布先在画布上完成所有绘制然后一次性将整块画布推送到屏幕实现无闪烁更新。TFT_eSprite spr TFT_eSprite(tft); // 创建一个Sprite对象 void setup() { // ... 其他初始化代码 tft.init(); // 创建Sprite大小与更新区域一致或略大 spr.createSprite(200, 120); // 假设我们只更新下方数据区域 spr.setTextColor(TFT_WHITE, TFT_BLACK); } void updateDisplay() { spr.fillSprite(TFT_BLACK); // 清空Sprite // 在Sprite上绘制所有动态内容 drawProgressBarOnSprite(0, 0, 110, 18, cpuPercent, TFT_GREEN); spr.setCursor(120, 0); spr.printf(CPU:%2.0f%%, cpuPercent); // ... 绘制其他内容 // 将Sprite推送到屏幕的指定位置 spr.pushSprite(20, 60); // (x, y)是Sprite在屏幕上的起始位置 }2. 绘制更美观的图表对于网络速度这类随时间变化的数据折线图比单纯的数字更直观。我们可以维护一个历史数据数组在Sprite上绘制简单的折线图。#define HISTORY_SIZE 50 float netSentHistory[HISTORY_SIZE] {0}; int historyIndex 0; void updateNetGraph() { // 将新数据存入历史数组 netSentHistory[historyIndex] netSent; historyIndex (historyIndex 1) % HISTORY_SIZE; // 在Sprite上绘制折线图背景和坐标轴 spr.drawRect(10, 90, 180, 40, TFT_DARKGREY); // 绘制折线 int16_t prevX 10, prevY 130; for (int i 0; i HISTORY_SIZE; i) { int idx (historyIndex i) % HISTORY_SIZE; int16_t x 10 (i * 180 / HISTORY_SIZE); // 将数值映射到Y轴坐标假设最大值为200 KB/s int16_t y 130 - constrain(netSentHistory[idx], 0, 200) * 40 / 200; if (i 0) { spr.drawLine(prevX, prevY, x, y, TFT_CYAN); } prevX x; prevY y; } }5. 系统联调与问题排查实录将代码分别烧录到ESP32和运行在PC端后真正的挑战才开始。以下是几个我踩过的坑和解决方法。5.1 连接建立失败现象PC端Python脚本报错SerialException: could not open port COM5: Permission denied或[WinError 121]。排查1确认端口号。在Windows设备管理器的“端口COM和LPT”下查看蓝牙串口对应的COM号确保脚本中的端口号一致。排查2关闭占用程序。确保没有其他软件如Arduino IDE的串口监视器、Putty、其他蓝牙终端正在占用这个COM口。排查3重新配对。有时蓝牙配对信息会出错。在电脑和ESP32上删除旧的配对记录重新进行配对。ESP32端可以在setup()里使用SerialBT.disconnect()和SerialBT.end()后再重新begin()。排查4检查ESP32蓝牙初始化。确保ESP32程序已烧录并运行且蓝牙名称正确。可以在手机蓝牙搜索列表中查看是否能找到“ESP32-System-Monitor”。5.2 数据接收乱码或不全现象ESP32串口监视器显示乱码或JSON解析一直失败。排查1波特率匹配。确保PC端serial.Serial和ESP32端Serial.begin、SerialBT.begin使用的波特率一致。虽然蓝牙串口本身速率是自适应的但虚拟串口层的波特率设置需要匹配115200是通用选择。排查2数据分隔符。我使用\n作为数据包结束标志。确保PC端发送的每个JSON字符串末尾都添加了\n。在ESP32端使用readStringUntil(\n)来读取完整的一包数据。排查3JSON缓冲区大小。如果数据包较大例如包含更多传感器信息需要增大DynamicJsonDocument doc(512)中的缓冲区大小。可以通过serializeJson(doc, Serial)打印doc的大小来估算。排查4字符编码。双方都使用UTF-8编码。Python的encode(utf-8)和Arduino的默认字符串处理都是UTF-8通常没问题。5.3 屏幕显示异常现象白屏、花屏、颜色错乱、显示不全。排查1引脚定义。这是最常见的问题反复检查User_Setup.h或你选择的配置文件中的引脚定义TFT_CS,TFT_DC,TFT_RST,TFT_MOSI,TFT_SCLK是否与你的实际接线一一对应。ESP32的引脚编号不是连续的容易看错。排查2屏幕初始化与旋转。tft.init()必须在setup()中调用。tft.setRotation(0/1/2/3)可以调整屏幕方向试试不同的值。排查3电源问题。屏幕尤其是背光可能耗电较大。确保使用ESP32的VIN5V或稳定的3.3V引脚为屏幕供电并且共地。如果屏幕有独立的背光控制引脚TFT_BL尝试在代码中将其设置为HIGH。排查4库冲突。确保只安装了TFT_eSPI库的一个版本并且正确配置了用户设置文件。有时其他TFT库如Adafruit的库可能会引起冲突。5.4 数据传输延迟或卡顿现象屏幕更新明显慢于1秒或者数据跳跃。优化1减少数据量。PC端只发送必要的数据并适当降低发送频率如改为2秒一次。JSON键名可以缩短如用c”代替cpu_percent但会降低可读性。优化2优化ESP32端绘制。使用前面提到的Sprite进行局部刷新避免全屏刷新。只更新变化的部分。优化3检查蓝牙信号。确保ESP32和电脑蓝牙适配器之间没有严重的物理遮挡。USB 3.0接口有时会对2.4G信号包括蓝牙造成干扰尝试将蓝牙适配器插在USB 2.0口或使用延长线。优化4简化UI。如果使用了复杂的图形或历史图表会消耗大量计算和内存。可以考虑简化显示内容或者降低图表更新的频率。6. 功能扩展与进阶玩法基础功能实现后这个项目还有很大的扩展空间。1. 监控更多指标GPU监控PC端可以使用GPUtilPython库或调用NVIDIA SMI/AMD ADL来获取GPU占用率和温度并加入到数据包中。进程监控监控特定进程如你的渲染软件、游戏的CPU和内存占用。硬件传感器在ESP32上连接DHT11温湿度传感器同时显示桌面环境温湿度。2. 显示模式与交互多页面切换在ESP32上接一个按钮单击切换不同的显示页面如概览页、详细数据页、图表历史页。屏幕旋转与亮度调节通过加速度计实现自动旋转或通过电位器调节屏幕亮度。3. 无线升级OTA为ESP32配置Wi-Fi和OTA功能这样以后更新程序就不需要再插线烧录了直接通过网络上传新的固件。4. 改用BLE低功耗蓝牙当前使用的是经典蓝牙SPP功耗相对较高。可以改用BLE蓝牙低功耗ESP32作为GATT服务器PC作为客户端连接并订阅特征值通知。这样功耗会大大降低甚至可以考虑用电池供电。不过BLE的通信模型比SPP复杂一些。5. 集成到智能家居平台将ESP32监控屏的数据通过MQTT协议发布到Home Assistant或Node-RED等平台这样你不仅能在桌面上看到还能在手机App或家庭控制面板上查看PC状态甚至设置自动化规则如CPU温度过高时发送通知。我个人在完成基础版本后增加了GPU监控和一个简单的折线图显示网络流量历史。最大的体会是调试阶段一定要有耐心特别是硬件接线和软件配置的对应关系必须一丝不苟。当屏幕上第一次正确跳动出CPU百分比数字时那种成就感是纯粹的。这个项目就像搭积木基础框架搭好后你可以根据自己的需求和创意不断往上添加新的模块乐趣无穷。