Chapter 03 · Software

Tour the app

About 800 lines of commented Python turn the board into an appliance: join Wi-Fi, call OpenRouter over HTTPS, render spend on the TFT, repeat every minute. This chapter walks the screens, the architecture, and the loop — then shows you where to cut when you remix it.

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.

warp Today $0.35 Week $1.20 Month $4.50 Used $0.35 $20.00 @ 18:51 1/3
D0D1D2

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.

Account Today $0.35 Week $2.60 Month $9.80 Used $12.39 $20.00 @ 18:51 2 keys
D0D1D2

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.

prod Loading 2/3
D0D1D2

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.

Setup needed Edit config.py
D0D1D2

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.

D0 · GPIO0

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.

D1 · GPIO1

Next key

Pages forward through OPENROUTER_KEYS, wrapping at the end. Switching refreshes that key's numbers immediately.

D2 · GPIO2

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.

CLOUD OpenRouter API GET /api/v1/key · GET /api/v1/credits 1 · raw usage JSON, fetched over HTTPS openrouter.py + vendored urequests.py BOARD I/O net.py + config.py Wi-Fi · NTP · secrets BOARD I/O buttons.py debounced D0 / D1 / D2 ORCHESTRATOR main.py — the loop poll buttons · refresh on a deadline · degrade 2 · raw payload dicts, handed to the core PURE · UNIT-TESTED ON YOUR LAPTOP usage_view.py · keyring.py payloads → view-model dict · key cycling no machine, no network imports — just Python 3 · view-model dict, ready to draw RENDERER dash.py layout · colors · budget bar · pager 4 · draw calls — rows, colors, the bar VENDORED DRIVER st7789py.py + font pixels over SPI @ 40 MHz GLASS 240×135 TFT the thing on your desk
pure logic — board + laptop hardware wrappers — board only the outside world
Why it matters

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

FileJobRuns on
main.pyBoot entry: power the display, join Wi-Fi, run the poll/render loopboard
usage_view.pyAPI payloads → view-model; all parsing & formatting decisionsboard + laptop tests
keyring.pyConfig → a wrapping cycle of API keys for D1/D2board + laptop tests
dash.pyDraws the view-model: rows, Wi-Fi dot, budget bar, pagerboard
openrouter.pyThin HTTPS client for /key and /creditsboard
net.pyWi-Fi connect with timeout + best-effort NTP syncboard
buttons.pyDebounced button reads; hides the D0-vs-D1/D2 wiring differenceboard
config.pyYour Wi-Fi + keys (gitignored; copied from config.example.py)board
urequests.py · st7789py.py · vga2_bold_16x16.pyVendored HTTP client, display driver, bitmap font (MIT)board
tests/73 host-side pytest cases: the pure modules, plus the renderer against a fake TFTlaptop 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:

Time

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

Feel

Acknowledge before you block

The network fetch takes ~a second and blocks. Painting Loading first makes every press feel registered.

Resilience

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:

TargetWhat it does
make deployCopy src/*.py + config.py to the board and reset
make replOpen the live REPL (see full error detail the screen won't show)
make mountBoard uses your src/ live — edit, import main, repeat
make runRun main.py once without installing it
make testHost-side pytest for the pure logic
make wipeRemove main.py so the board stops auto-running
If it sulks

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.

weather frame CI build monitor crypto ticker home-automation panel train departures board GitHub stars counter

Go deeper

Bookmark these

Back · Chapter 02← Python on bare metal