Skip to content
This repository was archived by the owner on Oct 28, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ passes `--timer-async`.
### Architecture note: timers and refresh

- Non-LVGL examples instantiate `appdev.App(board_config)`.
LVGL's frozen/bundled `display_driver` owns an independent coordinator and
does not import `appdev`. Both consume neutral board-config callables and
LVGL's `display_driver` (pydevices) builds its own coordinator on
`appdev.App`. Both consume neutral board-config callables and
use `multimer`; display drivers remain policy-free.

### MCU: no `_thread` for network / blocking work
Expand All @@ -141,11 +141,12 @@ App pattern: queue work and drain on the main tick — see `roku_widgets` /
- Install the CPython LVGL binding from TestPyPI (import name `lvgl`):
`.venv/bin/pip install -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ pydevices-lvgl`
(see https://github.com/PyDevices/lvgl-python). The update script installs it.
- `display_driver` (frozen in MP/CP LVGL firmwares; bundled with `pydevices-lvgl`)
owns the LVGL `event_loop` (tick via `app.on_tick`, `asyncio` from
`multimer`) and claims app display refresh so LVGL presents frames from
`task_handler`. SoT: [lvgl-bindings](https://github.com/PyDevices/lvgl-bindings)
`python/display_driver.py` — not shipped from this repo.
- `display_driver` owns the LVGL `event_loop` (tick via `app.on_tick`,
`asyncio` from `multimer`) and claims app display refresh so LVGL presents
frames from `task_handler`. SoT:
[pydevices](https://github.com/PyDevices/pydevices) `lib/display_driver.py`,
shipped with pydevices (mip, wheel, frozen) — not from this repo, and since
2026-10 not from lvgl-bindings either.
- Test LVGL timers with `tools/lv_timer_test_kit.py` (modes: `sync`, `async`).
Headless: `SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy .venv/bin/python tools/lv_timer_test_kit.py --only cpython-venv`.
- Non-obvious: the sync `multimer.auto.Timer` provider on CPython/Linux delivers via a
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ a single `pydevices` package, not one per component.

- [pydevices](https://github.com/PyDevices/pydevices) — canonical product source and core driver package home
- [mip](https://github.com/PyDevices/mip) — PyDevices MIP index fork
- [lvgl-bindings](https://github.com/PyDevices/lvgl-bindings) — shared LVGL binding and `display_driver` source
- [lvgl-bindings](https://github.com/PyDevices/lvgl-bindings) — shared LVGL binding source (LVGL's `display_driver` is in pydevices)
- [lvgl-micropython](https://github.com/PyDevices/lvgl-micropython),
[lvgl-circuitpython](https://github.com/PyDevices/lvgl-circuitpython), and
[lvgl-python](https://github.com/PyDevices/lvgl-python) — interpreter-specific LVGL distributions
Expand Down
240 changes: 197 additions & 43 deletions lib/examples/lv_test_timer.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,19 @@
LVGL timer smoke test. Uses whatever timer mode ``board_config`` / ``app``
already has.

Shows interpreter, OS, display, timer backend, and LVGL version; a seconds counter
and spinning arc prove LVGL timers fire; a tap button exercises input.
Two cards. One says what this run is: the interpreter and OS, the display
driver, the screen (size, rotation and, on a desktop, the window's scale), the
timer's wake source, and LVGL's version. The other holds every moving part, so
the dirty areas stay together: an arc that turns once a second, stepping
every frame at the display's refresh period, with the seconds counted in its
middle (if the two timers drift apart, it shows), the display's frame rate (``display_drv.measure_fps``), and a tap
button for input. The frame rate is also in kit mode's ``KIT_RESULT`` line as
``fps``.

The layout fits any screen from 240x240 to 1280x720 with two rules: the cards
sit side by side only when the screen is more than 1.5 times wider than tall,
and the font is the largest built-in size at or below a twentieth of the
screen's short side.

Interactive (default): build the UI and let the app run itself — no trailing
``app.run()``. At a REPL the prompt comes back for introspection while LVGL
Expand Down Expand Up @@ -59,6 +70,17 @@
_DURATION_S = 4
_RESULT_PREFIX = "KIT_RESULT="

# A displaydev older than measure_fps has no meter: the label says so.
_measure = getattr(display_drv, "measure_fps", None)
if _measure is not None:
_measure(True)


def get_fps():
"""The display's frame-rate dict, or None without a meter."""
fps = getattr(display_drv, "fps", None)
return fps() if fps is not None else None


def _mode_label():
return multimer.info().get("source") or "sync"
Expand Down Expand Up @@ -124,9 +146,13 @@ def _lvgl_label():


def _timer_type():
# multimer has one Timer class; what differs per host is the wake source.
"""multimer's wake source on this host (machine, native, signal, pending,
asyncio, wasm or none). Callbacks run between bytecodes almost everywhere,
so the delivery is shown only when it's the other kind, ``idle``, where
they run only when the program yields to its loop (asyncio, a browser)."""
info = multimer.info()
return "%s/%s" % (info.get("source"), info.get("delivery"))
source = info.get("source")
return source if info.get("delivery") != "idle" else "%s, idle" % source


def get_platform_info():
Expand All @@ -141,29 +167,50 @@ def get_platform_info():
"lvgl": _lvgl_label(),
"mode": _mode_label(),
"rotation": int(getattr(display_drv, "rotation", 0) or 0),
# a desktop window's scale (after fitting the desktop); boards have none
"scale": getattr(display_drv, "scale", None) or getattr(display_drv, "_scale", None),
}


def timer_backend_name():
return get_platform_info()["timer"]


def _add_info_labels(scr, info, y_start=26, line_h=16):
lines = (
f"Mode: {info['mode']}",
f"Interpreter: {info['interpreter']}",
f"OS: {info['os']}",
f"Display: {info['display']} {info.get('resolution', '?')}",
f"Timer: {info['timer']}",
f"LVGL: {info['lvgl']}",
f"Rotation: {info.get('rotation', 0)}",
_FONT_SIZES = (14, 16, 24, 32, 40)


def _font_for(short_side):
"""The largest built-in Montserrat at or below a twentieth of the short side."""
best = None
for size in _FONT_SIZES:
font = getattr(lv, "font_montserrat_%d" % size, None)
if font is not None and (best is None or size <= short_side // 20):
best = font
return best


def _fps_short(split=False):
"""The frame rate, the time each present takes, and the share of time
spent presenting; on two lines when it sits beside the arc."""
s = get_fps()
if s is None:
return "fps n/a"
return "%.1f fps%s%.1f ms, %d%% busy" % (
s["fps"],
"\n" if split else ", ",
s["present_ms"],
round(s["busy"] * 100),
)
y = y_start
for text in lines:
lbl = lv.label(scr)
lbl.set_text(text)
lbl.align(lv.ALIGN.TOP_MID, 0, y)
y += line_h


def _card(parent, flow, pad):
c = lv.obj(parent)
c.set_style_pad_all(pad, 0)
c.set_style_pad_gap(pad // 2, 0)
c.set_flex_flow(flow)
c.remove_flag(lv.obj.FLAG.SCROLLABLE)
c.set_flex_grow(1)
return c


def build_ui():
Expand All @@ -173,57 +220,163 @@ def build_ui():
_taps = 0
_arc_angle = 0

# Pause shared LVGL task_handler while constructing widgets (not re-entrant).
import display_driver

inst = display_driver.event_loop.current_instance()
if inst is not None:
inst.disable()
try:
scr = lv.screen_active()
info = get_platform_info()

title = lv.label(scr)
title.set_text("LVGL Timer Test")
title.align(lv.ALIGN.TOP_MID, 0, 8)
_add_info_labels(scr, info)
w = scr.get_width()
h = scr.get_height()
short = min(w, h)
font = _font_for(short)
big = _font_for(short * 2) or font
pad = max(4, short // 40)
try:
th = lv.theme_default_init(
lv.display_get_default(),
lv.palette_main(lv.PALETTE.BLUE),
lv.palette_main(lv.PALETTE.TEAL),
True,
font,
)
lv.display_get_default().set_theme(th)
except AttributeError:
pass
scr.set_style_text_font(font, 0)
scr.set_style_pad_all(pad, 0)
scr.set_style_pad_gap(pad, 0)
scr.remove_flag(lv.obj.FLAG.SCROLLABLE)
landscape = (
w * 2 > h * 3
) # side by side only when the screen is more than 1.5 times wider than tall
scr.set_flex_flow(lv.FLEX_FLOW.ROW if landscape else lv.FLEX_FLOW.COLUMN)

btn = lv.button(scr)
btn.set_size(120, 50)
btn.align(lv.ALIGN.BOTTOM_MID, 0, -30)
btn_lbl = lv.label(btn)
btn_lbl.set_text("Tap me (0)")
btn_lbl.center()
info = get_platform_info()
# The info card: what this run is, read once.
card = _card(scr, lv.FLEX_FLOW.COLUMN, pad)
if landscape:
card.set_height(lv.pct(100))
else:
card.set_width(lv.pct(100))
title = lv.label(card)
title.set_text("LVGL timer test")
title.set_style_text_color(lv.palette_main(lv.PALETTE.BLUE), 0)
rows = (
("Python", "%s, %s" % (info["interpreter"], info["os"])),
("Display", info["display"]),
(
"Screen",
"%s, rot %d%s"
% (
info["resolution"],
info["rotation"],
", x%.3g" % info["scale"] if info["scale"] and info["scale"] != 1 else "",
),
),
("Timer", info["timer"]),
("LVGL", info["lvgl"]),
)
muted = lv.palette_main(lv.PALETTE.GREY)
for key, value in rows:
row = lv.obj(card)
row.remove_style_all()
row.set_size(lv.pct(100), lv.SIZE_CONTENT)
row.set_flex_flow(lv.FLEX_FLOW.ROW)
row.set_flex_align(
lv.FLEX_ALIGN.SPACE_BETWEEN, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER
)
row.set_style_pad_column(pad, 0) # a key never runs into its value
k = lv.label(row)
k.set_text(key)
k.set_style_text_color(muted, 0)
v = lv.label(row)
v.set_text(value)
v.set_long_mode(lv.label.LONG_MODE.DOTS)
v.set_style_max_width(lv.pct(72), 0)

if landscape:
card.set_flex_align(lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.START, lv.FLEX_ALIGN.START)
else:
card.set_flex_grow(0)
card.set_height(lv.SIZE_CONTENT)
# The live card lays out along its own long side: down when it's tall
# (beside the info card, or under it on a portrait screen), across when
# it's wide (under the info card on a square or landscape screen).
across = not landscape and w * 10 >= h * 9

# The live card: every moving part, so the dirty areas stay together.
live = _card(scr, lv.FLEX_FLOW.ROW if across else lv.FLEX_FLOW.COLUMN, pad)
live.set_flex_align(lv.FLEX_ALIGN.SPACE_EVENLY, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER)
if landscape:
live.set_height(lv.pct(100))
else:
live.set_width(lv.pct(100))

arc = lv.arc(scr)
arc.set_size(80, 80)
arc.align_to(btn, lv.ALIGN.OUT_TOP_MID, 0, -8)
arc_d = short // 3 if across else short * 2 // 5
arc = lv.arc(live)
arc.set_size(arc_d, arc_d)
arc.set_bg_angles(0, 360)
arc.set_rotation(270)
arc.set_angles(0, 0)
arc.set_style_arc_width(max(4, arc_d // 12), lv.PART.MAIN)
arc.set_style_arc_width(max(4, arc_d // 12), lv.PART.INDICATOR)
arc.remove_style(None, lv.PART.KNOB)
arc.remove_flag(lv.obj.FLAG.CLICKABLE)

seconds_lbl = lv.label(scr)
seconds_lbl.set_text("Seconds: 0")
seconds_lbl.align_to(arc, lv.ALIGN.OUT_TOP_MID, 0, -4)
seconds_lbl = lv.label(arc)
seconds_lbl.set_style_text_font(big, 0)
seconds_lbl.set_text("0")
seconds_lbl.center()

side = live
if across:
# beside the arc: the frame rate over the button
side = lv.obj(live)
side.remove_style_all()
side.set_size(lv.SIZE_CONTENT, lv.SIZE_CONTENT)
side.set_flex_flow(lv.FLEX_FLOW.COLUMN)
side.set_flex_align(lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER)
side.set_style_pad_gap(pad, 0)
fps_lbl = lv.label(side)
fps_lbl.set_style_text_color(muted, 0)
fps_lbl.set_style_text_align(lv.TEXT_ALIGN.CENTER, 0)
fps_lbl.set_text(_fps_short(across))

btn = lv.button(side)
btn.set_style_pad_hor(pad * 2, 0)
btn.set_style_pad_ver(pad, 0)
btn_lbl = lv.label(btn)
btn_lbl.set_text("Tap 0")
btn_lbl.center()

def on_seconds_timer(_t):
global _seconds
_seconds += 1
seconds_lbl.set_text(f"Seconds: {_seconds}")
seconds_lbl.set_text(str(_seconds))
fps_lbl.set_text(_fps_short(across))

# The arc steps at the display's refresh period (33 ms unless the
# display or PYDEVICES_REFRESH_MS says otherwise), so it changes every
# frame and the frame rate shown is the display's, not this app's pace.
# Its angle comes from the clock: one turn a second at any period, in
# step with the seconds counter, which shows if the two timers drift.
frame_ms = int(getattr(display_drv, "refresh_period_ms", 0) or 33)
t0 = multimer.ticks_ms()

def on_arc_timer(_t):
global _arc_angle
_arc_angle = (_arc_angle + 10) % 360
ms = multimer.ticks_diff(multimer.ticks_ms(), t0) % 1000
_arc_angle = ms * 360 // 1000
arc.set_angles(0, _arc_angle)

def on_click(_e):
global _taps
_taps += 1
btn_lbl.set_text(f"Tap me ({_taps})")
btn_lbl.set_text("Tap %d" % _taps)

lv.timer_create(on_seconds_timer, 1000, None)
lv.timer_create(on_arc_timer, 50, None)
lv.timer_create(on_arc_timer, frame_ms, None)
btn.add_event_cb(on_click, lv.EVENT.CLICKED, None)
return btn
finally:
Expand Down Expand Up @@ -329,6 +482,7 @@ def _emit_result(state, taps):
"backend": timer_backend_name(),
"seconds": seconds,
"taps": taps,
"fps": get_fps(),
}
print(_RESULT_PREFIX + json.dumps(payload, separators=(",", ":")))
sys.stdout.flush()
Expand Down
3 changes: 3 additions & 0 deletions tools/lv_timer_test_kit.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ def summarize(result: dict | None, returncode: int, timed_out: bool) -> str:
if status == "skip":
return "NA"
if status == "ok":
fps = result.get("fps")
if isinstance(fps, dict):
return f"{backend}, ok, {fps.get('avg_fps', 0):.1f} fps"
return f"{backend}, ok"
if status == "error":
return f"{backend}, error"
Expand Down
1 change: 0 additions & 1 deletion tools/lvgl_input_probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
for _path in (
_workspace + "/pydevices/lib",
_workspace + "/pydevices/tools",
_workspace + "/lvgl-bindings/python",
_root + "/lib",
):
if _path not in sys.path:
Expand Down
1 change: 0 additions & 1 deletion tools/multimer_source_preload.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,6 @@ def _bootstrap_path(source_workspace=False):
"../../pydevices/drivers",
"../../pydevices/utils",
"../../pydevices/lib",
"../../lvgl-bindings/python",
)
)
for directory in directories:
Expand Down
Loading
Loading