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)
}
| Field | Type | Default | Description |
|---|---|---|---|
driver | string | — (required) | Controller driver: "st7789" (RGB565), "ssd1306" (mono), "ssd1322" (4-bit grayscale), or "epd4in2bc" (tri-color e-paper) |
w / h | number | driver's native size | Framebuffer width/height in pixels |
cs | number | 0 | SPI chip-select line index (CH347 bridge) |
dc | number | — (required) | Data/command GPIO pin |
rst | number | 0 | Reset GPIO pin |
chunk | number | 4096 | Maximum SPI data chunk in bytes |
x_offset | number | 0 | Column offset into controller RAM |
y_offset | number | 0 | Row offset into controller RAM |
invert | boolean | true | Whether to enable display inversion during init |
connection_id | number | 0 | Target 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().
| Method | Description |
|---|---|
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, h | Decode a local image file and blit it; returns the blitted size. |
flush() → boolean | Encode 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:
| Field | Type | Default | Description |
|---|---|---|---|
w | number | — | Bounding-box width. The image is scaled to fit, preserving aspect ratio. |
h | number | — | Bounding-box height. The image is scaled to fit, preserving aspect ratio. |
dither | boolean | false | Apply 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)
}
| Field | Type | Default | Description |
|---|---|---|---|
dc | number | — (required) | Data/command GPIO pin |
rst | number | 0 | Reset GPIO pin |
cs | number | 0 | SPI chip-select line index |
chunk | number | 4096 | Maximum SPI data chunk in bytes |
connection_id | number | 0 | Target 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.
| Method | Description |
|---|---|
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() → boolean | Send 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