Breadboard
docs
Example project · Displays

Big-screen station (ST7789 2.8″)

The bench’s flagship screen: a 240×320 IPS panel showing live weather, a UTC clock, and a three-ticker market board at once, all fetched from the sandbox internet over real DNS + TCP sockets. A full frame is 150 KB — bigger than the MicroPython heap likes — so it paints in eight 240×40 strips, each blitted into its own CASET/RASET window: the exact technique real firmware uses when the panel outgrows RAM.

The Big-screen station (ST7789 2.8″) circuit as rendered by the simulator

How it works

The bench's biggest screen, driven the honest way. A 240×320 ST7789 speaks the same ST77xx command set as the little 1.8″ TFT — CASET/RASET address windows, RAMWR pixel bursts, RGB565 big-endian — but a full frame is 150 KB, more than MicroPython wants to allocate in one piece. So the script owns a single 240×40 strip buffer (19 KB) and paints the screen as eight horizontal bands: header with a live UTC clock, weather card, a markets divider, one stock quote per band, footer. Each band is drawn into the strip framebuf, then blitted into its own CASET/RASET window — the exact technique real firmware uses when a panel outgrows RAM. The data is three genuine sandbox-internet fetches per refresh (api.weather.test, worldtimeapi.test, api.stocks.test) over real DNS + TCP sockets.

What's on the bench

  • Battery
  • ESP32-C3
  • TFT 2.8″ 240×320 (ST7789)

How it's wired

  • Battery · posESP32-C3 · vin
  • Battery · negESP32-C3 · gnd
  • ESP32-C3 · 3v3TFT 2.8″ 240×320 (ST7789) · vcc
  • ESP32-C3 · gnd2TFT 2.8″ 240×320 (ST7789) · gnd
  • ESP32-C3 · g4TFT 2.8″ 240×320 (ST7789) · din
  • ESP32-C3 · g6TFT 2.8″ 240×320 (ST7789) · clk
  • ESP32-C3 · g7TFT 2.8″ 240×320 (ST7789) · cs
  • ESP32-C3 · g3TFT 2.8″ 240×320 (ST7789) · dc
  • ESP32-C3 · g10TFT 2.8″ 240×320 (ST7789) · rst

The code

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

# Big-screen station: 240x320 ST7789 painted in 240x40 strips (a full
# frame is 150 KB - bigger than the heap, so we render band by band).
from machine import Pin, SoftSPI
import framebuf, time, network, socket, json
W, H, STRIP = 240, 320, 40
spi = SoftSPI(baudrate=8000000, sck=Pin(6), mosi=Pin(4), miso=Pin(5))
dc = Pin(3, Pin.OUT); cs = Pin(7, Pin.OUT, value=1); rst = Pin(10, Pin.OUT)
def wr(c, d=None):
    cs(0); dc(0); spi.write(bytes([c]))
    if d is not None:
        dc(1); spi.write(bytes(d))
    cs(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(0x36, [0x00])                       # MADCTL: portrait 240x320
wr(0x29)                               # DISPON
buf = bytearray(W * STRIP * 2)
fb = framebuf.FrameBuffer(buf, W, STRIP, framebuf.RGB565)
def rgb(r, g, b):
    # framebuf stores RGB565 little-endian, the ST77xx wire wants
    # big-endian - swap once here so colors land right.
    v = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)
    return ((v & 0xFF) << 8) | (v >> 8)
def blit(y0):
    # Push the strip into its own address window, then RAMWR the bytes.
    wr(0x2A, [0, 0, (W - 1) >> 8, (W - 1) & 0xFF])
    wr(0x2B, [y0 >> 8, y0 & 0xFF, (y0 + STRIP - 1) >> 8, (y0 + STRIP - 1) & 0xFF])
    cs(0); dc(0); spi.write(bytes([0x2C])); dc(1); spi.write(buf); cs(1)
w = network.WLAN(network.STA_IF); w.active(True)
w.connect('BreadboardNet')
while not w.isconnected():
    time.sleep_ms(100)
def fetch(host, path):
    addr = socket.getaddrinfo(host, 80)[0][-1]
    s = socket.socket(); s.connect(addr)
    s.send(('GET ' + path + ' HTTP/1.0\r\nHost: ' + host + '\r\n\r\n').encode())
    r = b''
    while True:
        c = s.recv(256)
        if not c: break
        r += c
    s.close()
    return json.loads(r.split(b'\r\n\r\n', 1)[1])
BG = rgb(12, 16, 24); BAND = rgb(22, 30, 44); EDGE = rgb(70, 110, 180)
CYAN = rgb(120, 200, 255); GREEN = rgb(110, 230, 140); RED = rgb(240, 110, 100)
YELLOW = rgb(235, 210, 90); WHITE = 0xFFFF
n = 0
while True:
    wx = fetch('api.weather.test', '/weather')
    tm = fetch('worldtimeapi.test', '/time')
    qs = fetch('api.stocks.test', '/stocks')['quotes']
    n += 1
    # strip 0: header band with the live UTC clock
    fb.fill(BAND)
    fb.text('BREADBOARD STATION', 48, 8, CYAN)
    fb.text(tm['utc_datetime'][11:19] + ' UTC', 76, 24, WHITE)
    fb.hline(0, 39, W, EDGE)
    blit(0)
    # strip 1: where + sky
    fb.fill(BG)
    fb.text(wx['location'][:26], 16, 8, WHITE)
    fb.text(wx['conditions'][:26], 16, 24, CYAN)
    blit(STRIP)
    # strip 2: the headline numbers
    fb.fill(BG)
    fb.text('TEMP  %s C' % wx['temperature_c'], 16, 8, GREEN)
    fb.text('HUMIDITY  %d %%' % wx['humidity'], 16, 24, YELLOW)
    blit(2 * STRIP)
    # strip 3: markets divider
    fb.fill(BAND)
    fb.text('MARKETS', 88, 16, CYAN)
    fb.hline(0, 0, W, EDGE); fb.hline(0, 39, W, EDGE)
    blit(3 * STRIP)
    # strips 4-6: one quote per band, red/green by direction
    for i in range(3):
        fb.fill(BG)
        if i < len(qs):
            q = qs[i]
            col = GREEN if q['change'] >= 0 else RED
            fb.text(q['symbol'], 16, 12, WHITE)
            fb.text('%s' % q['price'], 96, 12, col)
            fb.text('%+.2f' % q['change'], 168, 12, col)
        blit((4 + i) * STRIP)
    # strip 7: footer
    fb.fill(BG)
    fb.hline(0, 0, W, EDGE)
    fb.text('refresh #%d  every 2s' % n, 16, 16, rgb(150, 160, 175))
    blit(7 * STRIP)
    print('station drew frame', n, '-', wx['temperature_c'], 'C,', len(qs), 'quotes')
    time.sleep_ms(2000)

Try this

  • Watch the clock band: every 2-second refresh repaints all eight strips with fresh fetches.
  • Make a quote band taller: change STRIP to 64 and the loop to five bands — the CASET/RASET math is the whole lesson.
  • Open the 📡 Sniffer during a refresh: three HTTP round trips, then the long SPI burst as 8 strips stream out.
  • Point a band at /api or /echo instead of stocks and design your own dashboard row.

Related projects