Using LVGL with PyDevices
August 27, 2026 · View on GitHub
How to run LVGL on the PyDevices display, input, and timing
packages, and what python/display_driver.py
does for you.
The three sister projects
The PyDevices LVGL family bundles this integration for each interpreter:
| Repository | Interpreter |
|---|---|
| lvgl-micropython | MicroPython firmware |
| lvgl-circuitpython | CircuitPython firmware |
| lvgl-python | CPython (pydevices-lvgl wheels on TestPyPI) |
All three consume the bindings generated here, and all three share displaydev,
neutral board capabilities, and multimer — so the same LVGL Python code is
portable across them. You can even develop it interactively in
Jupyter.
The rest of this page covers wiring LVGL up by hand, which is what you need with upstream lv_micropython rather than one of the sister projects.
1. Install the minimum PyDevices packages
import mip
INDEX = "https://PyDevices.github.io/mip"
mip.install("pydevices", index=INDEX) # displaydev, appdev, and the rest of lib/
mip.install("github:PyDevices/pydevices/board_configs/<your_board>")
Replace <your_board> with a path from
board configs,
or follow
install workflows.
2. Build or obtain LVGL firmware
Follow upstream lv_micropython for your
board. PyDevices supplies the flush and input glue via board_config.py; LVGL
supplies the UI toolkit.
For production ESP32 projects, consider
kdschlosser's lvgl_micropython
C drivers wired through BusDisplay.
3. Wire board_config to LVGL
Your board_config.py should expose:
display_drv— adisplaydevdriver withblit_rect, dimensions, and rotation- neutral input callables where present:
host_read,touch_read,keypad_read,encoder_read,encoder_button_read
Board configs describe hardware; they do not create an application-level appdev.App.
Connect LVGL's display flush callback to copy LVGL's draw buffer through
display_drv.blit_rect — or use the packaged display_driver, which does it
for you.
display_driver
python/display_driver.py is the LVGL coordinator.
It ships with all three sister projects (frozen into the MicroPython and
CircuitPython firmwares, bundled with pydevices-lvgl). It requires a PyDevices
board_config, events, keys, and multimer, and is independent of the
optional appdev package.
With display_driver, LVGL input is wired automatically through its own
app (an appdev.App) and virtual touch / encoder / keypad devices.
Do not instantiate or poll
appdevin an LVGL app.lv.task_handler(), driven bydisplay_driver.event_loopplusmultimer, already drains input. Window-close (QUIT) is handled by the bridge'sHostInputpath.
Build the UI, then hand control over with app.run().
Wheel and swipe input
A mouse wheel or two-finger trackpad swipe drives the virtual encoder
device, so it adjusts whichever control the LVGL group has focused —
provided that control entered edit mode, which only happens once a click
lands on it (an LVGL core behavior: changing focus always drops edit mode,
so a handler that sets it back on must run on the FOCUSED event, not
PRESSED, which fires too early).
By default the encoder reads a single axis and never touches focus, matching
prior versions. Call display_driver.set_wheel_mapping() to change that:
import display_driver
# Horizontal sliders read best adjusted along their own axis, with the
# perpendicular swipe stepping between them:
display_driver.set_wheel_mapping(adjust_axis="h", adjust_sign=-1, navigate=True)
adjust_axis:"v"(default) or"h"— which axis adjusts the focused control's value. Match it to the control's orientation: horizontal sliders read best with"h"; vertical sliders and knobs,"v".adjust_sign:1(default) or-1to flip the adjust direction.navigate:False(default) orTrueto let the other axis move group focus between controls (lv.group_t.focus_next/focus_prevon the default group) — wheel-only browse-and-tweak, with no keyboard or extra indev required.navigate_sign:1(default) or-1to flip which way focus travels. Separate fromadjust_signbecause the two axes come from different sources with their own conventions — SDL and Win32 disagree about the sign of vertical scroll, so "swipe down goes to the next control" is a claim about that axis on that platform, not about the mapping as a whole.
A wheel event's legacy integer delta and its float "precise" delta
disagree about which one is real depending on the platform's usdl2
build — confirmed empirically to differ between a desktop MicroPython
build and a CPython one on the same machine. display_driver resolves
this per event already; if you ever read wheel deltas directly instead of
through display_driver, don't assume either field is the trustworthy one
without checking on your own target.
Sync versus async timers
display_driver includes the LVGL event_loop, which requires multimer.
Inspect app.timer_async — derived from board_config.timer_async, or
from the display driver's requires_async_timer — to see which backend was
selected:
app.timer_async | Applies to |
|---|---|
False (the desktop default) | MCUs, MicroPython Unix, CPython Linux — uses multimer.auto.Timer |
True | PyScript, Jupyter, or desktop with PYDEVICES_TIMER_ASYNC=1 — uses multimer.AsyncTimer |
display_driver passes it straight through as
event_loop(asynchronous=app.timer_async). When it is true, display_driver
drives both ticks and display.show() from its asynchronous LVGL refresh loop.
The desktop board_config reads PYDEVICES_TIMER_ASYNC for the PG/SDL
branch (the default comes from AutoDisplay and is normally False); PyScript
and Jupyter always use timer_async=True. To force async on desktop, set it
before board_config loads:
import os
os.environ["PYDEVICES_TIMER_ASYNC"] = "1"
import display_driver
Or export PYDEVICES_TIMER_ASYNC=1 in the shell that launches the process.
Verifying an integration
lv_test_timer.py
is a single smoke test that follows app.timer_async via
app.run(). Its UI reports the autodetected interpreter, OS, display
driver class, timer backend, mode (sync / async), and LVGL version, plus a
seconds counter, spinning arc, and tap button. It deliberately does not read
or write environment variables — set PYDEVICES_TIMER_ASYNC in the parent shell
if you want a specific desktop mode.
python examples/lv_test_timer.py kit runs a timed LVGL timer and input check,
prints a KIT_RESULT= JSON line, then quits. To drive that across every desktop
interpreter in both modes, use the harnesses documented in
pydevices-examples/tools/README.md.