diff --git a/AGENTS.md b/AGENTS.md index d90a4f5be..4492de155 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 diff --git a/README.md b/README.md index ece85691e..970b56f28 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/examples/lv_test_timer.py b/lib/examples/lv_test_timer.py index 4385101b7..54172b391 100644 --- a/lib/examples/lv_test_timer.py +++ b/lib/examples/lv_test_timer.py @@ -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 @@ -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" @@ -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(): @@ -141,6 +167,8 @@ 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), } @@ -148,22 +176,41 @@ 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(): @@ -173,7 +220,6 @@ 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() @@ -181,49 +227,156 @@ def build_ui(): 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: @@ -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() diff --git a/tools/lv_timer_test_kit.py b/tools/lv_timer_test_kit.py index 1cb9aa92a..f23c9dc80 100755 --- a/tools/lv_timer_test_kit.py +++ b/tools/lv_timer_test_kit.py @@ -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" diff --git a/tools/lvgl_input_probe.py b/tools/lvgl_input_probe.py index 5d51f157e..018f3502d 100755 --- a/tools/lvgl_input_probe.py +++ b/tools/lvgl_input_probe.py @@ -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: diff --git a/tools/multimer_source_preload.py b/tools/multimer_source_preload.py index ee864dbf3..1e8fabfd7 100755 --- a/tools/multimer_source_preload.py +++ b/tools/multimer_source_preload.py @@ -81,7 +81,6 @@ def _bootstrap_path(source_workspace=False): "../../pydevices/drivers", "../../pydevices/utils", "../../pydevices/lib", - "../../lvgl-bindings/python", ) ) for directory in directories: diff --git a/tools/run_cast_example.py b/tools/run_cast_example.py index 4103e26cb..7a2932368 100644 --- a/tools/run_cast_example.py +++ b/tools/run_cast_example.py @@ -4,8 +4,10 @@ The first argument is the method's board_config folder in lib/examples (roku, miracast or roku_hls). The example runs unchanged; after SECONDS -(default 30) one CAST line reports the display's stats(), and it reports -again every SECONDS after that until the example ends or is stopped. +(default 30) one CAST line reports the display's stats() and its frame +rate (displaydev's measure_fps: show_fps, show_frames, show_ms_mean, +show_ms_max, show_busy), and it reports again every SECONDS after that until +the example ends or is stopped. On a board, set CFG, EXAMPLE and SECONDS below and run it with mpftp; the line also goes to /cast.txt, written once, after the first report. @@ -56,10 +58,15 @@ def report(): try: import board_config - st = board_config.display_drv.stats() - if _shows[0]: - st["show_ms_mean"] = _shows[1] / _shows[0] - st["show_ms_max"] = _shows[2] + d = board_config.display_drv + st = d.stats() + fps = d.fps() if hasattr(d, "fps") else None + if fps and fps["frames"]: + st["show_fps"] = fps["avg_fps"] + st["show_frames"] = fps["frames"] + st["show_ms_mean"] = fps["present_ms"] + st["show_ms_max"] = fps["present_ms_max"] + st["show_busy"] = fps["busy"] line = "CAST %s %s %s %s %s" % ( cfg, example, @@ -92,26 +99,15 @@ def report(): machine.soft_reset() -_shows = [0, 0.0, 0.0] # count, total ms, longest ms - - -def time_shows(): - """Every show() timed, for the report: where an app's frame time goes.""" +def measure_shows(): + """The display's frame meter, for the report: where an app's frame time goes.""" import board_config - d = board_config.display_drv - show = d.show - - def timed(*args, **kwargs): - t = now() - r = show(*args, **kwargs) - ms = (now() - t) * 1000 - _shows[0] += 1 - _shows[1] += ms - _shows[2] = max(_shows[2], ms) - return r - - d.show = timed + measure = getattr(board_config.display_drv, "measure_fps", None) + if measure is None: + print("run_cast_example: this displaydev has no measure_fps; no show_* fields") + else: + measure(True) def report_loop(): @@ -168,7 +164,7 @@ def timed_show(*args, **kwargs): from utils import roku_companion roku_companion.ROKU_IP = target -time_shows() +measure_shows() if board: # an LVGL app holds a board's interpreter and starves a reporting thread report_from_show() diff --git a/tools/timing_bench/README.md b/tools/timing_bench/README.md index ad6566623..eed5db73d 100644 --- a/tools/timing_bench/README.md +++ b/tools/timing_bench/README.md @@ -17,8 +17,8 @@ run at a time, nothing else running. `lv_pace.py` builds an LVGL screen with an arc moved by a 16 ms LVGL timer, records `REFR_READY` events for `PACE_MS` (3000) and prints one `PACE=` line. `PACE_API=OLD|NEW`, `PACE_STATIC=1` for the static-screen case. The -path also has `lvgl-bindings/python` (the driver under test) and -`pydevices/board_configs/desktop` on it. Both interpreters were run +path also had `lvgl-bindings/python` (the driver under test, which has lived in +`pydevices/lib` since 2026-10) and `pydevices/board_configs/desktop` on it. Both interpreters were run headless with SDL's dummy video driver. Results: