Driving SPI Displays with Vanilla MicroPython
If you've been following the other posts, you'll know that I've been building an information display for my home using a Raspberry Pi Pico and MicroPython. The previous post on drawing images with PicoGraphics covered how to render raw framebuffer images efficiently using Pimoroni's custom MicroPython fork.

Since then, I've moved away from PicoGraphics entirely and replaced it with a custom framebuffer, a drawing class, and display drivers that work with vanilla MicroPython. I wanted to learn more about driving SPI displays directly, and to have a codebase that runs on any board MicroPython supports without needing a specific firmware build.
The Architecture
The design splits responsibility into three layers:
- Drawing - owns the framebuffer and exposes a drawing API (lines, rectangles, ellipses, text, and so on)
- ST7789 / ILI9488 - hardware drivers responsible for initialising the display and transferring pixel data over SPI
- ST7789Display / ILI9488Display - thin wiring classes that connect a
Drawinginstance to a driver and register it as thedisplayin the service provider
The Drawing class allocates a bytearray and wraps it in a framebuf.FrameBuffer. When you call drawing.update(), it calls driver.render(framebuffer, width, height, region) on whatever driver is attached, passing a raw view of the pixel data. The driver then handles all the SPI protocol details.
class Drawing:
def __init__(self, width, height, color_mode='RGB565'):
if color_mode == 'RGB565':
self.mode = framebuf.RGB565
self.bytes_per_pixel = 2
else:
self.mode = framebuf.GS8
self.bytes_per_pixel = 1
self._framebuffer = bytearray(width * height * self.bytes_per_pixel)
self.fb = framebuf.FrameBuffer(self._framebuffer, width, height, self.mode)
def update(self, region=None):
if region is None:
region = (0, 0, self.width, self.height)
self._driver.render(self._framebuffer, self.width, self.height, region)
The Drawing class accepts 24-bit hex colours (e.g. 0xFF8000) and has a pack method that converts them down to whatever the framebuffer format requires. This keeps call sites simple: you always pass a colour like 0xFFFFFF regardless of whether the framebuffer is RGB565 or RGB332.
Colour Modes and Framebuffer Size
Choosing a colour mode is a trade-off between visual quality and RAM. The two modes the Drawing class supports are:
| Resolution | RGB565 (16-bit) | RGB332 (8-bit) |
|---|---|---|
| 240x240 | 115,200 bytes | 57,600 bytes |
| 320x240 | 153,600 bytes | 76,800 bytes |
| 480x320 | 307,200 bytes | 153,600 bytes |
Regardless of the framebuffer mode chosen, the driver converts pixels to the display's native format on the fly. The ST7789 and ILI9488 support different sets of native colour modes:
| Controller | Supported Native Modes | Mode Used by Driver |
|---|---|---|
| ST7789 | RGB444 (12-bit), RGB565 (16-bit), RGB666 (18-bit) | RGB565 |
| ILI9488 | RGB565 (16-bit, parallel only), RGB666 (18-bit), RGB888 (24-bit) | RGB666 |
The ILI9488's RGB565 mode only functions on its parallel interface, meaning RGB666 must be sent to the display over SPI.
The RP2040 has 264 KB of RAM, and the RP2350 has 520 KB. When you factor in the MicroPython runtime, libraries, and any other allocations, a 300 KB framebuffer for a 480x320 display in RGB565 mode leaves very little headroom on an RP2040. RGB332 halves the framebuffer cost at the expense of colour depth: it stores 3 bits of red, 3 bits of green, and 2 bits of blue, giving 256 possible colours rather than 65,536.
MicroPython does not have a native RGB332 framebuffer mode. The Drawing class uses framebuf.GS8 (8-bit greyscale) as a stand-in, since it has the same memory layout. The pack method encodes a 24-bit colour into the RGB332 byte format manually, and the driver handles the reverse conversion when sending data to the display.
The Display Drivers
The two displays I use have different controllers and different native colour formats.
The ST7789 (used on the Pico Display Pack 2.8", a 320x240 panel) is initialised with 0x55 for the COLMOD register, which selects 16-bit RGB565 mode. The framebuffer stores pixels in little-endian byte order, but the ST7789 expects them big-endian over SPI. The driver swaps the bytes of each pixel using a @micropython.viper function as it streams each row to the display, avoiding any additional memory allocation. This optimized approach to flipping the framebuffer row bytes is based on the ST7789 driver by Peter Hinch.
The ILI9488 (used on a 3.5" Touchscreen IPS LCD Display, a 480x320 panel) is a different story. It is initialised with 0x66 for COLMOD, which selects 18-bit RGB666 mode. The ILI9488 does not support 16-bit RGB565 over SPI at all; it always receives 3 bytes per pixel over SPI. The framebuffer is still RGB565 or RGB332 (to save RAM), and the driver expands each pixel to 3 bytes on the fly as it sends each row.
@micropython.viper
def _rgb565_to_888_line(dest: ptr8, source: ptr16, src_offset: int, pixels: int):
s: int = src_offset
d: int = 0
while pixels:
c = source[s]
r5 = (c >> 11) & 0x1F
g6 = (c >> 5) & 0x3F
b5 = c & 0x1F
dest[d] = (r5 << 3) | (r5 >> 2)
dest[d + 1] = (g6 << 2) | (g6 >> 4)
dest[d + 2] = (b5 << 3) | (b5 >> 2)
s += 1
d += 3
pixels -= 1
The conversion runs inside a viper-compiled function so it stays fast despite expanding every pixel from 2 bytes to 3. Without viper, iterating over 480 pixels in pure Python per row would be far too slow.
Partial Updates
Both drivers support rendering a sub-region of the framebuffer. The update call accepts an optional region tuple of (x, y, width, height), which lets you limit the SPI transfer to only the area that has changed. This is particularly useful for UI layouts where most of the screen is static. The drivers use the display's CASET (column address) and RASET (row address) commands to set the window before writing pixel data, so the panel only illuminates pixels within the specified region.
Setting Up a Display
The display setup is handled in a short wiring class. Here is an abbreviated version of ST7789Display.create:
spi = SPI(0, baudrate=40_000_000, polarity=0, phase=0, sck=Pin(18), mosi=Pin(19))
dc = Pin(16, Pin.OUT, value=0)
cs = Pin(17, Pin.OUT, value=1)
st = ST7789(spi, cs=cs, dc=dc, height=240, width=320, source_color_mode='RGB565')
drawing = Drawing(320, 240, color_mode='RGB565')
drawing.set_driver(st)
From that point, any code that holds a reference to drawing can call drawing.rect(...), drawing.text(...), and so on, then call drawing.update() to push the framebuffer to the display. The driver is entirely hidden from the scripts in infodisplay.
Running with CPython
To test the drawing code without touching the Pico, I built a PygameDisplay driver that runs under CPython.
The simulator mimics the physical display, accepting the same bytearray framebuffers and implementing the same render method as the hardware drivers. It uses Pygame and NumPy to map the custom RGB565 and RGB332 formats into a 32-bit RGBA surface. The driver handles partial updates identically to a real SPI controller.
To make this work seamlessly, I also implemented a framebuf compatibility shim that duplicates the standard MicroPython API under CPython using NumPy. Since the simulated display exposes the precise interface required by the Drawing class, the scripts in infodisplay remain completely unaware of the substitution.
It facilitates rapid iteration on desktop computers, but similar headless approaches can be deployed onto full fat Linux environments. For instance, I wrote a direct Linux FbDisplay driver that bridges the same core drawing logic directly into /dev/fb0 on a Raspberry Pi, yielding a high-performance headless display driver with Python alone and no Pygame dependency.
Viewing the Framebuffer in a Browser
The Pico also runs a small management HTTP server, and I added a FramebufferController that serves the live framebuffer as a BMP image. Hitting /framebuffer on the device returns a snapshot of whatever is currently on screen, directly from the raw pixel data in memory.
The controller constructs a valid BMP file on the fly, writing the file header, DIB header, and then streaming the framebuffer rows over HTTP one at a time. It handles both RGB565 and RGB332 colour modes: for RGB565 it emits BI_BITFIELDS masks so the browser knows how to decode the 5-6-5 channel layout, and for RGB332 it builds a 256-entry palette mapping each byte value back to full 8-bit colour. The row data is already sitting in the framebuffer, so the only real work is constructing the headers and padding each row to a 4-byte boundary as BMP requires.
This turned out to be useful for debugging display issues remotely, since I can just open a browser tab pointed at the Pico's IP and see exactly what the screen looks like without being in the same room.
🏷️ display framebuffer driver byte rgb565 spi colour rgb332 micropython pixel mode class row drawing drivers
Please click here to load comments.