MicroPython JD9853 driver
A pure-Python, four-wire SPI driver for the Waveshare ESP32-C6-Touch-LCD-1.47
172×320 LCD. JD9853 subclasses framebuf.FrameBuffer and uses standard RGB565
colors. No custom firmware or native extension is required.
Status: host tests pass using real framebuf in the micropython/unix
Docker image. Text/RGB output and all four orientations have also been visually
confirmed with Viper acceleration at 40 MHz on the Waveshare board running
MicroPython 1.29.0. See
hardware validation↗ (opens in a new tab) for results and remaining checks.
Install and draw
Install both the driver and its optional Viper helper from GitHub:
import mip
mip.install("github:mattytrentini/micropython-jd9853")
Or, from the host with an existing mpremote installation:
mpremote connect auto mip install github:mattytrentini/micropython-jd9853
mip installs jd9853.py and jd9853_viper.py into the first /lib path on
the board. To install a specific release after tags are published, append its
tag: github:mattytrentini/[email protected].
This example uses the board's LCD pin assignments. The touch controller is a separate device and is not handled by this driver.
from machine import Pin, SPI
from jd9853 import JD9853, rgb565
Pin(4, Pin.OUT, value=1) # Keep the SD card deselected on the shared bus.
backlight = Pin(23, Pin.OUT, value=0)
spi = SPI(1, baudrate=40_000_000, polarity=0, phase=0,
bits=8, firstbit=SPI.MSB, sck=Pin(1), mosi=Pin(2), miso=Pin(3))
display = JD9853(spi,
cs=Pin(14, Pin.OUT, value=1),
dc=Pin(15, Pin.OUT, value=0),
reset=Pin(22, Pin.OUT, value=1),
rotation=0)
display.fill(0)
display.text("MicroPython", 10, 10, 0xFFFF)
display.rect(10, 30, 80, 40, rgb565(255, 0, 0), True)
display.show()
backlight(1)
SPI must use mode 0, eight-bit words, and MSB first. The example uses 40 MHz;
20 MHz is also supported. The
driver uses the supplied SPI configuration without reinitializing it; callers
sharing the bus must serialize access, select the appropriate SPI settings,
and deselect other devices. LCD reads/MISO are not needed, but explicitly set
miso=Pin(3) (the board's SD-card input). The ESP32-C6 build tested here defaults
MISO to GPIO2 when omitted, conflicting with the LCD's MOSI on GPIO2.
Interface
JD9853(spi, cs, dc, reset, *, rotation=0) accepts an initialized SPI object
and output Pin objects. Construction resets and initializes the panel, clears
its visible memory to black, and enables display output. Backlight control is
separate. Initialization includes a 10 ms reset pulse and 120 ms waits after
reset release and sleep exit.
| Rotation | width × height | Controller offset (x, y) |
|---|---|---|
| 0 | 172 × 320 | 34, 0 |
| 90 | 320 × 172 | 0, 34 |
| 180 | 172 × 320 | 34, 0 |
| 270 | 320 × 172 | 0, 34 |
Rotation follows the vendor's native orientation and MADCTL mapping. Select it at construction; to change orientation, discard the old instance, collect garbage, and create another. The logical origin is the top-left corner of the selected orientation. Hardware testing must confirm the physical orientation.
| Method or attribute | Behavior |
|---|---|
Inherited fill, pixel, line, rect, text, blit, etc. | Draw into the framebuffer using MicroPython's normal semantics. |
show() | Send the entire framebuffer synchronously. Returns None. |
show_rect(x, y, width, height) | Send just one in-bounds framebuffer rectangle. Redraw all changed pixels in that rectangle first; useful for small animations. |
invert(value) | Enable/disable panel inversion. Enabled by default for normal colors on this panel. |
poweroff(), poweron() | Disable/enable display output without changing the Python framebuffer or backlight. These do not enter/exit sleep. |
sleep(value) | Enter/exit controller sleep; wait 120 ms before returning. After waking, call show(); if previously powered off, also call poweron(). |
width, height | Logical dimensions, to be treated as read-only. |
buffer | Native RGB565 backing bytearray. Edit its contents if needed; do not resize or replace it. |
rgb565(r, g, b) | Module function converting components in the range 0–255 to an RGB565 integer. |
Colors are conventional RGB565 integers: red 0xF800, green 0x07E0, blue
0x001F, white 0xFFFF, and black 0x0000. Blit directly from other
framebuf.RGB565 framebuffers. When loading raw assets into buffer, their
16-bit pixels must use the host's native byte order (little-endian on ESP32-C6).
The driver handles high-byte-first SPI transmission separately.
Memory use is 110,080 bytes for pixels, plus a reusable eight-row buffer
(2,752 bytes in portrait or 5,120 in landscape), and Python/native code overhead.
show() does not allocate another full framebuffer or modify the original
pixels. Avoid concurrent drawing or bus access during a transfer. SPI errors
propagate after releasing CS; retrying show() reprograms the full window.
The optional jd9853_viper.py module accelerates conversion using Viper.
If it is absent or the build rejects Viper compilation, the driver falls back
to Python conversion. The public RGB565 interface is identical in both cases.
Eight-row transfers reduce SPI call overhead; the final landscape transfer
contains only the remaining four rows, using a preallocated memoryview.
Benchmarks on this board reduced portrait refresh time from about 715 ms (original Python loop, 20 MHz) to 68 ms (Viper and eight-row transfers, 20 MHz), or 46 ms at 40 MHz—roughly 22 full-frame updates per second. There is no promised minimum refresh rate. Transfers remain blocking, and updates are not synchronized to the panel's refresh. Partial updates, runtime rotation, other panel configurations, hardware scrolling, and non-SPI interfaces are outside this version.
The datasheet's four-wire SPI write timing specifies a minimum 16 ns clock period (62.5 MHz), with minimum high/low durations of 7 ns (section 10.3.3, page 160). The example's 40 MHz is below that limit. The vendor demo's 80 MHz setting exceeds the published timing limit.
Backlight brightness
GPIO23 is application-controlled and independent of LCD display/sleep commands. For PWM brightness, use this in place of the plain backlight Pin above:
from machine import PWM
backlight = PWM(Pin(23), freq=5000, duty_u16=0)
# After display initialization and show():
backlight.duty_u16(32768) # Approximately 50% duty.
# Turn off before disposal:
backlight.duty_u16(0)
backlight.deinit()
Pin(23, Pin.OUT, value=0)
Testing
Run the host tests in the Unix container; building the Unix port is unnecessary:
docker run --rm -v "$PWD:/work:ro" -w /work micropython/unix tests/run.py
The image already uses MicroPython as its entrypoint. The tests print the runtime version and exercise real framebuffer construction, drawing, scrolling, RGB565 blitting, and readback. Recording SPI/Pin doubles verify initialization, delays, rotation windows, full transfer length, color byte order, repeated updates, power/sleep controls, invalid rotation, and recovery from SPI errors. These checks cannot establish electrical or visual correctness.
The suite also checks all 65,536 RGB565 values, conversion bounds, transfer boundaries, and error recovery on the short final landscape transfer. To test without the optional Viper module, mount only the core driver and tests:
docker run --rm -v "$PWD/jd9853.py:/work/jd9853.py:ro" \
-v "$PWD/tests:/work/tests:ro" -w /work micropython/unix tests/run.py
With the board connected and both modules installed, run the manual test without
installing it as main.py:
mpremote connect auto run examples/board_test.py
It cycles through all four orientations, draws RGB bars, corner labels, a
one-pixel border, text and shapes, and exercises output, sleep, inversion and
brightness controls. It prints firmware information, free heap, and mean
show() duration over ten transfers. At completion (or interruption), it
turns the backlight off and releases peripherals.
Record these results during hardware validation:
- Red/green/blue appear in order, with a black background and white markings.
- All corner labels and the complete border are visible in every orientation; there is no clipping, wrapping, mirroring, or displacement.
- Repeated refreshes are stable; display off/on and sleep/wake restore the image.
- Disabling inversion changes the colors; re-enabling it restores them.
- PWM brightness works; cold power-up followed by the example also works.
- Record the MicroPython build, SPI frequency, free heap, and refresh duration.
Sources and license
- JD9853 datasheet↗ (opens in a new tab): serial protocol, RGB565 format, address windows, MADCTL and timing.
- Waveshare board documentation↗ (opens in a new tab): panel dimensions, GPIO assignments and shared SPI wiring.
- Waveshare demo archive↗ (opens in a new tab):
vendor initialization from
ESP-IDF/01_factory/components/esp_lcd_jd9853/esp_lcd_jd9853.c, rotation mapping frommain/main.c, and RGB/inversion defaults frombsp_display.c. - MicroPython framebuf documentation↗ (opens in a new tab) and SSD1306 driver↗ (opens in a new tab): framebuffer interface and subclassing style.
The driver implementation is licensed under MIT; see LICENSE↗ (opens in a new tab). The panel register data adapted from the Waveshare/Espressif demo remains subject to Apache-2.0; its attribution and license information are in NOTICE↗ (opens in a new tab). The vendor register data retains its original order, including repeated register-page writes. Standard commands are applied separately: one sleep exit, TE output disabled, explicit RGB565 and rotation, inversion enabled, a full black frame transferred, then display enabled. Panel-specific power/gamma settings have not been generalized to other JD9853 LCDs.