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:

RepositoryInterpreter
lvgl-micropythonMicroPython firmware
lvgl-circuitpythonCircuitPython firmware
lvgl-pythonCPython (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 — a displaydev driver with blit_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 appdev in an LVGL app. lv.task_handler(), driven by display_driver.event_loop plus multimer, already drains input. Window-close (QUIT) is handled by the bridge's HostInput path.

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 -1 to flip the adjust direction.
  • navigate: False (default) or True to let the other axis move group focus between controls (lv.group_t.focus_next/focus_prev on the default group) — wheel-only browse-and-tweak, with no keyboard or extra indev required.
  • navigate_sign: 1 (default) or -1 to flip which way focus travels. Separate from adjust_sign because 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_asyncApplies to
False (the desktop default)MCUs, MicroPython Unix, CPython Linux — uses multimer.auto.Timer
TruePyScript, 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.

See also