Test scripts are written in Python (PocketPy) and have access to the lvv module.
PocketPy includes json and math from the standard library.
import lvv
import jsonFind a widget by its name. Returns a JSON string with widget info, or None if not found.
btn = lvv.find("btn_ok")
if btn is not None:
info = json.loads(btn)
print(info["x"], info["y"], info["width"], info["height"])Returned JSON fields: name, type, x, y, width, height, visible, clickable, auto_path, text.
Find the first widget matching a multi-property selector. Returns JSON string or None.
Selector syntax: comma-separated key=value pairs. All must match.
Supported keys: type, name, text, visible, clickable, auto_path.
Boolean keys (visible, clickable) accept true, false, 1, or 0. Other values raise ValueError.
Values may contain commas and = characters — the parser only splits on , when followed by a recognized key. For example, text=Hello, world,type=label correctly parses as two pairs.
Caveat: A typo in a key name after a valid clause (e.g. type=button,tyep=label) will be absorbed into the preceding value rather than raising an error. The selector won't match any widget, but no error is reported. To catch typos early, put commonly mistyped clauses first — unknown keys at the start of a selector are always rejected.
btn = lvv.find_by("type=button,text=OK")
btn = lvv.find_by("type=slider,visible=true")
btn = lvv.find_by("name=home_title")
btn = lvv.find_by("text=Price: $1,500,type=label") # comma in text valueFind all widgets matching a selector. Returns a JSON array string (may be empty "[]").
buttons = lvv.find_all_by("type=button,visible=true")
items = json.loads(buttons)
print("Found", len(items), "buttons")Find the deepest widget at screen coordinates. Returns JSON string or None.
widget = lvv.find_at(150, 200)Poll for a widget until found or timeout. Supports both names and selectors
(auto-detected by presence of =). Polls every 100ms.
Raises TimeoutError if not found within the timeout.
# By name
home = lvv.find_with_retry("home_title", 3000)
# By selector
btn = lvv.find_with_retry("type=button,text=Settings", 3000)Get the position and size of a widget as (x, y, width, height).
Raises RuntimeError if the widget is not found.
x, y, w, h = lvv.widget_coords("brightness_slider")
lvv.drag(x + 5, y + h // 2, x + w - 5, y + h // 2, 400)Get a flat list of all widgets in the tree. Returns a JSON array string.
all_w = json.loads(lvv.get_all_widgets())
for w in all_w:
print(w["name"], w["type"])Get the full widget tree hierarchy as a JSON string.
Get all properties of a widget by name. Returns a JSON string with target-dependent properties.
Get display dimensions and color format.
info = json.loads(lvv.screen_info())
print(info["width"], info["height"], info["color_format"])Click a widget by name. Verifies the click actually reached the target widget — raises
AssertionError if the click was intercepted by another widget (e.g. a modal dialog).
lvv.click("btn_ok")Settle barrier: drains all immediate LVGL work caused by previous commands. Runs the LVGL event loop repeatedly until the widget tree is stable across two consecutive passes (or a safety cap of 50 iterations is reached).
Does not wait for animations or async app transitions — use lvv.wait_for()
for those.
Pattern for deterministic tests:
# Immediate effect (show/hide, layout change):
lvv.click("btn_ok")
lvv.sync()
lvv.assert_visible("result_label")
# Async transition (screen change with animation):
lvv.click("btn_settings")
lvv.sync()
lvv.wait_for("settings_screen", 2000)Click at screen coordinates.
lvv.click_at(150, 200)Begin a pointer press at coordinates.
Release the pointer.
Move pointer to coordinates (drag while pressed).
Swipe between two points.
lvv.swipe(100, 200, 300, 200, 400)Press, hold for duration, then release. Triggers LV_EVENT_LONG_PRESSED.
widget = lvv.find("my_button")
w = json.loads(widget)
lvv.long_press(w["x"] + w["width"] // 2, w["y"] + w["height"] // 2, 800)Press at start, move in interpolated steps to end, then release. Use for sliders, scrolling, list reordering.
# Drag a slider from left to right
s = json.loads(lvv.find("brightness_slider"))
cy = s["y"] + s["height"] // 2
lvv.drag(s["x"] + 5, cy, s["x"] + s["width"] - 5, cy, 400)Type text into the focused widget.
lvv.click("input_field")
lvv.type_text("Hello World")Send a key event.
Supported keys: UP, DOWN, LEFT, RIGHT, ENTER, ESC, BACKSPACE, NEXT, PREV.
lvv.key("ENTER")
lvv.key("ESC")Sleep for a duration in milliseconds. Supports cancellation.
lvv.wait(300)Wait until a widget is visible. Supports both names and selectors.
Raises TimeoutError on failure.
lvv.wait_for("settings_screen", 2000)
lvv.wait_for("name=settings_title", 3000)Wait until a widget property equals an expected value.
Raises TimeoutError on failure.
lvv.wait_until("counter_label", "text", "5", 3000)Assert that a widget exists and is visible. Raises AssertionError on failure.
lvv.assert_visible("home_screen")lvv.assert_hidden(name)
Assert that a widget is either missing or hidden. Raises AssertionError on failure.
lvv.assert_hidden("dialog_overlay")Assert that a widget property equals an expected string value.
Raises AssertionError on mismatch, RuntimeError if property doesn't exist.
lvv.assert_value("status_label", "text", "Ready")Assert that a numeric property is within a range (inclusive).
Raises AssertionError if out of range or not a number.
lvv.assert_range("brightness_slider", "value", 0, 100)
lvv.assert_range("volume_slider", "value", 50, 80)Assert that a property value matches a regex pattern (search, not full match).
Raises AssertionError on mismatch, ValueError for invalid regex.
lvv.assert_match("status_label", "text", "^Ready")
lvv.assert_match("version_label", "text", r"\d+\.\d+\.\d+")Assert that a boolean property is true (matches "true" or "1").
lvv.assert_true("wifi_switch", "checked")
lvv.assert_true("btn_ok", "clickable")Assert that a boolean property is false (matches "false" or "0").
lvv.assert_false("wifi_switch", "checked")
lvv.assert_false("disabled_btn", "clickable")Compare widget tree structure — catches missing widgets, wrong hierarchy, property changes that visual regression misses when the layout looks the same.
Compare the widget tree against a reference JSON file.
On first run (no reference), saves the current tree and returns True.
On subsequent runs, compares and raises AssertionError with a detailed diff.
By default compares: type, name, text, visible, clickable, and children.
Ignores: x, y, width, height, id, auto_path (layout-dependent).
Named children are matched by name (order-independent).
# Full tree, structure only
lvv.assert_tree("home_tree.json")
# Only the settings screen subtree
lvv.assert_tree("settings_tree.json", "settings_screen")
# Include geometry (exact match)
lvv.assert_tree("home_layout.json", "", True)
# Subtree with geometry and 5px tolerance
lvv.assert_tree("settings_layout.json", "settings_screen", True, 5)| Argument | Default | Description |
|---|---|---|
ref_path |
required | Reference JSON file (relative to --ref-images) |
root |
"" |
Widget name for subtree ("" = full tree) |
include_geometry |
False |
True to compare x/y/width/height |
tolerance |
0 |
Pixel tolerance for geometry comparison |
Save the normalized widget tree to a JSON file (for manual inspection or custom comparison).
lvv.save_tree("/tmp/current_tree.json")Save the current screen to a PNG file.
lvv.screenshot("/tmp/current.png")Compare current screen against a reference image.
On first run (no reference), saves the current screen as the reference and returns True.
Relative paths are resolved against the --ref-images directory (default: ref_images/).
lvv.screenshot_compare("home_screen.png", 0.1)
lvv.screenshot_compare("settings_configured.png", 0.5)Like screenshot_compare but with ignore regions. The third argument is a JSON array
string of [x, y, width, height] rectangles to exclude from comparison.
# Ignore a 100x30 timestamp area at top-right and a 50x50 animation at (200,100)
ignore = "[[700, 0, 100, 30], [200, 100, 50, 50]]"
lvv.screenshot_compare_ex("home.png", 0.1, ignore)Capture LV_LOG output from the target. Logs are stored in a ring buffer (64 entries max).
Enable or disable log capture on the target.
lvv.set_log_capture(True)
# ... do things that produce log output ...Get captured logs as a JSON string with a "logs" array.
import json
lvv.set_log_capture(True)
lvv.click("btn_settings")
lvv.wait(500)
logs = json.loads(lvv.get_logs())
for entry in logs["logs"]:
print(entry)Clear the log buffer on the target.
Get performance metrics from the target as a JSON string.
import json
metrics = json.loads(lvv.get_metrics())
print("Poll rate:", metrics["poll_rate"], "Hz")
print("Uptime:", metrics["uptime_ms"], "ms")Returned fields: poll_rate (spy loop iterations per second), uptime_ms.
Ping the target. Returns the spy version string.
Load a JSON object map (logical name -> physical name mapping). Returns the number of entries loaded.
lvv.load_object_map("object_map.json")
# Now lvv.click("login_button") resolves through the mapThe LVGL spy reports widget types as short names:
| LVGL Class | Type name in lvv |
|---|---|
lv_obj |
obj |
lv_btn |
button |
lv_label |
label |
lv_slider |
slider |
lv_switch |
switch |
lv_checkbox |
checkbox |
lv_dropdown |
dropdown |
lv_list |
list |
lv_msgbox |
msgbox |
lv_textarea |
textarea |
Use these short names in selectors: type=button, not type=lv_btn.
import lvv
import json
# Navigate to settings screen
lvv.click("btn_settings")
lvv.wait_for("settings_screen", 2000)
# Verify title is visible
lvv.assert_visible("settings_title")
# Drag brightness slider to max
slider = lvv.find("brightness_slider")
assert slider is not None, "Slider not found"
s = json.loads(slider)
cy = s["y"] + s["height"] // 2
lvv.drag(s["x"] + 5, cy, s["x"] + s["width"] - 5, cy, 400)
lvv.wait(200)
# Find all visible buttons
buttons = json.loads(lvv.find_all_by("type=button,visible=true"))
print("Found", len(buttons), "buttons")
# Visual check
lvv.screenshot_compare("settings_done.png", 0.5)
# Go back
lvv.click("btn_back_settings")
lvv.wait_for("home_screen", 2000)
print("Test passed!")