dmellok/el133-pico-driver

Minimal standalone driver for the Pimoroni Inky Impression 13.3 (EL133UF1 / Spectra 6) on a Pico Plus 2 W: panel bring-up, test patterns, and on-device JPEG decode + dither.

C

23

5 commits

updated Jun 22, 2026

See the code

README

el133-pico-driver

Minimal standalone driver for the Pimoroni Inky Impression 13.3" (EL133UF1 / Spectra 6, 1600x1200, 6-colour) on a Pimoroni Pico Plus 2 W (RP2350B). It brings the panel up, draws a set of test patterns and an embedded sample image, and advances one screen per press of the front button. No WiFi, MQTT, deep sleep, or config — just the panel.

Extracted from the tesserae-device-pico-bin firmware as a clean reference / bring-up tool for this panel.

Hardware

Pico Plus 2 W on a Hard Stuff "Pico to Pi Hat" adapter, driving the Inky 13.3". The adapter is a 1:1 Pi-BCM-to-Pico-GP passthrough except it swaps clock and data (meter-verified; the vendor doc is wrong).

SignalPico GP
SCLKGP10 (SPI1 SCK)
MOSIGP11 (SPI1 TX)
DCGP22
RSTGP27
BUSYGP17 (active low)
CS_M (left half, cols 0..599)GP26
CS_S (right half, cols 600..1199)GP16
Button AGP15 (active low)

The panel is landscape-native 1600x1200, but its two controllers scan a 1200x1600 portrait frame split at column 600. The driver rotates a landscape frame 90 deg CW and splits it; patterns are drawn directly in portrait. Spectra 6 panels need a ~300 ms per-command D/C setup or they drop commands, and a full refresh takes ~35 s.

Build / flash / monitor

PlatformIO core: ~/.platformio/penv/bin/pio (adjust if yours is on PATH).

pio run                       # build -> .pio/build/pimoroni_pico_plus_2w/firmware.uf2
pio device monitor            # 115200 baud

Flashing (no pio run -t upload — the bundled picotool can't force this RP2350 into BOOTSEL): double-tap RESET to mount the RP2350 drive, then copy firmware.uf2 onto it. (A 1200-baud touch on the USB CDC port also triggers BOOTSEL.)

What it does

On boot it paints the vertical-stripe test pattern, then each button A press advances to the next screen:

  1. vertical stripes (on boot)
  2. an embedded JPEG decoded and dithered to the panel on the device
  3. the pre-packed embedded sample image (sample_card)
  4. horizontal stripes 5. checkerboard
  5. colour blocks (3x2 of the six colours) 7. solid white

The two image screens come right after boot so they're reachable in one or two presses — the on-device JPEG is the slow one (decode + ~35 s refresh) and was tedious to reach when it sat last. Each paint includes the ~35 s refresh; wait for the panel to settle before pressing A again. Serial logs each step.

Images

There are two image paths: a frame packed on the host, and a JPEG converted on the device itself.

Pre-packed .bin (host-converted)

src/sample_card.bin is a landscape 1600x1200 Spectra-6 packed-4bpp frame (960000 bytes), embedded into flash via src/sample_card.S (.incbin) and shown directly by el133_show_frame() (no decode, no PSRAM).

  • Regenerate the synthetic test card (no deps): python3 tools/gen_sample.py
  • Use your own image (needs Pillow): python3 tools/png2frame.py photo.png — it cover-fits to 1600x1200, quantises to the Spectra 6 palette, and overwrites src/sample_card.bin. Rebuild and reflash.

Frame format: packed 4bpp, 2 px/byte, high nibble = even column, 800 bytes/row, 1200 rows. Colour codes: black 0x0, white 0x1, yellow 0x2, red 0x3, blue 0x5, green 0x6.

On-device JPEG decode + dither

src/sample.jpg is a 1600x1200 baseline JPEG embedded the same way (src/sample_jpg.S). At paint time img_decode_jpeg() (src/img_decode.c) decodes it with the vendored picojpeg into a full RGB888 buffer in PSRAM, then Floyd-Steinberg dithers it to the six panel colours, packing the result into a second PSRAM buffer that el133_show_frame() paints. This mirrors what a networked client does with a server-delivered photo, no host conversion needed.

The dither boosts saturation ~1.4x and snaps to the nominal sRGB primaries (pure red/green/blue, not muted "as it renders" targets). Muted targets — a navy-ish blue especially — sit close to dusty-pink/mauve shadow tones, so shadows snap to blue and the dither smears them into a violet haze; pure primaries keep the colour decisions clean and let the panel do its own gamut projection.

  • PSRAM is required (the RGB buffer is 5.76 MB and the packed frame 0.96 MB, both in the Pico Plus 2 W's 8 MB APS6404). On a board without PSRAM the screen is skipped with a serial note.
  • The bundled sample is a royalty-free tulip photo (see NOTICES.md). Fetch and crop it reproducibly with python3 tools/fetch_sample_jpg.py, or point it at your own file: python3 tools/fetch_sample_jpg.py --src photo.jpg.
  • For a synthetic gradient instead (needs Pillow; numpy optional, just faster): python3 tools/make_sample_jpg.py.
  • To show your own photo directly, save it as a 1600x1200 baseline JPEG over src/sample.jpg and rebuild — the decoder does not resize, so it clips a larger image and white-pads a smaller one.

Layout

  • include/epd_io.h / src/epd_io.c — SPI1 + D/C/RST/BUSY transport, command + data-stream helpers.
  • include/el133.h / src/el133.c — the EL133UF1 driver: reset, the verified Pimoroni init sequence, the rotate/split paint, and refresh. el133_show_pattern() (per-row generator) and el133_show_frame() (landscape buffer).
  • include/patterns.h / src/patterns.c — the procedural test patterns.
  • include/images.h / src/sample_card.S / src/sample_card.bin — the embedded pre-packed image; src/sample_jpg.S / src/sample.jpg — the embedded JPEG.
  • include/img_decode.h / src/img_decode.c — on-device JPEG decode + Floyd-Steinberg dither to a Spectra-6 frame in PSRAM.
  • include/psram.h / src/psram.c — RP2350 QMI bring-up for the 8 MB PSRAM.
  • include/picojpeg.h / src/vendor/picojpeg.c — vendored picojpeg (public domain) baseline JPEG decoder.
  • src/main.c — boot, PSRAM init, button-A advance loop.
  • tools/gen_sample.py, tools/png2frame.py — pre-packed .bin tooling; tools/make_sample_jpg.py (synthetic gradient) and tools/fetch_sample_jpg.py (fetch/crop a real photo) — embedded-JPEG tooling.

License

AGPL-3.0-or-later (LICENSE). The vendored picojpeg decoder (src/vendor/picojpeg.c) is public domain, and the bundled sample photo (src/sample.jpg) is CC BY-SA 3.0; see NOTICES.md for both.

Contributors

dmellok

5 commits

dmellok/el133-pico-driver

Minimal standalone driver for the Pimoroni Inky Impression 13.3 (EL133UF1 / Spectra 6) on a Pico Plus 2 W: panel bring-up, test patterns, and on-device JPEG decode + dither.

C

23

5 commits

updated Jun 22, 2026

See the code

README

el133-pico-driver

Minimal standalone driver for the Pimoroni Inky Impression 13.3" (EL133UF1 / Spectra 6, 1600x1200, 6-colour) on a Pimoroni Pico Plus 2 W (RP2350B). It brings the panel up, draws a set of test patterns and an embedded sample image, and advances one screen per press of the front button. No WiFi, MQTT, deep sleep, or config — just the panel.

Extracted from the tesserae-device-pico-bin firmware as a clean reference / bring-up tool for this panel.

Hardware

Pico Plus 2 W on a Hard Stuff "Pico to Pi Hat" adapter, driving the Inky 13.3". The adapter is a 1:1 Pi-BCM-to-Pico-GP passthrough except it swaps clock and data (meter-verified; the vendor doc is wrong).

SignalPico GP
SCLKGP10 (SPI1 SCK)
MOSIGP11 (SPI1 TX)
DCGP22
RSTGP27
BUSYGP17 (active low)
CS_M (left half, cols 0..599)GP26
CS_S (right half, cols 600..1199)GP16
Button AGP15 (active low)

The panel is landscape-native 1600x1200, but its two controllers scan a 1200x1600 portrait frame split at column 600. The driver rotates a landscape frame 90 deg CW and splits it; patterns are drawn directly in portrait. Spectra 6 panels need a ~300 ms per-command D/C setup or they drop commands, and a full refresh takes ~35 s.

Build / flash / monitor

PlatformIO core: ~/.platformio/penv/bin/pio (adjust if yours is on PATH).

pio run                       # build -> .pio/build/pimoroni_pico_plus_2w/firmware.uf2
pio device monitor            # 115200 baud

Flashing (no pio run -t upload — the bundled picotool can't force this RP2350 into BOOTSEL): double-tap RESET to mount the RP2350 drive, then copy firmware.uf2 onto it. (A 1200-baud touch on the USB CDC port also triggers BOOTSEL.)

What it does

On boot it paints the vertical-stripe test pattern, then each button A press advances to the next screen:

  1. vertical stripes (on boot)
  2. an embedded JPEG decoded and dithered to the panel on the device
  3. the pre-packed embedded sample image (sample_card)
  4. horizontal stripes 5. checkerboard
  5. colour blocks (3x2 of the six colours) 7. solid white

The two image screens come right after boot so they're reachable in one or two presses — the on-device JPEG is the slow one (decode + ~35 s refresh) and was tedious to reach when it sat last. Each paint includes the ~35 s refresh; wait for the panel to settle before pressing A again. Serial logs each step.

Images

There are two image paths: a frame packed on the host, and a JPEG converted on the device itself.

Pre-packed .bin (host-converted)

src/sample_card.bin is a landscape 1600x1200 Spectra-6 packed-4bpp frame (960000 bytes), embedded into flash via src/sample_card.S (.incbin) and shown directly by el133_show_frame() (no decode, no PSRAM).

  • Regenerate the synthetic test card (no deps): python3 tools/gen_sample.py
  • Use your own image (needs Pillow): python3 tools/png2frame.py photo.png — it cover-fits to 1600x1200, quantises to the Spectra 6 palette, and overwrites src/sample_card.bin. Rebuild and reflash.

Frame format: packed 4bpp, 2 px/byte, high nibble = even column, 800 bytes/row, 1200 rows. Colour codes: black 0x0, white 0x1, yellow 0x2, red 0x3, blue 0x5, green 0x6.

On-device JPEG decode + dither

src/sample.jpg is a 1600x1200 baseline JPEG embedded the same way (src/sample_jpg.S). At paint time img_decode_jpeg() (src/img_decode.c) decodes it with the vendored picojpeg into a full RGB888 buffer in PSRAM, then Floyd-Steinberg dithers it to the six panel colours, packing the result into a second PSRAM buffer that el133_show_frame() paints. This mirrors what a networked client does with a server-delivered photo, no host conversion needed.

The dither boosts saturation ~1.4x and snaps to the nominal sRGB primaries (pure red/green/blue, not muted "as it renders" targets). Muted targets — a navy-ish blue especially — sit close to dusty-pink/mauve shadow tones, so shadows snap to blue and the dither smears them into a violet haze; pure primaries keep the colour decisions clean and let the panel do its own gamut projection.

  • PSRAM is required (the RGB buffer is 5.76 MB and the packed frame 0.96 MB, both in the Pico Plus 2 W's 8 MB APS6404). On a board without PSRAM the screen is skipped with a serial note.
  • The bundled sample is a royalty-free tulip photo (see NOTICES.md). Fetch and crop it reproducibly with python3 tools/fetch_sample_jpg.py, or point it at your own file: python3 tools/fetch_sample_jpg.py --src photo.jpg.
  • For a synthetic gradient instead (needs Pillow; numpy optional, just faster): python3 tools/make_sample_jpg.py.
  • To show your own photo directly, save it as a 1600x1200 baseline JPEG over src/sample.jpg and rebuild — the decoder does not resize, so it clips a larger image and white-pads a smaller one.

Layout

  • include/epd_io.h / src/epd_io.c — SPI1 + D/C/RST/BUSY transport, command + data-stream helpers.
  • include/el133.h / src/el133.c — the EL133UF1 driver: reset, the verified Pimoroni init sequence, the rotate/split paint, and refresh. el133_show_pattern() (per-row generator) and el133_show_frame() (landscape buffer).
  • include/patterns.h / src/patterns.c — the procedural test patterns.
  • include/images.h / src/sample_card.S / src/sample_card.bin — the embedded pre-packed image; src/sample_jpg.S / src/sample.jpg — the embedded JPEG.
  • include/img_decode.h / src/img_decode.c — on-device JPEG decode + Floyd-Steinberg dither to a Spectra-6 frame in PSRAM.
  • include/psram.h / src/psram.c — RP2350 QMI bring-up for the 8 MB PSRAM.
  • include/picojpeg.h / src/vendor/picojpeg.c — vendored picojpeg (public domain) baseline JPEG decoder.
  • src/main.c — boot, PSRAM init, button-A advance loop.
  • tools/gen_sample.py, tools/png2frame.py — pre-packed .bin tooling; tools/make_sample_jpg.py (synthetic gradient) and tools/fetch_sample_jpg.py (fetch/crop a real photo) — embedded-JPEG tooling.

License

AGPL-3.0-or-later (LICENSE). The vendored picojpeg decoder (src/vendor/picojpeg.c) is public domain, and the bundled sample photo (src/sample.jpg) is CC BY-SA 3.0; see NOTICES.md for both.

Contributors

dmellok

5 commits

Languages

C

82.3%

Python

15.3%

Assembly

2.4%