The big picture
Where's the HDMI?
There isn't one — and that changes everything about how you draw. Three pipelines, same goal. Follow each column top to bottom, and watch where the cyan box (the place the finished picture actually lives) sits relative to the cable.
In pipelines A and B the picture lives inside the computer, so a cable must re-send all of it, 60 times a second, forever — a static desktop still costs ≈3 Gbit/s of HDMI traffic. In pipeline C the framebuffer sits on the far side of the wire, inside the display controller itself. Send nothing and the picture simply stays. The app only ever transmits changes, and the panel handles its own refresh.
That last number is the strangest one: the full picture never exists in the ESP32's memory. A whole frame would be 240 × 135 × 2 bytes = 64,800 — but the driver streams everything through scratch buffers of at most 512 bytes. The only complete copy of what you see is in the panel's own RAM. (The SPI bus here is even wired without a MISO line — the display is a one-way street we write to and never read back.)
The mechanism
A panel with a photographic memory
The glass has a chip bonded onto it — the Sitronix ST7789 — with enough RAM (GRAM) for 240×320 pixels and a tiny state machine. Drawing means one thing: writing bytes into that RAM through a three-command protocol. The controller repaints the liquid crystal from GRAM on its own, about 60 times a second, powered but unbothered by our code.
Every draw in the entire app is this one move: declare a rectangle, then pour
pixels into it. After RAMWR, the controller advances its own write
cursor left→right, top→bottom inside the window — the ESP32 sends raw color data with
no addresses attached.
The abridged heart of
st7789py.py:
def _set_window(self, x0, y0, x1, y1):
# our glass is a window into the bigger GRAM,
# hence the (40, 53) offset in landscape
self._write(CASET, pack(">HH", x0+40, x1+40))
self._write(RASET, pack(">HH", y0+53, y1+53))
self._write(RAMWR) # "pixels follow"
def fill_rect(self, x, y, w, h, color):
self._set_window(x, y, x+w-1, y+h-1)
pixel = pack(">H", color) # 2 bytes, RGB565
for chunk in chunks(pixel * (w*h), 512):
self.spi.write(chunk) # fire and forget
Everything is a rectangle
Every primitive the dash uses reduces to _set_window + a stream.
That's the whole graphics API:
Call in dash.py | Window it opens | Bytes streamed |
|---|---|---|
pixel(x, y, c) | 1 × 1 | 2 |
hline(x, y, w, c) | w × 1 | 2·w |
fill_rect(x, y, w, h, c) | w × h | 2·w·h — one pixel, repeated |
text(font, s, x, y, fg, bg) | 16 × 8, twice per glyph | 256 per strip |
fill(c) — clear the screen | 240 × 135 | 64,800 |
Even outlines and diagonals obey the rule: rect() is four
skinny filled rectangles, and line() is Bresenham stepping — a fresh 1×1 window
per pixel, which is why diagonal lines are slow and the dash's layout uses none.
The vocabulary
Colors are 16 bits, letters are 32 bytes
No color names, no font files. A color is one 16-bit number in RGB565 encoding — 5 bits red, 6 green (your eye resolves green best), 5 blue — two bytes on the wire per pixel:
def color565(r, g, b): # 0–255 each → one 16-bit number
return (r & 0xF8) << 8 | (g & 0xFC) << 3 | b >> 3
_DIM = color565(90, 90, 90) # dash.py mixes its own grays & orange
Text works the same way — there is no font engine, so the app ships a bitmap font:
vga2_bold_16x16.py, a PC-BIOS typeface where every character is a 16×16 grid of
bits packed into 32 bytes. To draw a glyph, the driver expands each bit into a foreground or
background pixel and blits it as two 16×8 strips. These are the actual bytes for
$ — the most-drawn glyph on this dashboard:
# font.FONT, 32 bytes at ord('$')·32
03 c0 ■ 0000001111000000
03 c0 ■ 0000001111000000
07 e0 ■ 0000011111100000
1e 78 ■ 0001111001111000
3c 3c ■ 0011110000111100
3c 00 ■ 0011110000000000
1e 00 ■ 0001111000000000
07 e0 ■ 0000011111100000
00 78 ■ 0000000001111000
00 3c ■ 0000000000111100
3c 3c ■ 0011110000111100
1e 78 ■ 0001111001111000
07 e0 ■ 0000011111100000
03 c0 ■ 0000001111000000
03 c0 ■ 0000001111000000
00 00 ■ 0000000000000000
1 becomes a foreground pixel, each
0 a background pixel — 256 color bytes per 16×8 strip, blitted through the
same set-window move as everything else.Because every glyph paints its own background, drawing new text over old text
cleanly replaces it — no clearing needed. But the driver silently drops any glyph that
would cross the right edge, so dash.py pre-clips long strings and marks
them with a trailing ~ (see _clip()).
Our layout engine
No CSS — a ruler and constants
A web page reflows; this screen is surveyed. The font is 16×16, so the
240×135 panel is a grid of 15 columns × 8 rows, and every coordinate in
dash.py
is a hand-picked constant. This is the per-key dash with its actual coordinates:
Reading the rulers: x=8 is the text gutter, x=104
(_VALUE_X, the cyan guide) starts the value column — labels are ≤5 glyphs, so
they always fit to its left and $9999.99 still fits to its right.
x=224 parks the 12 px Wi-Fi dot in the corner. Rows sit at
y=20/38/56 — 16 px of glyph plus 2 px of air — with hairline rules at
y=18 and y=74, the budget bar at y=96, and the footer
at y=112.
And the render pass is nothing more than those constants in order — abridged from
render():
def render(tft, view, updated, wifi_ok, ...):
tft.fill(st7789.BLACK) # wipe the canvas
tft.text(font, header, 8, 0, WHITE, BLACK) # key name
tft.fill_rect(224, 2, 12, 12, GREEN if wifi_ok else RED) # Wi-Fi dot
tft.hline(0, 18, 240, _DIM) # rule
_row(tft, "Today", view["today"], 20)
_row(tft, "Week", view["week"], 38)
_row(tft, "Month", view["month"], 56)
tft.hline(0, 74, 240, _DIM) # rule
... # Used row @ 76
_budget_bar(tft, view["used_frac"], view["budget"], 8, 96, 224, 10)
tft.text(font, "@ " + updated, 8, 112, _DIM, BLACK) # footer
The web habits, translated
| Building a web page | Drawing this dash |
|---|---|
| CSS + a layout engine decide positions | You decide: x=8, _VALUE_X=104, rows 18 px apart |
| Responsive reflow on any screen | One screen, ever: 15 glyph columns × 8 rows |
| Components / a DOM tree | Four render functions: render, render_account, render_loading, render_error |
| Browser repaints when state changes | main.py calls a render function when it has new data |
requestAnimationFrame, 60 fps | One repaint per refresh — every 60 seconds by default |
| Text wraps; overflow scrolls | _clip() truncates and appends ~ |
| Web fonts, kerning, shaping | One vendored bitmap font, 16×16 cells, ASCII |
| GPU compositing, vsync | The ST7789 refreshes itself from GRAM |
Each refresh clears the whole screen and redraws — the simplest correct strategy, and at
one repaint per minute the brief blank is invisible in practice. Since text paints its own
background, a fancier version could update numbers in place with zero flicker —
that's a great first exercise when you remix dash.py.
The trade
What you give up, what you get
The layout engine
Nothing flows, wraps, or centers itself. Every glyph is where a constant in
dash.py put it — and moving a row means re-doing arithmetic.
Typography
One font, one size, no kerning, ASCII only. Want bold 24 px? You vendor another bitmap font module and spend the flash space.
Animation for free
No vsync, no requestAnimationFrame. Motion means re-blitting rectangles
yourself, racing a 40 MHz wire from interpreted Python.
Determinism
Same bytes in, same pixels out — no user agents, no zoom levels. The renderer is unit-tested against a fake TFT because its output is exactly predictable.
Absurdly small footprint
The frame never exists in MCU RAM: 512-byte scratch buffers and the panel's own GRAM do all the remembering.
A stack you can read
Your code to lit glass is ~1,000 lines of vendored Python — no browser, no OS, no driver blobs. Every pixel is explainable.
Go deeper