Skip to content

martin-ger/ESP32StatusDisplay

Repository files navigation

ESP32StatusDisplay

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)
└──────────────┘

Hardware

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*).


Repository layout

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)

Build & flash (firmware)

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...

Serial protocol

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_COUNT strings 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.

Quick test

python3 test_serial.py                 # defaults to /dev/ttyACM0
python3 test_serial.py /dev/ttyACM1    # override port

Live server / GPU status client

gpu_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.

Integrated GPUs (DGX Spark GB10, Jetson)

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.


Run on boot (systemd)

A ready-to-edit unit lives at systemd/gpu-status.service.

  1. Grant serial access (one-time, then re-login):
    sudo usermod -aG dialout $USER
  2. Edit the unit — set User= and the absolute ExecStart= path to your clone. The port is auto-detected; pin it via a /dev/serial/by-id/... path in ExecStart if the machine has other ACM serial devices.
  3. Install & enable:
    sudo cp systemd/gpu-status.service /etc/systemd/system/
    sudo systemctl daemon-reload
    sudo systemctl enable --now gpu-status.service
  4. 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.


Configuration & gotchas

Console must stay off the USB channel

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 choice member set in sdkconfig.defaults.esp32c3, so this value must be written directly into the persistent sdkconfig (which takes precedence), then idf.py build — do not delete sdkconfig or it reverts. Verify with grep CONSOLE_SECONDARY sdkconfig.

Host clients must not reset the chip on open

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.

Port number isn't stable

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).


Troubleshooting

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.

About

ESP32-C3 USB status display: 72x40 SSD1306 OLED driven over USB-CDC JSON, with a Linux/NVIDIA-GPU status client

Topics

Resources

License

Stars

Watchers

Forks

Contributors