A tiny USB-attached status display: an ESP32-C3 drives a 72×40 SSD1306 OLED and shows up to 5 lines of text that a host computer pushes over USB as JSON.
The firmware is deliberately dumb — it just renders whatever lines it receives —
so the content lives on the host. Included is gpu_status.py, which turns the
panel into a live status readout for a Linux server with NVIDIA GPUs (hostname,
CPU, RAM, and per-GPU utilisation / temperature / memory).
┌──────────────┐
│ gpubox01 │ hostname (+ "pos/N" when multiple GPUs)
│ CPU 25% 2.6 │ CPU busy % + 1-min load average
│ RAM 31/64G │ system RAM used / total
│ G0 99% 72C │ GPU util % + temperature
│ G0 20/24G │ GPU VRAM used / total (or GPU name on unified-memory parts)
└──────────────┘
| ESP32-C3 (primary) | ESP32-S3 (optional) | |
|---|---|---|
| Panel | 72×40 SSD1306 (0.42") | 128×64 SSD1306 |
| I²C address | 0x3C |
0x3C |
| SDA / SCL | GPIO 5 / GPIO 6 | GPIO 17 / GPIO 18 |
| Lines × cols | 5 × 12 (5×7 font) | 4 × 21 (2× font) |
The ESP32-C3's native USB acts as both the flashing port (ROM bootloader) and the
runtime JSON channel (/dev/ttyACM*).
main/ app_main() — inits OLED, shows splash, idles
hello_world_main.c
components/oled_display/ SSD1306 I²C driver + JSON serial reader task
oled_display.c
include/oled_display.h OLED_LINE_COUNT, pins, public API
font5x7.h
test_serial.py send canned JSON test frames
gpu_status.py live Linux + NVIDIA GPU status client
systemd/gpu-status.service run the status client on boot
sdkconfig.defaults.esp32c3 target config (console on UART, 160 MHz)
Requires ESP-IDF v5.x (CMake build system, not PlatformIO).
source ../esp-idf/export.sh
idf.py build
idf.py -p /dev/ttyACM0 flash # flash over USB CDC (adjust port)
idf.py monitor # logs over UART console (NOT USB)Flashing uses the USB CDC port (ESP32-C3 ROM bootloader). idf.py monitor reads
the UART console, so it does not conflict with the USB JSON client.
After flashing, the panel shows a splash screen until the host sends data:
Status Disp
ESP32-C3
Ready.
Waiting for
USB data...
Newline-terminated JSON, sent to the USB CDC port at 115200 baud:
{"lines": ["Line 1", "Line 2", "Line 3", "Line 4", "Line 5"]}- Up to
OLED_LINE_COUNTstrings are rendered (5 on C3, 4 on S3); extras ignored. - Fewer lines updates only those; each line is truncated to 12 chars (C3).
- The firmware renders immediately and echoes nothing back.
python3 test_serial.py # defaults to /dev/ttyACM0
python3 test_serial.py /dev/ttyACM1 # override portgpu_status.py polls the host and pushes a frame every few seconds. CPU and RAM
come from /proc; GPU data from nvidia-smi. On multi-GPU hosts, lines 3–4
rotate through one GPU per refresh (line 0 shows pos/N).
python3 gpu_status.py # auto-detect port, 2s refresh loop
python3 gpu_status.py -p /dev/ttyACM0 # explicit port
python3 gpu_status.py -i 1.0 # faster refresh
python3 gpu_status.py --once # single update, then exit
python3 gpu_status.py --probe # diagnose GPU detection (no OLED needed)
python3 gpu_status.py --debug # print raw nvidia-smi each refresh| Flag | Purpose |
|---|---|
-p, --port |
serial port; default auto-detects /dev/serial/by-id → /dev/ttyACM* |
-i, --interval |
refresh seconds (default 2.0) |
--once |
one frame and quit (good for cron/testing) |
--probe |
print detected GPUs + raw nvidia-smi, then exit |
--debug |
verbose nvidia-smi output while running |
Dependencies: pip install pyserial, and nvidia-smi on PATH. No psutil.
These have a unified-memory GPU, so nvidia-smi reports [N/A] for some
fields (VRAM, sometimes utilisation). The client tolerates this: the GPU is still
recognised (anchored on its index), unavailable VRAM is replaced by the GPU name
on line 4, and other missing metrics render as --. If a GPU isn't detected, run
--probe to see exactly what nvidia-smi returns.
A ready-to-edit unit lives at systemd/gpu-status.service.
- Grant serial access (one-time, then re-login):
sudo usermod -aG dialout $USER - Edit the unit — set
User=and the absoluteExecStart=path to your clone. The port is auto-detected; pin it via a/dev/serial/by-id/...path inExecStartif the machine has other ACM serial devices. - Install & enable:
sudo cp systemd/gpu-status.service /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable --now gpu-status.service - Verify:
systemctl status gpu-status journalctl -u gpu-status -f # live frames
If the display isn't connected yet, the client stays active (running) and
waits for it (polling every --interval seconds), then connects
automatically when it's plugged in. If the ESP is later unplugged, it drops the
connection and waits again — no restart churn. Restart=always is a backstop for
unexpected crashes.
The USB CDC port must carry only JSON — no log output. This requires the secondary console disabled:
CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
# CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG is not set
Logs then go to UART0 only (read via idf.py monitor). If logs appear on
USB, they corrupt the JSON stream and the console contends with the firmware's
reads, so input is dropped.
kconfgen quirk: ESP-IDF silently ignores a non-default
choicemember set insdkconfig.defaults.esp32c3, so this value must be written directly into the persistentsdkconfig(which takes precedence), thenidf.py build— do not deletesdkconfigor it reverts. Verify withgrep CONSOLE_SECONDARY sdkconfig.
pyserial asserts DTR/RTS on open(), which on the ESP32-C3 maps to EN/GPIO9 and
resets the chip into download mode (USB stalls, writes hang forever). Both
gpu_status.py and test_serial.py avoid this by de-asserting dtr/rts
before open() and setting a write_timeout. Reuse that pattern for any new
client.
The board enumerates as /dev/ttyACM0 or /dev/ttyACM1 depending on
reset/replug order. Find it with ls /dev/ttyACM*, or rely on gpu_status.py's
auto-detection (prefers the stable /dev/serial/by-id symlink).
| Symptom | Likely cause / fix |
|---|---|
| Display blank, no input accepted | Secondary console still on USB — see Console gotcha; re-check sdkconfig. |
| Client hangs after "Opened …" | Chip reset into download mode on open — ensure DTR/RTS de-asserted; recover with idf.py -p <port> flash. |
Cannot open /dev/ttyACMx |
Wrong/changed port — ls /dev/ttyACM*; add user to dialout. |
--probe says 0 GPUs |
nvidia-smi missing or erroring — check its raw output in the [debug] lines. |
GPU shows but VRAM n/a |
Expected on unified-memory parts (DGX Spark / Jetson); line 4 shows the GPU name instead. |