Skip to main content

LCD / Display Helpers

Drive an SPI-attached LCD panel from Lua. The display is wired to a CH347 USB bridge (or a native spidev node) over SPI, with the data/command (dc) and reset (rst) lines on GPIO. Drawing calls accumulate in Rust; calling :flush() encodes the whole frame into a single lcd_op message that the worker replays atomically with correct DC toggling and timing. This replaces the hand-rolled DC-flipping, timelines and chunking that raw SPI/GPIO scripts used to need.

There are two layers:

  • display_new{...} — a high-level drawing surface. You draw shapes, text and images into an in-RAM framebuffer, then flush. The driver runs the panel's init sequence and encodes the frame for you. Use this for almost everything.
  • lcd_new{...} — a low-level program builder. You issue raw controller commands, data and reset/delay segments yourself, then flush. Use this only to bring up a panel the built-in drivers don't cover.

Both are write-only: :flush() returns true once the frame is queued; the panel never sends data back, so there is no on_receive() handling.

High-Level Drawing: display_new{...}​

display_new{...} returns a display surface bound to a driver and panel wiring. Configuration is a single table:

local d = display_new{
driver = "st7789", -- required: controller driver, "st7789", "ssd1306", "ssd1322", or "epd4in2bc"
w = 240, h = 280, -- framebuffer size in pixels (default: driver's native size)
cs = 0, -- SPI chip-select line index (default: 0)
dc = 6, -- data/command GPIO pin (required)
rst = 7, -- reset GPIO pin (default: 0)
chunk = 4096, -- max SPI data chunk in bytes (default: 4096)
x_offset = 0, -- column offset into controller RAM (default: 0)
y_offset = 20, -- row offset into controller RAM (default: 0)
invert = true, -- send INVON during init (default: true)
connection_id = 0, -- target connection (default: 0)
}
FieldTypeDefaultDescription
driverstring— (required)Controller driver: "st7789" (RGB565), "ssd1306" (mono), "ssd1322" (4-bit grayscale), or "epd4in2bc" (tri-color e-paper)
w / hnumberdriver's native sizeFramebuffer width/height in pixels
csnumber0SPI chip-select line index (CH347 bridge)
dcnumber— (required)Data/command GPIO pin
rstnumber0Reset GPIO pin
chunknumber4096Maximum SPI data chunk in bytes
x_offsetnumber0Column offset into controller RAM
y_offsetnumber0Row offset into controller RAM
invertbooleantrueWhether to enable display inversion during init
connection_idnumber0Target connection (0-based configuration index)

The native size is 240×280 for st7789, 128×64 for ssd1306, 256×64 for ssd1322, and 400×300 for epd4in2bc. Many panels need a small x_offset/y_offset because the controller RAM is larger than the visible glass. A typical 256×64 ssd1322 module needs x_offset = 112 (controller column 0x1C).

The epd4in2bc is a tri-color (black/white/red) e-paper panel. A full refresh is slow (budget ~18 s) and the BUSY line is left unconnected — the worker replays the program with fixed delays in place of the controller's "wait while BUSY" loops. Unlike the LCD/OLED drivers, the panel resets, powers on, refreshes and deep-sleeps on every flush, so each flush is a complete, self-contained refresh.

Colors​

A color argument accepts any of:

  • a 24-bit integer 0xRRGGBB (e.g. 0x00FFFF),
  • an {r, g, b} table with 0–255 components,
  • a name: "black", "white", "red", "green", "blue", "yellow", "cyan", "magenta", "gray"/"grey", "orange".

nil defaults to white. On a mono panel (ssd1306) any non-black color lights the pixel. On a grayscale panel (ssd1322) the color's Rec.601 luma is quantized to one of 16 gray levels. On a tri-color e-paper panel (epd4in2bc) each color is classified to the nearest ink: a strongly red color maps to red, otherwise the Rec.601 luma decides white vs. black.

Drawing Methods​

All coordinates are in pixels. fill (default false) draws a filled shape; width (default 1) is the outline thickness for unfilled shapes. Draw calls mutate the in-RAM framebuffer synchronously — nothing is sent until :flush().

MethodDescription
clear(color)Fill the whole framebuffer with color.
pixel(x, y, color)Set a single pixel.
line(x0, y0, x1, y1, color, width?)Straight line between two points.
polyline({{x,y}, ...}, color, width?)Connected line strip through the listed points.
rect(x, y, w, h, color, fill?, width?)Rectangle with top-left at (x, y) and size w × h.
square(x, y, size, color, fill?, width?)Rectangle with equal sides.
rounded_rect(x, y, w, h, radius, color, fill?, width?)Rectangle with rounded corners of the given radius.
circle(cx, cy, r, color, fill?, width?)Circle centered on (cx, cy) with radius r.
ellipse(cx, cy, w, h, color, fill?, width?)Ellipse centered on (cx, cy) with bounding size w × h.
arc(cx, cy, diameter, start_deg, sweep_deg, color, width?)Arc of a circle, from start_deg sweeping sweep_deg degrees.
sector(cx, cy, diameter, start_deg, sweep_deg, color, fill?, width?)Pie/sector wedge of a circle.
triangle(x0, y0, x1, y1, x2, y2, color, fill?, width?)Triangle through three vertices.
text(x, y, str, color, font?)Draw text with its top-left at (x, y).
image(x, y, w, bytes)Blit raw pixels in the surface's native format (height derived from w).
load_image(x, y, path, opts?) → w, hDecode a local image file and blit it; returns the blitted size.
flush() → booleanEncode the current frame and send it as one lcd_op message.

Circles, ellipses, arcs and sectors are centered on (cx, cy). Angles are in degrees.

Fonts​

text()'s optional font is a built-in monospaced ASCII font name (default "6x10"):

4x6, 5x7, 5x8, 6x9, 6x10, 6x12, 6x13, 6x13bold, 6x13italic, 7x13, 7x13bold, 7x13italic, 7x14, 7x14bold, 8x13, 8x13bold, 8x13italic, 9x15, 9x15bold, 9x18, 9x18bold, 10x20.

image(x, y, w, bytes)​

Blit a raw pixel buffer at (x, y). bytes is in the surface's native format — big-endian RGB565 (2 bytes/pixel) for st7789, 4-bpp MSB-first for ssd1322, 1-bpp MSB-first for ssd1306, or 24-bit RGB888 (3 bytes/pixel, classified to the nearest ink) for epd4in2bc. The height is derived from w and the buffer length.

load_image(x, y, path, opts?) → w, h​

Decode a local image file and blit it at (x, y). Supported formats are PNG, JPEG, BMP and GIF. The file is decoded and color-converted in Rust, so you do not need to pre-pack pixels (unlike image()). Returns the width and height of the region actually drawn, which is handy for laying out around the picture.

opts is an optional table:

FieldTypeDefaultDescription
wnumber—Bounding-box width. The image is scaled to fit, preserving aspect ratio.
hnumber—Bounding-box height. The image is scaled to fit, preserving aspect ratio.
ditherbooleanfalseApply Floyd–Steinberg dithering before quantizing to the panel's palette.

If both w and h are given the image is scaled to fit inside that box without distortion; if only one is given the other is unconstrained; if neither is given the image is drawn at its native size. Anything past the panel edge clips silently.

Each decoded pixel is converted to the surface's format the same way color arguments are (RGB565 truncation, Rec.601 luma for mono/grayscale, nearest-ink classification for tri-color). For photos on the low-color panels (ssd1306, ssd1322, epd4in2bc) set dither = true — plain quantizing produces hard banding, while error diffusion approximates the missing tones and looks far better.

-- Fit a logo into a 120×80 box on an ST7789, top-left at (10, 10):
local w, h = d:load_image(10, 10, "/sd/logo.png", { w = 120, h = 80 })

-- A dithered photo filling a mono OLED:
d:load_image(0, 0, "/sd/cat.jpg", { w = 128, h = 64, dither = true })

flush() → boolean​

Encode the current framebuffer and send it as one lcd_op message. On the first flush the driver also runs the controller init sequence (reset pulse, MADCTL/COLMOD, etc.) ahead of the frame. (The epd4in2bc e-paper driver instead emits its full reset → power-on → refresh → deep-sleep cycle on every flush.) Returns true once the frame is queued. The framebuffer keeps its contents after a flush, so redraw only what changed between frames.

Example: clock face on an ST7789​

local d   -- created lazily so flush() runs on the engine thread

function on_start()
d = display_new{ driver = "st7789", w = 240, h = 280, y_offset = 20,
cs = 0, dc = 6, rst = 7 }
end

function on_timer(now_ms)
local secs = (now_ms // 1000) % 60

d:clear("black")
d:rounded_rect(10, 10, 220, 260, 12, "white", false, 2)
d:circle(120, 140, 90, 0x202020, true)
d:text(70, 130, string.format("%02d s", secs), "cyan", "10x20")
d:flush()
end

Example: progress bar on an SSD1306 OLED​

local d
local pct = 0

function on_start()
d = display_new{ driver = "ssd1306", dc = 6, rst = 7, cs = 0 }
end

function on_timer(now_ms)
pct = (pct + 5) % 105

d:clear("black")
d:text(0, 0, "Loading", "white", "6x10")
d:rect(0, 20, 128, 16, "white", false, 1)
d:rect(2, 22, (124 * pct) // 100, 12, "white", true)
d:flush()
end

Low-Level Program Builder: lcd_new{...}​

lcd_new{...} returns a builder you fill with raw controller segments. Use it only when no built-in driver fits — to bring up an unsupported controller, run a custom init, or send bespoke commands.

local p = lcd_new{
dc = 6, -- data/command GPIO pin (required)
rst = 7, -- reset GPIO pin (default: 0)
cs = 0, -- SPI chip-select line index (default: 0)
chunk = 4096, -- max SPI data chunk in bytes (default: 4096)
connection_id = 0, -- target connection (default: 0)
}
FieldTypeDefaultDescription
dcnumber— (required)Data/command GPIO pin
rstnumber0Reset GPIO pin
csnumber0SPI chip-select line index
chunknumber4096Maximum SPI data chunk in bytes
connection_idnumber0Target connection (0-based configuration index)

Builder Methods​

A value passed where bytes are expected may be a number (one byte), a string (its bytes), or a sequence table of byte values.

MethodDescription
cmd(byte [, data])A command byte (DC low), optionally followed by its parameter bytes (DC high). data is a number, string, or table.
data(payload)Pixel/parameter bytes (DC high). Large blobs are chunked by the worker; pass a whole framebuffer in one call.
reset(high1_ms, low_ms, high2_ms)Hardware reset pulse on RST (defaults: 10, 10, 120 ms).
delay(ms)Pause before the next segment (e.g. SLPOUT settle time).
flush() → booleanSend the accumulated program as one lcd_op message and reset the builder for the next frame.

Example: ST7789 init + fill by hand​

function on_start()
local p = lcd_new{ dc = 6, rst = 7, cs = 0 }

p:reset(10, 10, 120) -- RST high/low/high pulse
p:cmd(0x01) -- SWRESET
p:delay(150)
p:cmd(0x11) -- SLPOUT
p:delay(120)
p:cmd(0x3A, 0x05) -- COLMOD = 16bpp RGB565
p:cmd(0x36, 0x00) -- MADCTL
p:cmd(0x29) -- DISPON

-- Address window 0..239 x 0..239, then a red fill
p:cmd(0x2A, { 0x00, 0x00, 0x00, 0xEF }) -- CASET
p:cmd(0x2B, { 0x00, 0x00, 0x00, 0xEF }) -- RASET
p:cmd(0x2C) -- RAMWR
local red = string.char(0xF8, 0x00):rep(240 * 240)
p:data(red)

p:flush()
end