MicroPython user C module glue for LVGL: micropython.mk, CMake usermod, GC-aware allocator, and smoke tests.
New here? Read the newcomer's guide for the generated- bindings boundary, runtime entrypoint, and MicroPython integration map.
This repo is a consumer/build repo for the LVGL stack: it consumes generated bindings from lvgl-bindings and rebuilds MicroPython targets, but does not publish its own package. See lvgl-bindings — The LVGL family for how the family fits together.
It builds against lvgl-bindings: the checkout you name with BINDINGS_DIR, else one beside this repository, else the commit in LVGL_BINDINGS_COMMIT, which the build fetches for you. See Build.
Synced from lvgl-bindings: lib/fs_driver.py is synced from lvgl-bindings at the commit pinned in LVGL_BINDINGS_COMMIT, along with the generated bindings. Do not edit it here — change it in lvgl-bindings and re-sync.
display_driver is not here. LVGL's PyDevices coordinator lives in pydevices lib/ and comes with pydevices (mip.install("pydevices", index="https://PyDevices.github.io/mip"), or frozen by micropython-pydevices' --modules pydevices), beside the appdev, events, keys and multimer it needs.
See docs/.
This repo is mostly glue: it wires LVGL into MicroPython builds and exposes the interpreter hooks that the firmware needs. In practice, you usually change the build glue or allocator here when the port itself changes, but you do not regenerate the bindings here. If the binding layer changed, update lvgl-bindings first and then rebuild this module against the new generated file.
What's planned next is in ROADMAP.md.
workspace/
lvgl-micropython/ ← this repo
lvgl-bindings/
micropython/ ← for builds
cd lvgl-bindings
git submodule update --init lvgl
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
./regenerate_all.sh --target micropythonThe build finds lvgl-bindings in one of three places, the first that exists:
BINDINGS_DIR, onmake's command line or in the environment (CMake ports read it from the environment or-DBINDINGS_DIR=). A firmware build that pins lvgl-bindings itself, such as micropython-pydevices, sets it.- An
lvgl-bindingscheckout beside this repository, with itslvglsubmodule. - Neither: the build fetches the commit in
LVGL_BINDINGS_COMMIT into
.deps/lvgl-bindings, with only what it compiles. You need nothing butgit.
Its generated binding is committed, so there is nothing to generate. A
checkout you named or put beside this one is built as it is: if its generated
binding, lvgl or lv_conf.h differ from the pinned commit, the build prints
a note saying which commit it is building and carries on. To build exactly
the pinned bindings from a checkout of your own:
git clone https://github.com/PyDevices/lvgl-micropython
git clone https://github.com/PyDevices/lvgl-bindings
git -C lvgl-bindings checkout "$(cat lvgl-micropython/LVGL_BINDINGS_COMMIT)"
git -C lvgl-bindings submodule update --init lvglOn MicroPython 1.29 or later, add one line to the manifest your build already uses:
include("/path/to/lvgl-micropython/manifest.py")On unix that manifest is ports/unix/variants/standard/manifest.py; on esp32
and rp2 it is usually ports/<port>/boards/manifest.py, unless your board
brings its own. Then build as usual. The include brings in the lvgl C module
and freezes fs_driver. Tested on the
unix port against MicroPython v1.29.0, where import lvgl reports 9.5.
If you would rather not edit the MicroPython tree, write a manifest of your own
and pass it as FROZEN_MANIFEST=. That replaces the port's default, so include
the default too (include("$(PORT_DIR)/variants/standard/manifest.py") on
unix, include("$(PORT_DIR)/boards/manifest.py") on esp32) or you lose
asyncio and the port's other frozen modules.
Older than 1.29? Manifests there have no c_module(), so this one will not
load; use USER_C_MODULES, and freeze the two helpers from lib/ yourself if
you want them. On esp32 and rp2 point it at this repository:
make BOARD=ESP32_GENERIC_S3 USER_C_MODULES=/abs/path/to/lvgl-micropythonOn Make ports such as unix point it at the directory that contains this repository, which builds every module in that directory:
cd micropython/ports/unix && make USER_C_MODULES=../../..lv.image draws PNG and LVGL's own BIN format on any build of this module. It
draws nothing at all for a JPEG unless the jpegio module is in the
same firmware.
The decoder moved. LV_USE_TJPGD is 0 in the bindings, so LVGL's built-in
JPEG decoder is not compiled in; jpegio registers its own TJpgDec with LVGL
through lv_image_decoder_create when the two are built together. The point
was one TJpgDec per firmware instead of two, without carrying a fork of LVGL,
but it means a build of this module alone silently skips JPEGs at run time
rather than failing at build time.
So name jpegio when you want JPEG:
micropython-pydevices/build_mp.py --port unix --variant pydevices --modules jpegio,lvgl-micropythonjpegio lives in micropython-pydevices' modules/jpegio (it was part of
displayif until 2026-10-06). Both of this module's build files print a note
when jpegio isn't in the build, so you are told at build time rather than
finding out from a blank image. From jpegio's side,
jpegio.register_lvgl_decoder() and jpegio.lvgl_decoders() exist on every
build and report what happened.
To build several user C modules together across ports, name them in one
manifest: this repository's manifest.py carries c_module("."), and a
manifest that include()s several such files builds them all.
micropython-pydevices'
build_mp.py does that for you: name the modules and it builds them on any
port, with upstream's own boards:
micropython-pydevices/build_mp.py --port unix --variant pydevices --modules displayif,jpegio,lvgl-micropythonjpegio comes along for JPEG: it owns the TJpgDec decoder LVGL draws JPEGs through.
With pydevices installed, pydevices' display_driver drives LVGL from machine.Timer (hardware interrupts):
- Interactive REPL (
micropython -ior on-board prompt): Simply create widgets and drop out to the prompt. Hardware timer interrupts keep LVGL animations, timers, and touch input running continuously in the background while you inspect variables or test code interactively. - Standalone Scripts: Use
app.run()if you need an explicit loop for non-interactive desktop scripts.
import display_driver # noqa: F401 - initializes display, input, and machine.Timer
import lvgl as lv
scr = lv.screen_active()
btn = lv.button(scr)
btn.center()
label = lv.label(btn)
label.set_text("Hello MicroPython LVGL!")
# Dropping out the bottom leaves the UI active in the background!./micropython/ports/unix/build-standard/micropython ./lvgl-bindings/tools/test_lvgl_smoke.pyThe smoke suite belongs to the exact pinned lvgl-bindings source; this repo does not forward or duplicate it.
| Path | Role |
|---|---|
micropython.mk |
Make ports (pre-1.29: USER_C_MODULES = parent directory) |
micropython.cmake |
CMake ports (pre-1.29: USER_C_MODULES = this repo) |
src/lv_mem_core_micropython.c |
GC-aware LVGL allocator |
manifest.py |
Names the C module and freezes lib/fs_driver.py (synced from lvgl-bindings) |
lib/fs_driver.py |
Python-backed LVGL filesystem driver (import fs_driver) |
LVGL_BINDINGS_COMMIT |
The lvgl-bindings commit a build fetches when you give it no checkout |
scripts/sync_from_lvgl_bindings.sh |
Refresh helpers and record an exact commit/tag |
scripts/fetch_bindings.sh |
Fetch the pinned lvgl-bindings into .deps/ (the build runs it) |
CircuitPython integration lives in lvgl-circuitpython.