Sixteen bars and a bouncing box

Three days ago I wrote about the HDMI board I bought before reading the datasheet - the 50-pin RGB888 converter the Nucleo could never actually drive, and the plan B that came out of it: a $30 Adafruit Feather RP2040 DVI as a video co-processor. The H7 keeps being the console; the RP2040's whole job is to take frames over a four-wire SPI link and bit-bang them out an HDMI connector using PicoDVI.

The Feather RP2040 DVI arrived at 9:46 this morning, sharing the parcel with the W25Q128JV flash chips from the cartridge-slot experiment - a good post day for the letterbox. The morning went to the console's search feature, so the Feather got its turn just before noon; by 12:32pm it was drawing the console's sixteen PICO-8 colours as bars on a monitor, with a white box bouncing over them.

That clip is a pixel-exact re-render of what the smoke test puts in the framebuffer (the demo is deterministic, so the frames can be reproduced byte-for-byte off-device). The real monitor shows one thing the clip cannot, and it matters later in this post. The rest is about the 45 minutes in between, because none of it went the way the plan said, and all of it was cheaper to learn on day one than mid-integration.

The Adafruit Feather RP2040 with DVI output port

The wrong board's diagram

The wiring plan was already drawn: the H7 gives up four pins (SPI5 SCK on PF7, MOSI on PF9, a CS GPIO on PF8, and PE2 listening for vsync coming back), all on CN9, all clear of the panel, the audio bus, and Ethernet. The Feather side of the link is the silk pins SCK, MI, D9 and D10 - one hardware SPI1 slave group.

Before touching a jumper I cross-checked the wiring table against the Feather pinout diagrams saved in the repo - and they disagreed. The diagrams put SCK, MO and MI on GP18, GP19 and GP20. The table said GP14, GP15 and GP8.

Both were right. The saved diagrams were for the plain Feather RP2040 - product 4884. The board on the bench is the DVI variant - product 5710 - and Adafruit quietly remapped the SPI pins on it, because the DVI connector consumes GP16 through GP23 for the TMDS pairs, which is exactly where the plain board keeps its SPI block. Same silk labels, different silicon underneath. On the DVI variant, D9 is not just a GPIO that happens to be free; it is literally SPI1 CSn.

The Feather RP2040 DVI pinout diagram - SCK, MO and MI live on GP14, GP15 and GP8, and the HDMI connector's TMDS pairs occupy GP16 through GP23

The practical rules that came out of this: wire by the silk names and both boards agree; write firmware pin constants only from the variant's own diagram; and keep the correct diagrams in the repo so the wrong ones cannot ambush anyone again. The docs now carry the warning and the 5710 images.

The firmware that wasn't there

Day-one testing philosophy: neither side of the SPI link exists yet - the H7 PicoDviStream backend and the RP2040 receiver are designed on paper only - so wiring the link today would produce exactly nothing. The only test that can put pixels on a screen is a standalone one: flash the Feather with something that draws, and prove board + cable + monitor before writing a line of link firmware.

The smoke test is fifteen lines of scene setup and a loop: bring up the 320x240 DVI output, draw the sixteen PICO-8 palette colours as vertical bars, bounce a white box over them (the full listing is at the end of this post). Using the console's own palette was deliberate - the smoke test doubles as a colour check for the day real frames flow. Under CircuitPython's picodvi module sits PicoDVI, the bit-banged DVI project that overclocks the RP2040 to ~252 MHz and drives the TMDS pairs from its PIO blocks - the previous post covers why that is the whole reason this board exists.

Plugged in, the board enumerated as "Feather RP2040 DVI" with a serial port - and no CIRCUITPY drive. The factory firmware is a test app, not CircuitPython. So: hold BOOTSEL, replug, and an RPI-RP2 drive appears; drop the CircuitPython 10.2.1 UF2 on it (1.8 MB, 3517 blocks); the board reboots, formats its little filesystem, and comes back as CIRCUITPY. Copy the smoke test on as code.py.

The monitor showed a console. Not my bars - CircuitPython renders its serial console out the DVI port by default, which is a genuinely great feature (the display path was already proven at this point) and also not what I asked for. The board had run the factory-default code.py - "Hello World!", then "Code done running" - because my copy raced its first boot. One Ctrl-D down the serial port soft-reloads, and there they were: sixteen bars, one bouncing box.

M105M105M105M105

Then it went back to the console. On the screen, in the traceback: a KeyboardInterrupt at line 86 - the time.sleep in my bounce loop. That one was me; a diagnostic I sent down the REPL to check on it carried a Ctrl-C.

But the REPL was also filling up with this:

>>> M105M105M105M105M105M105
NameError: name 'M105M105' is not defined

M105 is G-code. It is Marlin's "report temperature". Something on the Mac had decided my display co-processor was a 3D printer and was politely asking it, over and over, how hot its hotend was.

$ lsof /dev/cu.usbmodem11201
COMMAND   PID   USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
UltiMaker 721 fulvio   36u   CHR   9,17    0t498 1245 /dev/cu.usbmodem11201

UltiMaker Cura, running quietly since the night before, probes every new usbmodem device on the system as a potential printer and holds the port while it polls. It also explains the "multiple access on port" error my own serial script had thrown earlier and that I had shrugged off. Quit Cura, one clean Ctrl-D, and the bars have been up ever since, with nothing typing over them.

New bench rule, filed permanently: when a serial device misbehaves, lsof the port before blaming the firmware. Cura will grab the console's own CDC port just as happily as the Feather's.

What first light actually proves

Not much, honestly - and exactly enough. The DVI output works, the cable works, the monitor negotiates the 640x480 mode PicoDVI generates from a pixel-doubled 320x240 framebuffer, and CircuitPython's picodvi module drives it all from a default board setup with no soldering and no configuration. Board, cable, monitor: three variables eliminated before the real work starts.

First light also handed over one bug report, free of charge: the bouncing box tears. The loop moves it whenever it likes, with no locking to the DVI scanout, so every so often the box splits along a scanline mid-flight. That is not a smoke-test bug to fix - it is the exact phenomenon the link design already budgets a wire for. The fifth wire in the plan runs from the Feather's D10 back to PE2 on the H7 so the console can pace its frame pushes to the scanout instead of writing into a framebuffer while it is being read. The demo demonstrated the problem three firmwares before the fix exists.

The real work is the link. Next on the RP2040: a small firmware that sits as an SPI slave (RX on GP8, clock on GP14, CS on GP9 - the DVI variant's numbers) and DMAs incoming frames into a paletted 8bpp framebuffer for PicoDVI to scan out. 8bpp is not a stylistic choice: PicoDVI's DVI generation already eats one core, both PIO blocks and most of the RP2040's 264 KB of SRAM, and a 16bpp 320x240 double-buffer alone would overrun the chip. One byte per pixel plus a 256-entry CLUT is what fits - and it happens to be exactly the format the console's framebuffer already uses. Next on the H7: a Present backend that treats the Feather the way it already treats the SPI panel - a double-buffered DMA blit, just pointed at different wires.

If you have this board, here is the whole smoke test - drop it on the CIRCUITPY drive as code.py and you get first light in one file copy (no libraries needed, built-ins only):

import time
import board
import displayio

display = getattr(board, "DISPLAY", None)
if display is None:
    import framebufferio
    import picodvi

    displayio.release_displays()
    fb = picodvi.Framebuffer(
        320, 240,
        clk_dp=board.CKP, clk_dn=board.CKN,
        red_dp=board.D0P, red_dn=board.D0N,
        green_dp=board.D1P, green_dn=board.D1N,
        blue_dp=board.D2P, blue_dn=board.D2N,
        color_depth=8,
    )
    display = framebufferio.FramebufferDisplay(fb)

import vectorio

PICO8 = [
    0x000000, 0x1D2B53, 0x7E2553, 0x008751,
    0xAB5236, 0x5F574F, 0xC2C3C7, 0xFFF1E8,
    0xFF004D, 0xFFA300, 0xFFEC27, 0x00E436,
    0x29ADFF, 0x83769C, 0xFF77A8, 0xFFCCAA,
]

palette = displayio.Palette(len(PICO8))
for i, c in enumerate(PICO8):
    palette[i] = c

W, H = display.width, display.height
group = displayio.Group()
bar_w = W // len(PICO8)
for i in range(len(PICO8)):
    group.append(
        vectorio.Rectangle(
            pixel_shader=palette, color_index=i,
            width=bar_w, height=H, x=i * bar_w, y=0,
        )
    )

SIZE = 24
box = vectorio.Rectangle(
    pixel_shader=palette, color_index=7, width=SIZE, height=SIZE, x=20, y=20
)
group.append(box)

try:
    display.root_group = group
except AttributeError:
    display.show(group)

x, y, dx, dy = 20, 20, 3, 2
while True:
    x += dx
    y += dy
    if x <= 0 or x >= W - SIZE:
        dx = -dx
        x += dx
    if y <= 0 or y >= H - SIZE:
        dy = -dy
        y += dy
    box.x = x
    box.y = y
    time.sleep(0.016)

45 minutes, two ambushes, sixteen bars. The console is one link firmware away from a TV.