The screens
Four states, one tiny display
These mockups aren't artist's impressions — they're drawn from the same
pixel coordinates dash.py uses on the real 240×135 panel.
1 · Per-key dash — the default view. Today / Week / Month
spend for the selected key, then Used against that key's credit limit with a bar
that fills green → orange → red. Top-right: the Wi-Fi dot. Bottom: last-update clock and
a 1/3 pager showing which key is selected.
2 · Account overview — press D0. Rows are summed
across all configured keys; Used is the account-wide credits you've burned
and the bar fills toward your purchased-credits cap, so $12.39 of $20.00 reads
at a glance. The balance remaining is the bar's unfilled portion. Press D0 again to go back.
3 · Switching — any button press paints this emblem instantly, before the (blocking) network fetch. Without it, a key switch would leave the previous key's numbers on screen for a whole round-trip and feel ignored.
4 · Fail fast, degrade gracefully — missing config stops
here instead of crashing. And once running, a failed refresh keeps the last good
numbers on screen with a short reason (Check API key,
API unreachable) in place of the clock.
The controls
All three are onboard buttons — the GPIO numbers are the board's internal wiring (see the reference pinout in chapter 01), not pins you connect to anything.
Toggle account ⇄ key view
The BOOT button moonlights as the account-overview switch. Wired active-low — one of
the two button wirings buttons.py hides for you.
Next key
Pages forward through OPENROUTER_KEYS, wrapping at the end. Switching
refreshes that key's numbers immediately.
Previous key
Pages backward, wrapping at the start. With a single key configured, D1/D2 do nothing and no pager is drawn.
Blueprint
Architecture: a pure core in a hardware shell
One design rule shapes everything: the app's decisions — parsing, formatting,
fallbacks — never touch hardware. Read the diagram top to bottom as a single refresh tick:
raw JSON comes down from the cloud, the green pure core reshapes it into a ready-to-draw
view-model, and the render pipeline pushes it to the glass. main.py drives every
numbered step; the gray boxes around the core are thin, board-only wrappers, while the pure
core is plain Python that runs — and is unit-tested — on your laptop.
The two green-outlined modules import nothing hardware-specific — and even the
renderer is exercised against a fake display — so make test runs
73 pytest cases on your laptop with no board plugged in. Most of the app's actual
decisions (parsing, formatting, fallbacks, key cycling) live in that pure core.
File-by-file
| File | Job | Runs on |
|---|---|---|
main.py | Boot entry: power the display, join Wi-Fi, run the poll/render loop | board |
usage_view.py | API payloads → view-model; all parsing & formatting decisions | board + laptop tests |
keyring.py | Config → a wrapping cycle of API keys for D1/D2 | board + laptop tests |
dash.py | Draws the view-model: rows, Wi-Fi dot, budget bar, pager | board |
openrouter.py | Thin HTTPS client for /key and /credits | board |
net.py | Wi-Fi connect with timeout + best-effort NTP sync | board |
buttons.py | Debounced button reads; hides the D0-vs-D1/D2 wiring difference | board |
config.py | Your Wi-Fi + keys (gitignored; copied from config.example.py) | board |
urequests.py · st7789py.py · vga2_bold_16x16.py | Vendored HTTP client, display driver, bitmap font (MIT) | board |
tests/ | 73 host-side pytest cases: the pure modules, plus the renderer against a fake TFT | laptop only |
Heartbeat
The main loop
No threads, no asyncio — one polite loop that wakes 20 times a second to check
buttons, and refreshes when a deadline passes. Abridged from
main.py:
while True:
if acct_btn.fell(): # D0: flip key ⇄ account view
show_account = not show_account
dash.render_loading(tft, ...) # acknowledge the press instantly
last_view = _poll_account(tft, ring, None)
elif time.ticks_diff(deadline, time.ticks_ms()) <= 0:
last_view = _poll(tft, entry, ring.position(), last_view)
deadline = time.ticks_add(time.ticks_ms(), refresh * 1000)
time.sleep_ms(50) # check buttons ~20×/s between refreshes
Three habits in there are worth stealing for any gadget:
ticks_ms, not time.time()
MicroPython's tick helpers (ticks_add/ticks_diff) handle
counter wrap-around safely — the idiomatic way to schedule “every N seconds”.
Acknowledge before you block
The network fetch takes ~a second and blocks. Painting Loading first makes every press feel registered.
Keep the last good data
A failed refresh never blanks the screen: old numbers stay, with a short reason. An appliance should degrade, not crash.
Hands on
Run it yourself
Configure
Copy the template (it's gitignored, so secrets stay local) and add your Wi-Fi and one or more OpenRouter keys:
$ cp src/config.example.py src/config.py
Test on the laptop first
No board needed — this exercises the pure core:
$ uv sync
$ make test
Deploy
Copies every module in src/ (plus config.py) to the board's
flash, then resets it. The board connects and the dash appears:
$ make deploy
The Makefile wraps the whole mpremote workflow from chapter 02 — these are the
targets you'll actually type:
| Target | What it does |
|---|---|
make deploy | Copy src/*.py + config.py to the board and reset |
make repl | Open the live REPL (see full error detail the screen won't show) |
make mount | Board uses your src/ live — edit, import main, repeat |
make run | Run main.py once without installing it |
make test | Host-side pytest for the pure logic |
make wipe | Remove main.py so the board stops auto-running |
Blank screen, No Wi-Fi, port busy, swapped colors — the
README's
troubleshooting table covers every known failure and its one-line fix.
Remix
Make it your own
The OpenRouter dashboard is just the payload. Underneath is a reusable skeleton for any “call an API, show it on a screen” gadget — three cuts and it's yours:
Swap the client
Replace openrouter.py with a thin client for your API: fetch, return a
dict. Keep it dumb.
Reshape the view-model
Rewrite usage_view.build_view() to map your payload into the fields you
want on screen — and update the tests beside it, still no hardware required.
Redraw the layout
Adjust dash.py's rows and bar to render your fields.
main.py, net.py, the Makefile and the config handling don't
change at all.
Go deeper