For a long time, I was irritated by constantly having to alt‑tab to a task manager or a system monitoring tool to check how much of my PC’s resources were being used. Especially during heavy rendering or gaming sessions, having that information at a glance would have saved me a lot of guesswork. A small, dedicated display that shows CPU load, RAM usage, disk capacity, network traffic and battery status seemed like the ideal solution.
Instead of buying an overpriced desktop gadget, I decided to build my own. This project combines a low‑cost ESP32 development board with a standard 16×2 I2C LCD screen. A Python script running on the PC collects system statistics using the psutil library and sends them over a simple USB serial connection to the ESP32. The microcontroller parses the data as JSON and cycles through several informative pages, updating the screen every few seconds.
The whole system is completely local; no Wi‑Fi, no cloud service, and no complex configuration are required. Everything works over a single USB cable.
Software & Libraries- Arduino IDE – for uploading the ESP32 firmware
- LiquidCrystal I2C library by Frank de Brabander – drives the I2C LCD
- ArduinoJson library (version 6) by Benoit Blanchon – parses the incoming JSON string
- Python 3 – runs the PC monitoring script
- pyserial – handles USB serial communication from Python
- psutil – collects real‑time system information (CPU, memory, disk, network, battery, uptime)
You can install the two Python packages with:
pip install pyserial psutilIn the Arduino IDE, use the Library Manager to install LiquidCrystal I2C and ArduinoJson (version 6).
🔌 Wiring DiagramOne of the best things about using an I2C LCD is the minimal wiring: only four wires are needed. The I2C bus runs on two signal lines (SDA and SCL) plus power and ground.
Important: The default I2C pins on most ESP32 boards are GPIO21 (SDA) and GPIO22 (SCL). If your board uses different pins, change the Wire.begin(SDA, SCL) line in the sketch.⚙️ Setting Up the ESP32 FirmwareThe ESP32 continuously listens for serial data, builds a JSON message character by character, and parses it only after a complete object has been received. This robust approach avoids problems with fragmented or partial data.
1. Installing the Board PackageIf you haven’t used ESP32 with the Arduino IDE before:
Go to File > Preferences and add the Espressif ESP32 board index URL to the Additional Boards Manager URLs field.
Open Tools > Board > Boards Manager, search for esp32 and install the package by Espressif Systems.
Select ESP32 Dev Module (or your specific board) from the board list.
Create a new sketch, copy the complete code from below, change the LCD address if necessary, and upload it to your ESP32.
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <ArduinoJson.h>
// LCD configuration
#define LCD_ADDRESS 0x27
#define LCD_COLUMNS 16
#define LCD_ROWS 2
LiquidCrystal_I2C lcd(LCD_ADDRESS, LCD_COLUMNS, LCD_ROWS);
// Serial buffer – large enough for full JSON
#define SERIAL_BUFFER_SIZE 1024
char serialBuffer[SERIAL_BUFFER_SIZE];
int bufferIndex = 0;
// JSON accumulator: wait for balanced braces
int braceBalance = 0;
bool insideJson = false;
// Global variables (same as before)
float cpu_total = 0, cpu_cores[8] = {0};
int cpu_freq = 0;
float ram_perc = 0, ram_used = 0, ram_total = 0;
float disk_perc = 0, disk_used = 0, disk_total = 0;
float net_up = 0, net_down = 0;
int bat_perc = -1;
bool bat_plugged = false;
int uptime_h = 0, uptime_m = 0;
// Page cycling
unsigned long lastPageChange = 0;
const unsigned long PAGE_INTERVAL = 3000;
int currentPage = 0;
const int TOTAL_PAGES = 5;
// ========== JSON PARSING ==========
void parseStats(const char* jsonString) {
Serial.print("RAW JSON (full): ");
Serial.println(jsonString);
StaticJsonDocument<1024> doc;
DeserializationError error = deserializeJson(doc, jsonString);
if (error) {
Serial.print("❌ JSON parse failed: ");
Serial.println(error.c_str());
return;
}
Serial.println("✅ JSON parsed successfully");
cpu_total = doc["cpu_total"] | 0.0f;
cpu_freq = doc["cpu_freq"] | 0;
JsonArray cores = doc["cores"];
for (int i = 0; i < cores.size() && i < 8; i++) cpu_cores[i] = cores[i].as<float>();
ram_perc = doc["ram_perc"] | 0.0f;
ram_used = doc["ram_used"] | 0.0f;
ram_total = doc["ram_total"] | 0.0f;
disk_perc = doc["disk_perc"] | 0.0f;
disk_used = doc["disk_used"] | 0.0f;
disk_total = doc["disk_total"] | 0.0f;
net_up = doc["net_up"] | 0.0f;
net_down = doc["net_down"] | 0.0f;
bat_perc = doc["bat_perc"] | -1;
bat_plugged = doc["bat_plugged"] | false;
uptime_h = doc["uptime_h"] | 0;
uptime_m = doc["uptime_m"] | 0;
Serial.print("CPU: "); Serial.println(cpu_total);
Serial.print("RAM: "); Serial.println(ram_perc);
Serial.print("DISK: "); Serial.println(disk_perc);
}
// ========== DISPLAY PAGES (unchanged) ==========
void showPage0() {
lcd.setCursor(0,0); lcd.print("CPU "); lcd.print(cpu_total,0); lcd.print("% "); lcd.print(cpu_freq); lcd.print("MHz");
lcd.setCursor(0,1); lcd.print("Cores:");
for(int i=0;i<2 && cpu_cores[i]>0;i++) { lcd.print(" "); lcd.print((int)cpu_cores[i]); lcd.print("%"); }
}
void showPage1() {
lcd.setCursor(0,0); lcd.print("RAM "); lcd.print(ram_perc,0); lcd.print("% Used:");
lcd.setCursor(0,1); lcd.print(ram_used,1); lcd.print("/"); lcd.print(ram_total,1); lcd.print("GB");
}
void showPage2() {
lcd.setCursor(0,0); lcd.print("DISK "); lcd.print(disk_perc,0); lcd.print("% Used:");
lcd.setCursor(0,1); lcd.print(disk_used,1); lcd.print("/"); lcd.print(disk_total,1); lcd.print("GB");
}
void showPage3() {
lcd.setCursor(0,0); lcd.print("NET ↑"); lcd.print(net_up,0); lcd.print("MB");
lcd.setCursor(0,1); lcd.print(" ↓"); lcd.print(net_down,0); lcd.print("MB");
}
void showPage4() {
lcd.setCursor(0,0);
if(bat_perc>=0){ lcd.print("BAT "); lcd.print(bat_perc); lcd.print("%"); if(bat_plugged) lcd.print(" CHG"); else lcd.print(" ");}
else lcd.print("No battery ");
lcd.setCursor(0,1); lcd.print("UP "); lcd.print(uptime_h); lcd.print("h "); lcd.print(uptime_m); lcd.print("m ");
}
void updateDisplay() {
lcd.clear();
switch(currentPage){
case 0: showPage0(); break; case 1: showPage1(); break; case 2: showPage2(); break;
case 3: showPage3(); break; case 4: showPage4(); break; default: showPage0();
}
}
// ========== SETUP ==========
void setup() {
Serial.begin(115200);
Wire.begin(21,22);
lcd.begin(); lcd.backlight();
lcd.print(" PC Monitor "); lcd.setCursor(0,1); lcd.print(" v2.1 ");
delay(2000); lcd.clear(); updateDisplay();
Serial.println("ESP32 ready – waiting for JSON data...");
}
// ========== MAIN LOOP ==========
void loop() {
// Read all available serial characters
while (Serial.available()) {
char ch = Serial.read();
// Track braces to find complete JSON object
if (ch == '{') {
insideJson = true;
braceBalance = 1;
bufferIndex = 0;
serialBuffer[bufferIndex++] = ch;
}
else if (insideJson) {
serialBuffer[bufferIndex++] = ch;
if (ch == '{') braceBalance++;
else if (ch == '}') braceBalance--;
// When braces balance back to zero, we have a full JSON object
if (braceBalance == 0) {
serialBuffer[bufferIndex] = '\0';
parseStats(serialBuffer);
insideJson = false;
bufferIndex = 0;
// Immediately update display with new data
updateDisplay();
}
// Prevent buffer overflow
if (bufferIndex >= SERIAL_BUFFER_SIZE - 1) {
bufferIndex = 0;
insideJson = false;
Serial.println("Buffer overflow, resetting");
}
}
// Ignore any characters outside JSON (like stray newlines)
}
// Cycle pages
if (millis() - lastPageChange >= PAGE_INTERVAL) {
lastPageChange = millis();
currentPage = (currentPage + 1) % TOTAL_PAGES;
updateDisplay();
}
}After the upload, open the Serial Monitor (115200 baud) to see the "ESP32 ready" message.
Then close the monitor – it is time to set up the PC side.
3. 💻 Python Script – Collecting PC StatsThe Python script uses the psutil library to obtain live system information. All collected data is sent as a single JSON object (followed by a newline) over the serial port to the ESP32. The script runs continuously, updating at an interval of two seconds by default.
You only need to change one line: SERIAL_PORT should be set to the correct COM port (Windows) or device path (Linux / macOS) of your ESP32.
Windows: Open Device Manager, look under Ports (COM & LPT) for something like USB Serial Port (COMx).
Linux: Run ls /dev/ttyUSB* or ls /dev/ttyACM*; the ESP32 usually appears as /dev/ttyUSB0.
macOS: It will appear as /dev/cu.usbserial-xxxx.
Create a file named pc_monitor.py and paste the following content. Edit SERIAL_PORT accordingly.
import serial
import psutil
import time
import json
# === CONFIGURATION ===
SERIAL_PORT = 'COM4' # Change to your ESP32 port (e.g., '/dev/ttyUSB0')
BAUD_RATE = 115200
UPDATE_INTERVAL = 2 # seconds
def get_system_stats():
"""Return a dictionary with all stats."""
# CPU
cpu_total = psutil.cpu_percent(interval=0.5)
cpu_freq = psutil.cpu_freq().current if psutil.cpu_freq() else 0
cpu_cores = psutil.cpu_percent(percpu=True)
# Memory
mem = psutil.virtual_memory()
ram_perc = round(mem.percent, 1)
ram_used = round(mem.used / (1024**3), 1)
ram_total = round(mem.total / (1024**3), 1)
# Disk (C:\ on Windows, / on Linux)
disk = psutil.disk_usage('/')
disk_perc = round(disk.percent, 1)
disk_used = round(disk.used / (1024**3), 1)
disk_total = round(disk.total / (1024**3), 1)
# Network (cumulative MB)
net = psutil.net_io_counters()
net_up = round(net.bytes_sent / (1024**2), 1)
net_down = round(net.bytes_recv / (1024**2), 1)
# Battery
battery = psutil.sensors_battery()
bat_perc = battery.percent if battery else -1
bat_plugged = battery.power_plugged if battery else False
# Uptime
uptime_sec = time.time() - psutil.boot_time()
uptime_h = int(uptime_sec // 3600)
uptime_m = int((uptime_sec % 3600) // 60)
stats = {
"cpu_total": cpu_total,
"cpu_freq": cpu_freq,
"cores": cpu_cores,
"ram_perc": ram_perc,
"ram_used": ram_used,
"ram_total": ram_total,
"disk_perc": disk_perc,
"disk_used": disk_used,
"disk_total": disk_total,
"net_up": net_up,
"net_down": net_down,
"bat_perc": bat_perc,
"bat_plugged": bat_plugged,
"uptime_h": uptime_h,
"uptime_m": uptime_m
}
return stats
def main():
try:
ser = serial.Serial(SERIAL_PORT, BAUD_RATE, timeout=1)
time.sleep(2) # Allow ESP32 to reset after serial open
print(f"Connected to {SERIAL_PORT}. Sending stats every {UPDATE_INTERVAL}s.\n")
while True:
stats = get_system_stats()
json_str = json.dumps(stats) + "\n"
ser.write(json_str.encode())
print("Sent:", json_str.strip())
time.sleep(UPDATE_INTERVAL)
except serial.SerialException as e:
print(f"Serial error: {e}")
except KeyboardInterrupt:
print("\nExiting...")
finally:
if 'ser' in locals() and ser.is_open:
ser.close()
if __name__ == "__main__":
main()6. Running the ScriptEnsure your ESP32 is plugged into the USB port and the Arduino Serial Monitor is closed (it blocks the port).
Open a terminal (Command Prompt, PowerShell, or Linux/macOS terminal) inside the folder where pc_monitor.py is saved.
Run:
python pc_monitor.pyYou should see the JSON data being printed every 2 seconds.
At the same time, the LCD on the ESP32 will start cycling through live system information.
7. 📺 What You See on the LCDThe display cycles through five different pages, spending 3 seconds on each page before moving to the next one.
The data updates immediately whenever a new JSON packet arrives from the PC (every 2 seconds), so all numbers stay fresh.
8. 🧪 Testing and First RunWhen you first connect power, the LCD will show a brief splash screen (PC Monitor v2.0) and then the first statistics page. If no serial data is yet received, the values remain at zero.
Once the Python script starts, you should see the numbers change live. The PC side also prints each sent JSON, so you can verify that data is actually being transmitted.
🖨️ 3D Printed Enclosure – Made by JUSTWAYOnce the electronics are working, the next step is to give the project a finished, professional look. Designing and printing an enclosure that precisely fits the ESP32 board, the LCD, and all the wires takes time and a good 3D printer.
I turned to JUSTWAY for a custom 3D printed case. JUSTWAY specialises in high‑quality printing with accurate dimensions, smooth walls, and clean layer lines. You can upload your own STEP file to their website and receive an instant quote. The parts arrive well packed and exactly as modelled
Why I recommend JUSTWAY for this project:
- Perfect fit for standard ESP32 Dev boards and 16×2 I2C LCDs
- Cutouts for the USB port and ventilation
- Available in multiple colors (black, white, transparent)
- Fast turnaround and worldwide shipping
👉 Visit JUSTWAY to get a quote for your own custom enclosure.
📚 Final ThoughtsThis project turns a cheap ESP32 and a simple I2C LCD into a dedicated PC performance monitor that sits on your desk. It requires no internet connection, no external cloud services, and only a single USB cable for power and data. The combination of Python and Arduino is flexible, and the JSON‑based protocol makes it easy to add or change the displayed metrics.
If you run into any issues, remember to check the basic points first: I2C address, correct wiring, correct COM port, and baud rate (115200). The provided ESP32 code has built‑in JSON parsing with error reporting, so the Serial Monitor will always tell you exactly what is wrong.
Now go ahead, build your own display, and never Alt‑Tab to the task manager again. If you do build one, share your photos and any modifications you made — I would love to see what you come up with.








Comments