Breadboard
docs
Example project · Builds & projects

microSD photo frame (TFT slideshow + video)

Photos and videos live on the microSD card and stream straight to the ST7735 — both slaves share the ESP32-C6’s hardware SPI bus (`machine.SPI(1)` on the stock pins), each behind its own chip-select. The card ships pre-loaded with three raw `.565` photos (RGB565, zero decode work); the script is also the canonical ~40-line SPI-mode SD driver — CMD0/CMD8/ACMD41 init, CMD18 multi-block reads — mounted with `vfs.VfsFat`. Select the card and open the Inspector’s Card contents panel to put your own images on it — or a video clip / animated GIF via “Add video…”, which converts in your browser to a raw `.565` stream the script plays paced at its own frame rate, exactly like real ST7735 video projects do.

The microSD photo frame (TFT slideshow + video) circuit as rendered by the simulator

How it works

Two SPI slaves share one bus: the microSD and the ST7735 both hang off the C6's hardware SPI (SCK=6, MOSI=7, MISO=2 — `machine.SPI(1)`'s fixed pins), and the chip-select lines decide who is listening. The firmware has no `sdcard` module, so the script IS the driver: CMD0 puts the card in SPI mode, CMD8/ACMD41 negotiate SDHC, and reads use CMD18 multi-block streaming with a CMD12 stop — the same ~40 lines every real SPI-mode SD project starts from. `vfs.VfsFat` mounts the card's FAT16 filesystem read-only, and each photo is a raw `.565` file (a 4-byte width/height header, then RGB565 big-endian pixels), so showing one is pure plumbing: open, read 4 KB at a time, write into an ST7735 RAMWR window. No decoding happens anywhere. The photos got onto the card from the circuit document itself — the card's Inspector panel converts uploaded images to `.565` in the browser and stores refs, and the engine writes real FAT16 files at boot.

What's on the bench

  • Battery
  • ESP32-C6
  • microSD card
  • TFT 1.8″ 160×128 (ST7735)

How it's wired

  • Battery · posESP32-C6 · vin
  • Battery · negESP32-C6 · gnd
  • ESP32-C6 · 3v3TFT 1.8″ 160×128 (ST7735) · vcc
  • ESP32-C6 · gnd2TFT 1.8″ 160×128 (ST7735) · gnd
  • ESP32-C6 · 3v3microSD card · vcc
  • ESP32-C6 · gnd2microSD card · gnd
  • ESP32-C6 · g6TFT 1.8″ 160×128 (ST7735) · clk
  • ESP32-C6 · g6microSD card · sck
  • ESP32-C6 · g7TFT 1.8″ 160×128 (ST7735) · din
  • ESP32-C6 · g7microSD card · mosi
  • ESP32-C6 · g2microSD card · miso
  • ESP32-C6 · g5TFT 1.8″ 160×128 (ST7735) · cs
  • ESP32-C6 · g20microSD card · cs
  • ESP32-C6 · g3TFT 1.8″ 160×128 (ST7735) · dc
  • ESP32-C6 · g4TFT 1.8″ 160×128 (ST7735) · rst

The code

This MicroPython script runs on the emulated board every boot; edit it in the Code tab.

# microSD photo frame - the photos live on the card, streamed
# straight to the TFT over the shared SPI bus. Select the card and
# open the Inspector's Card contents panel to swap in your own
# images (they convert to raw .565 on upload).
# There is no sdcard module in the firmware - this IS the driver,
# the same ~40 lines every SPI-mode SD project starts from.
from machine import Pin, SPI
import os, vfs, time

spi = SPI(1, baudrate=20000000)  # C6 hardware SPI: sck=6, mosi=7, miso=2
scs = Pin(20, Pin.OUT, value=1)  # SD chip-select
tcs = Pin(5, Pin.OUT, value=1)   # TFT chip-select
dc = Pin(3, Pin.OUT)
rst = Pin(4, Pin.OUT)

class SD:
    def __init__(self):
        spi.write(b'\xff' * 10)            # >=74 idle clocks, CS high
        assert self._cmd(0, 0, 0x95) == 1   # CMD0: go idle
        self._cmd(8, 0x1AA, 0x87, 4)        # CMD8: voltage check + echo
        while True:                         # CMD55+ACMD41 until ready
            self._cmd(55, 0, 1)
            if self._cmd(41, 0x40000000, 1) == 0:
                break
        self._cmd(58, 0, 1, 4)              # CMD58: OCR (SDHC addressing)
    def _cmd(self, c, a, crc, extra=0):
        scs(0)
        spi.write(bytes([0x40 | c, a >> 24 & 0xFF, a >> 16 & 0xFF, a >> 8 & 0xFF, a & 0xFF, crc]))
        r = 0xFF
        for _ in range(20):
            r = spi.read(1, 0xFF)[0]
            if not r & 0x80:
                break
        if extra:
            spi.read(extra, 0xFF)
        scs(1); spi.write(b'\xff')
        return r
    def readblocks(self, n, buf):
        mv = memoryview(buf)
        scs(0)                              # CMD18: multi-block read
        spi.write(bytes([0x52, n >> 24 & 0xFF, n >> 16 & 0xFF, n >> 8 & 0xFF, n & 0xFF, 1]))
        while spi.read(1, 0xFF)[0] != 0:    # R1
            pass
        for i in range(len(buf) // 512):
            while spi.read(1, 0xFF)[0] != 0xFE:  # data token
                pass
            spi.readinto(mv[512 * i:512 * (i + 1)], 0xFF)
            spi.read(2, 0xFF)               # CRC
        spi.write(bytes([0x4C, 0, 0, 0, 0, 1]))  # CMD12: stop
        spi.read(8, 0xFF)
        scs(1); spi.write(b'\xff')
    def ioctl(self, op, arg):
        if op == 4: return 131072           # sector count (64 MiB)
        if op == 5: return 512              # bytes per sector

vfs.mount(vfs.VfsFat(SD()), '/sd', readonly=True)

def wr(c, d=None):
    tcs(0); dc(0); spi.write(bytes([c]))
    if d is not None:
        dc(1); spi.write(bytes(d))
    tcs(1)

rst(0); time.sleep_ms(20); rst(1); time.sleep_ms(50)
wr(0x01); time.sleep_ms(120)   # SWRESET
wr(0x11); time.sleep_ms(120)   # SLPOUT
wr(0x3A, [0x05])               # COLMOD 16-bit RGB565
wr(0x29)                       # DISPON

buf = bytearray(4096)
mv = memoryview(buf)

def blit(f, w, h):
    wr(0x2A, [0, 0, 0, w - 1])  # CASET
    wr(0x2B, [0, 0, 0, h - 1])  # RASET
    tcs(0); dc(0); spi.write(bytes([0x2C])); dc(1)  # RAMWR, then stream
    left = w * h * 2
    while left:
        n = f.readinto(mv[:min(left, len(buf))])
        if not n:
            break
        spi.write(mv[:n])
        left -= n
    tcs(1)

# Photos are .565 with a 4-byte header (w, h); videos (the Inspector's
# "Add video..." output) carry an 8-byte header (w, h, fps, frames) and
# play PACED at their own frame rate. Size tells them apart.
media = sorted(n for n in os.listdir('/sd') if n.endswith('.565'))
print('card:', media)
while True:
    for name in media:
        size = os.stat('/sd/' + name)[6]
        f = open('/sd/' + name, 'rb')
        hdr = f.read(4)
        w = hdr[0] | hdr[1] << 8
        h = hdr[2] | hdr[3] << 8
        if size == 4 + w * h * 2:   # single photo: show for 3 s
            blit(f, w, h)
            f.close()
            print('PHOTO', name)
            time.sleep(3)
            continue
        hdr = f.read(4)             # video: fps + frame count
        fps = hdr[0] | hdr[1] << 8
        nf = hdr[2] | hdr[3] << 8
        print('VIDEO', name, w, 'x', h, '@', fps, 'fps,', nf, 'frames')
        budget = max(1, 1000 // max(1, fps))
        t0 = time.ticks_ms()
        nxt = time.ticks_add(t0, budget)
        for _ in range(nf):
            blit(f, w, h)
            d = time.ticks_diff(nxt, time.ticks_ms())
            if d > 0:
                time.sleep_ms(d)
            nxt = time.ticks_add(nxt, budget)
        f.close()
        ms = time.ticks_diff(time.ticks_ms(), t0)
        print('PLAYED', name, nf * 1000 // max(1, ms), 'fps achieved')

Try this

  • Select the microSD card and open Card contents in the Inspector — the three photos are listed. Add your own image; the board reboots with it on the card.
  • Watch the serial monitor: one `PHOTO <name>` line per displayed photo.
  • Change `time.sleep(3)` in the Code tab for a faster slideshow, or sort the list differently.
  • Open the ⌁ Analyzer while a photo loads to see the CMD18 block reads interleaved with the RAMWR bursts on the shared bus.

Related projects