A LAN desktop hardware monitor: a macOS server streams live system metrics over WebSocket to an ESP32, which renders them as labeled progress bars on a 128x32 SSD1306 OLED sitting on your desk.
Out of the box it shows CPU%, RAM%, and Claude Code weekly usage%
(CLDE); a session-usage provider (SESS) is also available. Which metrics
are shown is a one-line edit in config.toml — no
code changes, no re-flashing. See docs/DEVELOPMENT.md
to add a metric of your own.
┌─────────────────────────────┐ WebSocket, ~1s ┌──────────────────────────────┐
│ macOS (server/) │ {"ts": …, "readings": [...]} │ ESP32 (client/, MicroPython) │
│ FastAPI + psutil + providers│ ──────────────────────────────>│ ws_client → display → widgets│
└─────────────────────────────┘ └───────────────┬──────────────┘
│
v
128x32 SSD1306 OLED
(labeled progress bars)
Each broadcast tick, the server's ProviderRegistry collects one Reading
per active provider and sends an ordered JSON list; the ESP32 draws each
reading as a bar (percent) or text row, one per display row.
Developed and tested on macOS (Apple Silicon). The disk provider is macOS-aware — it reads the APFS Data volume so the percentage matches reality.
| Requirement | Details |
|---|---|
| OS | macOS |
| Python | 3.12+ |
| Runtime packages | fastapi, uvicorn[standard], psutil — server/requirements.txt |
| Dev/test packages | pytest, pytest-asyncio — server/requirements-dev.txt (includes the runtime deps; only needed for the test suite) |
| Install | make setup creates .venv and installs both, plus ampy for flashing |
claude CLI |
Optional — only for the Claude usage (CLDE) bar. Must be installed and authenticated so claude -p /usage succeeds from a terminal. |
| Requirement | Details |
|---|---|
| Board | Any ESP32 flashed with MicroPython (tested on v1.28.0) |
| Display | 128x32 SSD1306 OLED, connected over I2C |
| Wiring | OLED VCC → 3V3, GND → GND, SDA → GPIO21, SCL → GPIO22 (pins configurable in config.toml) |
| USB / serial | A data USB cable. Boards with a CH340 USB-serial chip need ampy to flash on macOS (see step 3). |
| Option | Notes |
|---|---|
ampy (adafruit-ampy) |
Recommended — works with CH340 boards on macOS |
| Thonny | GUI alternative (see step 3) |
The Makefile wraps the whole workflow — run make help to list every command:
| Command | What it does |
|---|---|
make setup |
Create .venv and install the server, dev, and flashing dependencies |
make init |
Copy config.example.toml → config.toml (only if it doesn't exist yet) and fill in this Mac's detected LAN IP |
make ip |
Detect this Mac's LAN IP and write it into config.toml [server] ip |
make config |
Generate client/config.py from config.toml |
make run |
Start the server (0.0.0.0, port from config.toml) |
make flash |
Upload the client files to the ESP32 (auto-detects the serial port) |
make test |
Run the test suite |
First run, start to finish:
make setup
make init # creates config.toml, with this Mac's LAN IP already filled in
$EDITOR config.toml # fill in your WiFi SSID + password
make config # regenerates client/config.py for the board
make run # and, in another shell:
make flashThe sections below explain each step in detail.
make init # copies config.example.toml -> config.toml, and fills in the LAN IPmake init also detects this Mac's LAN IP and writes it into [server] ip for
you. If detection fails (this Mac isn't on the network yet), it says so and
leaves the value empty — set it yourself, or run make ip once you're
connected.
Then edit config.toml:
| Setting | Value |
|---|---|
[display] providers |
Which metrics to show, top to bottom. Ships as ["cpu", "ram", "claude"]. Available ids: cpu, ram, disk, claude, claude-session. The 128x32 screen fits 3 rows; extra ids are collected but not drawn. |
[wifi] ssid / password |
Your WiFi network. 2.4 GHz only — the ESP32 has no 5 GHz radio. |
[server] ip |
Your Mac's LAN IP, as seen from the ESP32. Normally auto-filled by make init; make ip refreshes it when the address changes. Only set it by hand if detection failed — find it with ipconfig getifaddr en0. |
[server] port |
8000 by default. make run binds to this too, so the server and the board can't drift apart. |
[i2c] sda / scl |
Default 21/22 — check your board's wiring. |
Changing [display] providers only needs a server restart. The other
sections live on the device, so they need make config + make flash +
a board reset.
Then generate the device config:
make config # writes client/config.py from config.tomlconfig.toml is gitignored — it holds your WiFi password and must never be
committed. client/config.py is a generated artifact (the ESP32 has no
TOML parser): also gitignored, and never edited by hand — change config.toml
and re-run make config instead.
make runThis binds 0.0.0.0 on the [server] port from config.toml and exposes a
WebSocket at /ws. To run it manually instead of through the Makefile, do it
from the repo root so the server package resolves:
source .venv/bin/activate # fish: source .venv/bin/activate.fish
python -m uvicorn server.app:app --host 0.0.0.0 --port 8000make flash # auto-detects the port; override with: make flash PORT=/dev/cu.usbserial-XXXXThen reset the board (press the EN/RST button, or power-cycle it).
CH340 boards: use
ampy, notmpremote. CH340's DTR/RTS auto-reset makes the board reboot every time the serial port opens, which landsmpremote's raw-REPL handshake mid-boot ("could not enter raw repl").ampy --delay Nwaits after opening the port so the board finishes booting first —--delay 2is confirmed working. If your board isn't CH340-based,mpremoteworks fine.
make flash does exactly this, per file, in dependency order:
# find the port
ls /dev/cu.usbserial-*
PORT=/dev/cu.usbserial-XXXX
for f in config.py wifi.py ws_client.py display.py ssd1306.py widgets.py main.py; do
ampy --port "$PORT" --delay 2 put "client/$f" "$f"
doneampy is installed into .venv by make setup; standalone, it's
pip install adafruit-ampy.
GUI alternative: if you'd rather not use the CLI, open each client/*.py
file in Thonny, select the ESP32 as the interpreter,
and "Save copy... > MicroPython device" for each file. client/config.py must
be generated by make config first.
The OLED shows three rows — with the shipped defaults, CPU, RAM, and
CLDE (Claude Code weekly usage) — each as a label + progress bar +
percentage. The CLDE bar appears a few seconds after boot: it refreshes in
the background rather than blocking startup.
Five providers ship built-in. Which ones are displayed — and in what order,
top to bottom — is the [display] providers list in config.toml:
[display]
providers = ["cpu", "ram", "claude"]| Id | Measures | Data source | Kind | In the shipped default? |
|---|---|---|---|---|---|
| cpu | CPU utilization % | psutil.cpu_percent | percent | YES |
| ram | RAM utilization % | psutil.virtual_memory().percent | percent | YES |
| disk | Disk usage % | psutil.disk_usage (macOS: APFS Data volume; else /) | percent | NO — add "disk" to the list to show it |
| claude | Claude Code weekly usage % | claude -p /usage subprocess | percent | YES |
| claude-session | Claude Code current-session usage % | claude -p /usage subprocess | percent | NO — add "claude-session" to the list to show it |
To swap the view, edit the list and restart the server. Nothing else changes:
the ESP32 renders whatever it is sent, so no make config, no re-flash.
An unknown id fails fast at startup with the list of valid ones, rather than
silently dropping a metric. Only the first 3 readings fit on the display.
server/ FastAPI app: WebSocket broadcast + the metric providers
client/ MicroPython firmware for the ESP32 (WiFi, WS, OLED)
tools/ config helpers backing `make config` and `make ip`/`make init`
tests/ CPython tests for the client widgets
config.example.toml tracked config template; `make init` copies it to config.toml
Makefile setup / init / ip / config / run / flash / test
Each broadcast tick the server collects one reading per active provider and
sends an ordered JSON list; the ESP32 draws each reading as a bar (percent) or
a text row. See docs/DEVELOPMENT.md for the
file-by-file layout.
This project is built to be extended:
- Pluggable server providers — add a new metric by writing one provider
class and registering it in
server/providers/selection.py. No ESP32 re-flash needed if it reuses an existingkind. - Customizable OLED widgets — the client renders by
kindthrough a widget registry, so new display styles are one function + one entry.
See docs/DEVELOPMENT.md for the architecture,
step-by-step "add a provider" and "add a widget" guides, the built-in
provider reference, testing, and platform support.
| Symptom | Cause / fix |
|---|---|
| OLED shows "Server retry..." | Wrong [server] ip in config.toml (see "The Mac's IP changed" below), or the server isn't running / isn't reachable on the LAN. |
| Board joins WiFi but never connects | Check [server] port matches on both sides — it does automatically if you started the server with make run. |
| WiFi connect feels slow | Tune POLL_INTERVAL_SECONDS / CONNECT_RETRIES in client/wifi.py. |
DISK% looks wrong on macOS |
Expected on APFS: / is a small read-only system snapshot. The provider already queries /System/Volumes/Data instead — if you still see a mismatch, check that volume exists on your machine. |
CLDE bar never appears |
Requires the claude CLI installed and authenticated (claude -p /usage must succeed from a terminal). |
SESS bar never appears |
Same as CLDE — both read from the same claude -p /usage subprocess. If CLDE works, SESS should too. |
make config refuses to run |
It won't generate a device config from an unfilled template. It names what's wrong: replace the placeholder WiFi values in config.toml, and fill any empty key it lists (make ip fills [server] ip). |
A make command says the venv is missing |
Run make setup — it creates .venv and installs everything. |
make flash finds no port |
Plug the board in, or pass it explicitly: make flash PORT=/dev/cu.usbserial-XXXX. |
The ESP32 is the one that opens the WebSocket, and it reads the server's IP from its own filesystem — so the address is baked into the board at flash time. When your Mac's LAN IP changes, the board keeps dialing the old one:
make ip # detect this Mac's LAN IP, write it to config.toml
make config flash # regenerate client/config.py and push it to the boardThen reset the board. make ip reads the interface backing the default route,
so it works on WiFi or Ethernet; override the detection with
make ip LAN_IP=192.168.1.50.
There is no way around the USB re-flash, because the board has to be told the
new address before it can reach anything. The durable fix is to stop the IP
from changing: give your Mac a DHCP reservation (a static lease) on your
router — bind its MAC address to a fixed IP — and the value you flash stays
valid. make ip is then only for first setup.
MIT. client/ssd1306.py is a vendored third-party SSD1306 driver, also
MIT-licensed — see the file header for its original attribution.