Most of us check our PC's performance by opening Task Manager or glancing at a secondary monitor, which always means staring at yet another bright, backlit screen. I wanted something different. Something that just sits quietly on my desk, shows me what my computer is doing in real time, and doesn't pile on more screen glare to my workspace.
So I built a tiny PC performance monitor using an e-ink display, the same paper-like technology you'd find in a Kindle. It shows live CPU usage, RAM, Disk, Network speed, Battery status, and System Uptime, all refreshing every second. And unlike a traditional LCD or OLED display, there's no backlight involved whatsoever. The screen holds its image with zero power draw and only consumes energy during the brief moment it updates. The result is something that genuinely looks like a printed piece of paper sitting on your desk. Readable in any lighting, completely glare-free, and oddly satisfying to look at.
The build itself uses just a handful of components: a Seeed Studio XIAO ESP32-S3 microcontroller, a WeAct Studio 1.54" e-paper display, and a 3D-printed enclosure. A Python script running on the PC collects system stats and sends them over to the display through USB serial. The firmware, the Python script, and the 3D print files are all open-source, so anyone with a 3D printer and basic soldering skills can put one together.
Honestly, this started as a fun weekend project, but it ended up shifting how I think about the screens surrounding me every day. We've gotten so used to having bright, power-hungry displays everywhere, all constantly pulling at our attention. This little e-ink monitor does the opposite. It gives me the information I need without the glow, without the wasted energy, and without the eye strain that creeps up after a long day at the desk. It's a small move toward calmer, more thoughtful technology, and I think it's worth sharing with anyone who's ever felt just a little overwhelmed by their own setup.
Components:For this build, I used two compact off-the-shelf modules wired together and housed inside a fully 3D printed enclosure.
Electronics
Hardware
- Hook-up Wire
- Solder Kit
- USB-C Cable (for powering and flashing the XIAO)
Fabrication
- 3D Printed Parts (files included)
Software
- Arduino IDE
- Python 3 (runs on your PC)
- psutil (Python library for reading system stats)
- pyserial (Python library for USB serial communication)
The e-paper display connects directly to the XIAO ESP32-S3 through a handful of wires, and the whole assembly tucks neatly into the printed enclosure, making it straightforward to build, modify, and reproduce.
Step 1: CAD Design
For this project, I didn't design the enclosure from scratch. I came across a beautifully minimalist stand on MakerWorld, designed by Matt (Endpoint101), that fits the WeAct 1.54" e-paper display almost perfectly. It also has a clip on the back that holds the ESP32 development board in place, which meant no extra fasteners or glue were needed. Full credit goes to Matt for the thoughtful design work here. Go give his model a boost on MakerWorld if you end up using it!
The stand also includes an optional partial cover to hide any messy wiring at the back, which I found genuinely useful once everything was wired up.
Print Settings
I printed the enclosure in white PLA, which ended up being a great choice. The clean white finish gives it a very polished, almost commercial look that pairs nicely with the paper-like appearance of the e-ink display itself.
Here are the settings I used, based on the designer's recommended profile:
- Material: White PLA
- Layer Height: 0.2mm
- Walls: 2
- Infill: 15%
- Supports: Not required
- Estimated Print Time: Around 31 minutes
The print came out clean with no issues at these settings. If your first layer adhesion is solid and your bed is leveled well, this is a straightforward print with no tricky overhangs or tight tolerances to worry about.
Step 2: Where to Get Your PCBs ManufacturedThis project uses off-the-shelf modules and jumper wires, so you won't need a custom PCB to follow along. That said, if you ever want to take this further and design a proper integrated board with the ESP32 and e-paper connector in one clean package, here's where I'd point you.
This step is sponsored by NextPCB, one of the larger PCB and PCBA manufacturers out there, shipping to makers in over 150 countries.
They recently launched a service called Rev 0 PCBA that's worth knowing about if you're prototyping. You upload your Gerber files, BOM, and Pick-and-Place file, and the platform automatically runs DFM and DFA checks, matches your components against their inventory of over 600, 000 in-stock parts, and gives you a fixed price quote on the spot. No emails back and forth, no waiting on a sales engineer. Once you confirm, fully assembled boards can ship in as fast as 7 working days.
For a first PCBA order, they're offering up to a $500 SMT coupon through the Rev 0 campaign, which makes prototype quantities essentially free to assemble.
For a project like this one specifically, a small breakout PCB would make the build much cleaner and more reliable than loose jumper wires inside a printed enclosure. That's exactly the kind of thing Rev 0 PCBA is built for.
🔗 Get $500 SMT coupon on your first Rev0 PCBA order: https://www.nextpcb.com/rev0-pcba
🔗 NextPCB Official Website: https://www.nextpcb.com/
Step 3: The Build
With all the components in hand and the enclosure printed, putting everything together is actually pretty satisfying. There's no soldering involved at any point. Everything connects through jumper wires and a small JST connector.
- Start by sliding the WeAct 1.54" e-paper display into the front slot of the enclosure. The screen should face forward through the rectangular opening, with the display PCB sitting flush against the back of the frame. The fit is meant to be snug, so don't force it. If yours feels a little loose, a small piece of double-sided tape on the back of the display PCB will hold it in place just fine.
- Next, plug the mini JST cable into the connector port on the e-paper display PCB. It only fits one way, so there's no guessing involved there.
- Now take the other end of that cable and connect the jumper wires to the XIAO ESP32-S3 according to this pin mapping:
- BUSY → D0 (GPIO 1)
- RST → D1 (GPIO 2)
- DC → D2 (GPIO 3)
- CS → D3 (GPIO 4)
- SCK → D8 (GPIO 7, default SPI clock)
- MOSI → D10 (GPIO 9, default SPI MOSI)
- VCC → 3V3
- GND → GND
- One thing worth being careful about here: make sure VCC goes to the 3V3 pin and not the 5V pin. The e-paper display runs on 3.3V logic, and accidentally connecting it to 5V can damage it. Also, if you're using individual jumper wires rather than a ribbon cable, color-coding them really does help. Red for VCC, black for GND, and a different color for each signal line will save you a lot of head-scratching if something doesn't work on the first try.
- Once everything is wired up, tuck the XIAO ESP32-S3 into the slot at the back of the enclosure and route the wires so they're not pulling on any of the connections. Before you close everything up, make sure the USB-C port on the XIAO is still accessible. You'll need it for flashing the firmware and for the live serial connection to your PC.
There are two pieces of software involved in this project. The firmware that runs on the XIAO ESP32-S3 and handles everything the display does, and a Python script that runs on your PC in the background, collects your system stats, and sends them over to the board through USB serial.
1. Arduino Libraries
Open the Arduino IDE and head to Sketch → Include Library → Manage Libraries. Search for and install these three libraries:
- GxEPD2 by Jean-Marc Zingg — this is the main driver for the e-paper display and handles both full and partial refresh
- Adafruit GFX by Adafruit — provides the graphics primitives like text rendering and shapes
- ArduinoJson by Benoît Blanchon — parses the JSON data coming in from the Python script
2.Board Setup
Go to Tools → Board and look for XIAO ESP32S3. If it doesn't show up, you'll need to add the Seeed board package first. Go to File → Preferences, find the "Additional Board Manager URLs" field, and paste in this URL:
https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json
Then go to Tools → Board Manager, search for "esp32", and install the package. After that, XIAO_ESP32S3 will appear in your board list.
3.Arduino Firmware
Here's the firmware that goes on the XIAO. It handles the display layout, listens for incoming serial data, and manages the refresh logic.
/*
* ============================================================
* Code by: Shahbaz Hashmi Ansari
* ============================================================
*/
#include <GxEPD2_BW.h>
#include <Adafruit_GFX.h>
#include <ArduinoJson.h>
#include <SPI.h>
#include <Fonts/FreeSansBold9pt7b.h>
#include <Fonts/FreeSans9pt7b.h>
#include <Fonts/FreeMonoBold9pt7b.h>
#include <Fonts/FreeSansBold12pt7b.h>
// ── Pin Definitions ──
#define EPD_BUSY 1
#define EPD_RST 2
#define EPD_DC 3
#define EPD_CS 4
GxEPD2_BW<GxEPD2_154_D67, GxEPD2_154_D67::HEIGHT> display(
GxEPD2_154_D67(EPD_CS, EPD_DC, EPD_RST, EPD_BUSY)
);
float cpu_percent = 0;
int cpu_freq = 0;
float ram_percent = 0;
float ram_used = 0;
float ram_total = 0;
float dsk_percent = 0;
float dsk_used = 0;
float dsk_total = 0;
float net_up = 0;
float net_down = 0;
int bat_percent = -1;
bool bat_plugged = false;
int uptime_h = 0;
int uptime_m = 0;
// ── Serial Buffer ──
#define BUF_SIZE 768
char serialBuf[BUF_SIZE];
int bufIdx = 0;
int braceDepth = 0;
bool inJson = false;
// ── Refresh Management ──
int partialCount = 0;
const int FULL_EVERY = 30;
bool firstDraw = true;
// ============================================================
// ICON HELPERS
// ============================================================
void drawIconCPU(int x, int y) {
display.fillRoundRect(x + 2, y + 2, 10, 10, 1, GxEPD_BLACK);
display.fillRect(x + 4, y + 4, 6, 6, GxEPD_WHITE);
display.fillRect(x + 5, y + 5, 4, 4, GxEPD_BLACK);
for (int i = 0; i < 3; i++) {
display.fillRect(x + 4 + i * 3, y, 1, 2, GxEPD_BLACK);
display.fillRect(x + 4 + i * 3, y + 12, 1, 2, GxEPD_BLACK);
display.fillRect(x, y + 4 + i * 3, 2, 1, GxEPD_BLACK);
display.fillRect(x + 12, y + 4 + i * 3, 2, 1, GxEPD_BLACK);
}
}
void drawIconRAM(int x, int y) {
display.drawRect(x, y + 3, 14, 8, GxEPD_BLACK);
display.fillRect(x + 2, y + 5, 2, 4, GxEPD_BLACK);
display.fillRect(x + 6, y + 5, 2, 4, GxEPD_BLACK);
display.fillRect(x + 10, y + 5, 2, 4, GxEPD_BLACK);
display.fillRect(x + 5, y + 9, 4, 2, GxEPD_WHITE);
}
void drawIconDisk(int x, int y) {
display.drawCircle(x + 7, y + 7, 6, GxEPD_BLACK);
display.drawCircle(x + 7, y + 7, 5, GxEPD_BLACK);
display.fillCircle(x + 7, y + 7, 2, GxEPD_BLACK);
display.drawLine(x + 7, y + 7, x + 11, y + 4, GxEPD_BLACK);
}
void drawIconNet(int x, int y) {
display.fillTriangle(x + 3, y + 6, x, y + 6, x + 3, y, GxEPD_BLACK);
display.fillRect(x + 2, y + 6, 3, 4, GxEPD_BLACK);
display.fillTriangle(x + 10, y + 8, x + 7, y + 8, x + 10, y + 14, GxEPD_BLACK);
display.fillRect(x + 9, y + 4, 3, 4, GxEPD_BLACK);
}
void drawIconBat(int x, int y) {
display.drawRect(x, y + 3, 11, 8, GxEPD_BLACK);
display.fillRect(x + 11, y + 5, 2, 4, GxEPD_BLACK);
if (bat_percent >= 0) {
int fill = map(constrain(bat_percent, 0, 100), 0, 100, 0, 9);
display.fillRect(x + 1, y + 4, fill, 6, GxEPD_BLACK);
}
}
void drawIconClock(int x, int y) {
display.drawCircle(x + 7, y + 7, 6, GxEPD_BLACK);
display.drawCircle(x + 7, y + 7, 5, GxEPD_BLACK);
display.fillCircle(x + 7, y + 7, 1, GxEPD_BLACK);
display.drawLine(x + 7, y + 7, x + 7, y + 3, GxEPD_BLACK);
display.drawLine(x + 7, y + 7, x + 11, y + 7, GxEPD_BLACK);
}
// ============================================================
// UI HELPERS
// ============================================================
void drawProgressBar(int x, int y, int w, int h, float percent) {
display.drawRect(x, y, w, h, GxEPD_BLACK);
int filled = (int)((w - 2) * constrain(percent, 0, 100) / 100.0);
if (filled > 0) {
display.fillRect(x + 1, y + 1, filled, h - 2, GxEPD_BLACK);
}
}
void drawRightAligned(const char* text, int rightX, int topY) {
int16_t x1, y1;
uint16_t tw, th;
display.getTextBounds(text, 0, topY, &x1, &y1, &tw, &th);
display.setCursor(rightX - tw, topY);
display.print(text);
}
void drawCentered(const char* text, int centerX, int topY) {
int16_t x1, y1;
uint16_t tw, th;
display.getTextBounds(text, 0, topY, &x1, &y1, &tw, &th);
display.setCursor(centerX - (tw / 2), topY);
display.print(text);
}
void drawPanel(int x, int y, int w, int h, const char* title) {
display.fillRoundRect(x, y, w, h, 3, GxEPD_WHITE);
display.drawRoundRect(x, y, w, h, 3, GxEPD_BLACK);
display.fillRoundRect(x, y, w, 11, 3, GxEPD_BLACK);
display.fillRect(x, y + 8, w, 3, GxEPD_BLACK);
display.setFont(NULL);
display.setTextColor(GxEPD_WHITE);
display.setCursor(x + 4, y + 2);
display.print(title);
display.setTextColor(GxEPD_BLACK); // Crucial to prevent invisible text later
}
void drawPill(int x, int y, int w, int h, const char* text) {
display.fillRoundRect(x, y, w, h, 4, GxEPD_WHITE);
display.drawRoundRect(x, y, w, h, 4, GxEPD_BLACK);
display.setFont(NULL);
display.setTextColor(GxEPD_BLACK);
display.setCursor(x + 6, y + 3);
display.print(text);
}
// ============================================================
// SPLASH SCREEN
// ============================================================
void drawSplashScreen() {
display.setFullWindow();
display.firstPage();
do {
display.fillScreen(GxEPD_WHITE);
display.drawRect(0, 0, 200, 200, GxEPD_BLACK);
display.drawRect(2, 2, 196, 196, GxEPD_BLACK);
display.fillRect(4, 4, 14, 2, GxEPD_BLACK);
display.fillRect(4, 4, 2, 14, GxEPD_BLACK);
display.fillRect(182, 4, 14, 2, GxEPD_BLACK);
display.fillRect(194, 4, 2, 14, GxEPD_BLACK);
display.fillRect(4, 194, 14, 2, GxEPD_BLACK);
display.fillRect(4, 182, 2, 14, GxEPD_BLACK);
display.fillRect(182, 194, 14, 2, GxEPD_BLACK);
display.fillRect(194, 182, 2, 14, GxEPD_BLACK);
display.fillRoundRect(88, 28, 24, 24, 2, GxEPD_BLACK);
display.fillRect(92, 32, 16, 16, GxEPD_WHITE);
display.fillRect(95, 35, 10, 10, GxEPD_BLACK);
for (int i = 0; i < 4; i++) {
display.fillRect(92 + i * 5, 24, 2, 4, GxEPD_BLACK);
display.fillRect(92 + i * 5, 52, 2, 4, GxEPD_BLACK);
display.fillRect(84, 32 + i * 5, 4, 2, GxEPD_BLACK);
display.fillRect(112, 32 + i * 5, 4, 2, GxEPD_BLACK);
}
display.setTextColor(GxEPD_BLACK);
display.setFont(&FreeSansBold12pt7b);
drawCentered("PC MONITOR", 100, 90);
display.fillRect(36, 98, 128, 1, GxEPD_BLACK);
display.fillRect(56, 101, 88, 1, GxEPD_BLACK);
display.setFont(&FreeSans9pt7b);
drawCentered("E-Ink Dashboard", 100, 122);
display.fillRect(66, 132, 68, 1, GxEPD_BLACK);
display.setFont(NULL);
const char* line1 = "XIAO ESP32-S3";
const char* line2 = "WeAct 1.54\" 200x200 BW";
const char* line3 = "Awaiting PC Data";
display.setCursor((200 - strlen(line1) * 6) / 2, 146);
display.print(line1);
display.setCursor((200 - strlen(line2) * 6) / 2, 158);
display.print(line2);
display.setCursor((200 - strlen(line3) * 6) / 2, 176);
display.print(line3);
} while (display.nextPage());
}
// ============================================================
// DASHBOARD DRAWING
// ============================================================
void drawDashboard() {
char textBuf[48];
bool doFullRefresh = firstDraw || (partialCount >= FULL_EVERY);
if (doFullRefresh) {
display.setFullWindow();
partialCount = 0;
} else {
display.setPartialWindow(0, 0, 200, 200);
partialCount++;
}
display.firstPage();
do {
display.fillScreen(GxEPD_WHITE);
display.drawRect(0, 0, 200, 200, GxEPD_BLACK);
// Header
display.fillRect(0, 0, 200, 20, GxEPD_BLACK);
display.setFont(&FreeSansBold9pt7b);
display.setTextColor(GxEPD_WHITE);
display.setCursor(8, 14);
display.print("PC MONITOR");
drawPill(160, 3, 34, 14, "LIVE");
// ───────────────── CPU HERO CARD ─────────────────
drawPanel(4, 24, 192, 48, "CPU");
drawIconCPU(8, 38);
display.setFont(&FreeSansBold12pt7b);
snprintf(textBuf, sizeof(textBuf), "%d%%", (int)cpu_percent);
display.setCursor(28, 56);
display.print(textBuf);
display.setFont(NULL);
snprintf(textBuf, sizeof(textBuf), "%.2f GHz", cpu_freq / 1000.0);
drawRightAligned(textBuf, 192, 44);
// THICKER CPU BAR (Height 10px)
drawProgressBar(8, 59, 184, 10, cpu_percent);
// ───────────────── RAM + DISK ─────────────────
drawPanel(4, 76, 94, 42, "RAM");
drawPanel(102, 76, 94, 42, "DISK");
// RAM Panel
drawIconRAM(8, 92);
display.setFont(&FreeSansBold9pt7b);
snprintf(textBuf, sizeof(textBuf), "%d%%", (int)ram_percent);
display.setCursor(28, 104);
display.print(textBuf);
// THICKER RAM BAR (Height 8px)
drawProgressBar(8, 107, 80, 8, ram_percent);
// DISK Panel
drawIconDisk(106, 92);
display.setFont(&FreeSansBold9pt7b);
snprintf(textBuf, sizeof(textBuf), "%d%%", (int)dsk_percent);
display.setCursor(126, 104);
display.print(textBuf);
// THICKER DISK BAR (Height 8px)
drawProgressBar(106, 107, 80, 8, dsk_percent);
// ───────────────── NET + BATTERY ─────────────────
drawPanel(4, 122, 94, 38, "NET");
drawPanel(102, 122, 94, 38, "BAT");
drawIconNet(8, 137);
display.setFont(NULL);
// NET UP Dynamic GB/MB Check
display.setCursor(28, 136);
display.print("UP ");
if (net_up > 999.0) {
snprintf(textBuf, sizeof(textBuf), "%.2f GB", net_up / 1024.0);
} else {
snprintf(textBuf, sizeof(textBuf), "%.1f MB", net_up);
}
display.print(textBuf);
// NET DOWN Dynamic GB/MB Check
display.setCursor(28, 148);
display.print("DN ");
if (net_down > 999.0) {
snprintf(textBuf, sizeof(textBuf), "%.2f GB", net_down / 1024.0);
} else {
snprintf(textBuf, sizeof(textBuf), "%.1f MB", net_down);
}
display.print(textBuf);
drawIconBat(106, 137);
if (bat_percent >= 0) {
display.setFont(&FreeSansBold9pt7b);
snprintf(textBuf, sizeof(textBuf), "%d%%", bat_percent);
display.setCursor(126, 148);
display.print(textBuf);
display.setFont(NULL);
display.setCursor(126, 150);
display.print(bat_plugged ? "Charging" : "On Battery");
} else {
display.setFont(NULL);
display.setCursor(126, 142);
display.print("No Battery");
}
// ───────────────── UPTIME STRIP ─────────────────
drawPanel(4, 164, 192, 34, "UPTIME");
drawIconClock(8, 178);
display.setFont(&FreeSansBold9pt7b);
snprintf(textBuf, sizeof(textBuf), "%dh%02dm", uptime_h, uptime_m);
display.setCursor(28, 192);
display.print(textBuf);
display.setFont(NULL);
drawRightAligned("System runtime", 192, 180);
} while (display.nextPage());
firstDraw = false;
}
// ============================================================
// JSON PARSING
// ============================================================
void parseStats(const char* json) {
StaticJsonDocument<512> doc;
DeserializationError err = deserializeJson(doc, json);
if (err) {
Serial.print("JSON Error: ");
Serial.println(err.c_str());
return;
}
cpu_percent = doc["cpu"] | 0.0f;
cpu_freq = doc["freq"] | 0;
ram_percent = doc["ram_p"] | 0.0f;
ram_used = doc["ram_u"] | 0.0f;
ram_total = doc["ram_t"] | 0.0f;
dsk_percent = doc["dsk_p"] | 0.0f;
dsk_used = doc["dsk_u"] | 0.0f;
dsk_total = doc["dsk_t"] | 0.0f;
net_up = doc["net_u"] | 0.0f;
net_down = doc["net_d"] | 0.0f;
bat_percent = doc["bat"] | -1;
bat_plugged = doc["plug"] | false;
uptime_h = doc["up_h"] | 0;
uptime_m = doc["up_m"] | 0;
Serial.println("Data received — updating display...");
drawDashboard();
}
// ============================================================
// SERIAL READING
// ============================================================
void readSerial() {
while (Serial.available()) {
char ch = Serial.read();
if (ch == '{') {
inJson = true;
braceDepth = 1;
bufIdx = 0;
serialBuf[bufIdx++] = ch;
} else if (inJson) {
serialBuf[bufIdx++] = ch;
if (ch == '{') braceDepth++;
else if (ch == '}') braceDepth--;
if (braceDepth == 0) {
serialBuf[bufIdx] = '\0';
parseStats(serialBuf);
inJson = false;
bufIdx = 0;
}
if (bufIdx >= BUF_SIZE - 1) {
bufIdx = 0;
inJson = false;
Serial.println("Buffer overflow — reset");
}
}
}
}
// ============================================================
// SETUP & LOOP
// ============================================================
void setup() {
Serial.begin(115200);
delay(1000);
SPI.begin(7, 8, 9, 4);
display.init(115200);
display.setRotation(1);
display.setTextWrap(false);
drawSplashScreen();
Serial.println("PC Stats E-Ink Monitor ready!");
Serial.println("Waiting for data from main.py...");
}
void loop() {
readSerial();
}Once the code is ready, select the correct port under Tools → Port and hit Upload. When it finishes, the display should show a splash screen, which means the firmware is running and waiting for data to come in.
4.Python Script
Make sure you have Python 3 installed on your PC. Then open a terminal and run:
pip install pyserial psutilpsutil handles reading your CPU, RAM, Disk, Network, and Battery stats directly from the operating system. pyserial takes care of sending that data down to the XIAO over USB.
Here's the Python script:
import serial
import serial.tools.list_ports
import psutil
import time
import json
import sys
# ══════════════════════════════════════════════════════════════
# CONFIGURATION
# ══════════════════════════════════════════════════════════════
SERIAL_PORT = None # Set to 'COM4' or '/dev/ttyACM0' to override auto-detect
BAUD_RATE = 115200
UPDATE_INTERVAL = 10
# ══════════════════════════════════════════════════════════════
# AUTO-DETECT ESP32 PORT
# ══════════════════════════════════════════════════════════════
def find_esp32_port():
"""Auto-detect the XIAO ESP32-S3 serial port."""
ports = serial.tools.list_ports.comports()
# Keywords that identify ESP32/XIAO boards
keywords = ['esp32', 'esp', 'xiao', 'usb serial', 'cp210', 'ch340', 'usb-serial', 'jtag']
for port in ports:
desc = (port.description or '').lower()
hwid = (port.hwid or '').lower()
for kw in keywords:
if kw in desc or kw in hwid:
return port.device
# If no keyword match, list available ports
if ports:
print("\n⚠ Could not auto-detect ESP32. Available ports:")
for p in ports:
print(f" {p.device} — {p.description}")
print(f"\nUsing first available: {ports[0].device}")
return ports[0].device
return None
# ══════════════════════════════════════════════════════════════
# COLLECT PC STATS
# ══════════════════════════════════════════════════════════════
# Globals to track network speed over time
last_net_io = None
last_net_time = None
def get_stats():
"""Gather system performance data and return as a compact dict."""
global last_net_io, last_net_time
# CPU
cpu_percent = psutil.cpu_percent(interval=0.5)
cpu_freq_obj = psutil.cpu_freq()
cpu_freq = int(cpu_freq_obj.current) if cpu_freq_obj else 0
# RAM
mem = psutil.virtual_memory()
ram_percent = round(mem.percent, 1)
ram_used = round(mem.used / (1024 ** 3), 1) # GB
ram_total = round(mem.total / (1024 ** 3), 1) # GB
# Disk (primary drive)
try:
disk = psutil.disk_usage('C:\\' if sys.platform == 'win32' else '/')
except Exception:
disk = psutil.disk_usage('/')
dsk_percent = round(disk.percent, 1)
dsk_used = round(disk.used / (1024 ** 3), 1) # GB
dsk_total = round(disk.total / (1024 ** 3), 1) # GB
# Network (Real-time Speed Calculation in MB/s)
current_net_io = psutil.net_io_counters()
current_time = time.time()
if last_net_io is None:
# First run: establish baseline
net_up_speed = 0.0
net_down_speed = 0.0
else:
# Calculate delta
time_delta = current_time - last_net_time
bytes_sent = current_net_io.bytes_sent - last_net_io.bytes_sent
bytes_recv = current_net_io.bytes_recv - last_net_io.bytes_recv
# Convert to MB/s
net_up_speed = (bytes_sent / (1024 ** 2)) / time_delta
net_down_speed = (bytes_recv / (1024 ** 2)) / time_delta
# Prevent negative spikes on network reset
net_up_speed = max(0.0, net_up_speed)
net_down_speed = max(0.0, net_down_speed)
# Save current state for next loop
last_net_io = current_net_io
last_net_time = current_time
# Battery
battery = psutil.sensors_battery()
bat_percent = int(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)
# Compact JSON keys to reduce serial payload size
return {
"cpu": cpu_percent,
"freq": cpu_freq,
"ram_p": ram_percent,
"ram_u": ram_used,
"ram_t": ram_total,
"dsk_p": dsk_percent,
"dsk_u": dsk_used,
"dsk_t": dsk_total,
"net_u": round(net_up_speed, 1),
"net_d": round(net_down_speed, 1),
"bat": bat_percent,
"plug": bat_plugged,
"up_h": uptime_h,
"up_m": uptime_m
}
# ══════════════════════════════════════════════════════════════
# PRETTY PRINT STATS (terminal display)
# ══════════════════════════════════════════════════════════════
def print_stats(stats, count):
"""Display current stats in the terminal."""
bar = lambda pct: '█' * int(pct / 5) + '░' * (20 - int(pct / 5))
print(f"\n{'─' * 50}")
print(f" PC STATS MONITOR │ Update #{count}")
print(f"{'─' * 50}")
print(f" CPU {bar(stats['cpu'])} {stats['cpu']:5.1f}% {stats['freq']} MHz")
print(f" RAM {bar(stats['ram_p'])} {stats['ram_p']:5.1f}% {stats['ram_u']}/{stats['ram_t']} GB")
print(f" DISK {bar(stats['dsk_p'])} {stats['dsk_p']:5.1f}% {stats['dsk_u']}/{stats['dsk_t']} GB")
print(f" NET ▲ {stats['net_u']:>6.1f} MB/s ▼ {stats['net_d']:>6.1f} MB/s")
if stats['bat'] >= 0:
plug_str = "Charging" if stats['plug'] else " On Battery"
print(f" BAT {bar(stats['bat'])} {stats['bat']}% {plug_str}")
else:
print(f" BAT No battery detected (desktop PC)")
print(f" UP {stats['up_h']}h {stats['up_m']:02d}m")
print(f"{'─' * 50}")
# ══════════════════════════════════════════════════════════════
# MAIN
# ══════════════════════════════════════════════════════════════
def main():
# Determine port
port = SERIAL_PORT or find_esp32_port()
if not port:
print("No serial port found! Connect your XIAO ESP32-S3 via USB.")
print(" Or set SERIAL_PORT manually in main.py")
sys.exit(1)
# Connect
print(f"""
╔══════════════════════════════════════════════════╗
║ PC Stats → E-Ink Display Monitor ║
║ XIAO ESP32-S3 + WeAct 1.54" E-Paper ║
╚══════════════════════════════════════════════════╝
Port: {port}
Baud: {BAUD_RATE}
Interval: {UPDATE_INTERVAL}s
""")
try:
ser = serial.Serial(port, BAUD_RATE, timeout=1)
time.sleep(2) # Wait for ESP32 to reset after serial connection
print(f"Connected to {port}\n")
# Read any startup messages from ESP32
while ser.in_waiting:
line = ser.readline().decode('utf-8', errors='ignore').strip()
if line:
print(f" [ESP32] {line}")
count = 0
while True:
count += 1
stats = get_stats()
# Send JSON + newline
json_str = json.dumps(stats, separators=(',', ':')) # compact JSON
ser.write((json_str + '\n').encode())
# Display in terminal
print_stats(stats, count)
print(f"Sent {len(json_str)} bytes to ESP32")
# Read any response from ESP32
time.sleep(0.5)
while ser.in_waiting:
line = ser.readline().decode('utf-8', errors='ignore').strip()
if line:
print(f" [ESP32] {line}")
# Wait for next update
time.sleep(UPDATE_INTERVAL - 0.5)
except serial.SerialException as e:
print(f"\nSerial error: {e}")
print(" Make sure:")
print(" 1. ESP32 is connected via USB")
print(" 2. Arduino Serial Monitor is CLOSED")
print(f" 3. Port {port} is correct")
sys.exit(1)
except KeyboardInterrupt:
print("\n\nStopped by user. Display will retain last stats.")
finally:
if 'ser' in locals() and ser.is_open:
ser.close()
print(" Serial port closed.")
if __name__ == '__main__':
main()5.Running Everything
Connect the XIAO to your PC via USB-C, then open the Python script and update the COM port to match your device. On Windows, you can find it under Device Manager → Ports. Then just run:
python main.pyThe terminal will start printing your system stats every few seconds, and within a moment the e-ink display will refresh and show everything live. CPU usage, RAM, Disk, Network speed, Battery, and Uptime, all sitting quietly on your desk.
The display uses partial refresh for regular updates, which is fast and flicker-free, and does a full refresh every few minutes to clear any ghosting that builds up over time. You can leave the script running in the background without any noticeable impact on your system.
Step 5: Working Video & TutorialHere's the full build video showing the assembly, code walkthrough, and live demo:
What started as a simple weekend project quickly turned into one of those builds that ended up being much more satisfying than I expected. At first, I just wanted an easier way to keep an eye on my PC's performance without constantly opening Task Manager or monitoring software.
But the moment I saw live CPU and memory data appearing on that tiny paper-like display, I knew there was something special about it.
Unlike a traditional LCD or OLED screen, this display doesn't glow, flash, or compete for your attention. Because it's an e-ink display, it uses no backlight, consumes power only when the image changes, and looks more like a printed card sitting on your desk than another electronic screen. In a setup already filled with bright monitors and notifications, having a display that quietly shows useful information without adding more visual noise feels surprisingly nice.
One of the best parts of this project is how accessible it is. The core hardware costs less than $10, there's no soldering involved, and everything runs on open-source software. The Arduino code, Python script, and 3D-print files are all available in the description, so anyone with basic tools and a little curiosity can build one in under an hour.
What I like most about this project is that it proves useful hardware doesn't need to be expensive, complicated, or power-hungry.
- Less energy because the display only uses power when refreshing the image
- Less eye strain because there's no backlight and no constant screen glare
- More accessible because it costs under $10, requires no custom PCB, and no soldering
- More open because every file is shared and every step is documented
And this is really just the beginning. The same hardware could easily be turned into a weather display, a Spotify now-playing screen, a Pomodoro timer, a smart notification panel, or something completely different.
I'd love to know what you'd build with it. If you have an idea for a new mode or feature, leave a comment below and let me know.
Hopefully this project gives you a different perspective on what a desktop monitor can be. Sometimes the most useful display isn't the brightest or the fastest. It's the one that quietly gives you the information you need while staying out of the way.







_t9PF3orMPd.png?auto=compress%2Cformat&w=40&h=40&fit=fillmax&bg=fff&dpr=2)



Comments