Test scripts are written in Lua 5.4 and automate simulator interaction for testing. Pass a script file with --script:
edgetx-cli dev simulator --radio "Radiomaster TX16S" --headless --script test.lua --timeout 30s --screenshot result.png| Function | Description |
|---|---|
key.press(key) |
Tap: key down, 100ms pause, key up |
key.longpress(key) |
Long press: key down, 1s pause, key up |
key.down(key) |
Hold a key down |
key.up(key) |
Release a key |
Key arguments accept a KEY constant or a string: key.press(KEY.ENTER) or key.press("ENTER").
KEY constants: KEY.MENU, KEY.EXIT, KEY.ENTER, KEY.PAGEUP, KEY.PAGEDN, KEY.UP, KEY.DOWN, KEY.LEFT, KEY.RIGHT, KEY.PLUS, KEY.MINUS, KEY.MODEL, KEY.TELE, KEY.SYS
| Function | Description |
|---|---|
touch.tap(x, y) |
Tap: touch down, 100ms pause, touch up |
touch.longpress(x, y) |
Long press: down, 1s pause, up |
touch.down(x, y) |
Hold touch at coordinates |
touch.release() |
Release touch |
| Function | Description |
|---|---|
trim.press(name) |
Tap: down, 100ms pause, up |
trim.longpress(name) |
Long press: down, 1s pause, up |
trim.down(name) |
Hold trim button |
trim.up(name) |
Release trim button |
trim.get(name) |
Get current trim value |
trim.set(name, value) |
Set trim to an exact value |
trim.range() |
Get trim min/max limits (returns two values) |
Trim arguments accept a TRIM constant, string name, or raw index: trim.press(TRIM.T1) or trim.press("T1").
| Function | Description |
|---|---|
switch(name, state) |
Set switch position (-1, 0, 1) |
analog(name, value) |
Set analog input (0-4096) |
rotary(delta) |
Rotary encoder delta |
Switch and analog accept a SWITCH/INPUT constant, string name, or raw index: switch(SWITCH.SA, -1) or switch("SA", -1).
SWITCH / INPUT / TRIM constants are radio-specific and auto-populated from the radio definition (e.g., SWITCH.SA, SWITCH.SB, INPUT.LH, INPUT.P1, TRIM.T1, TRIM.T4).
| Function | Description |
|---|---|
channel.get(index) |
Get channel output value (1-based, -1024 to 1024) |
channel.mixer(index) |
Get mixer output value (1-based) |
channel.count() |
Number of output channels |
channel.used(index) |
Whether channel is in use (boolean, 1-based) |
channel.mix_count() |
Number of active mixes in the model |
| Function | Description |
|---|---|
logicalswitch.get(index) |
Get logical switch state (true/false, 1-based) |
logicalswitch.count() |
Number of logical switches |
| Function | Description |
|---|---|
gvar.get(gvar, flightmode) |
Get GVar value (both 1-based) |
gvar.count() |
Number of global variables |
gvar.flightmodes() |
Number of flight modes |
gvar.flightmode() |
Current active flight mode (0-based) |
Example:
-- Read all active logical switches
for i = 1, logicalswitch.count() do
if logicalswitch.get(i) then
print("L" .. i .. " is ON")
end
end
-- Read channel outputs
for i = 1, channel.count() do
if channel.used(i) then
print("CH" .. i .. " = " .. channel.get(i))
end
endColor-LCD radios expose their live LVGL widget tree. It is the basis for structural UI checks (which texts are on screen, what has focus, where a button is) without pixel matching. B&W radios draw straight into the framebuffer and have no tree; screen.* raises an error there.
| Function | Description |
|---|---|
screen.get([opts]) |
UI tree as a nested Lua table |
screen.json([opts]) |
UI tree as a JSON string |
screen.dump(path[, opts]) |
Write the UI tree JSON to a file |
wait_ready([timeout]) |
Block until the firmware has booted, its Lua interpreter is running and the UI loop is cycling (default timeout 10 s) |
opts is an optional table: hidden = false omits hidden subtrees (by default they are included and marked hidden); styles = true adds a style table per node (colors, font height, padding).
The firmware takes the snapshot on its GUI task between two LVGL cycles, so the tree is always self-consistent. A call takes up to one GUI cycle (about 50 ms).
The root is a pseudo node of type root. Its meta table carries lvgl (version), tick (firmware milliseconds), flags, focus (id of the focused widget, when any) and editing (true while the focused widget is in edit mode). The root's children are the LVGL layers, tagged layer = "screen" (the current page), "top" (keyboards, popups, dialogs) and "sys".
Every node has:
| Field | Meaning |
|---|---|
id |
Widget identity, stable while the widget exists |
type |
obj, window, label, btn, textarea, keyboard, btnmatrix, checkbox, switch, slider, bar, arc, table, img, canvas, line, tileview, tile |
x, y, w, h |
Absolute screen rectangle in pixels |
children |
Child nodes in draw order (absent on leaves) |
Boolean attributes are present only when true: visible (not hidden and inside the visible area after clipping), hidden, clickable, scrollable, checkable, floating, focused, focus_key, edited, checked, pressed, disabled, scrolled. Scrolled containers carry scroll = {x, y}.
Type-specific fields:
| Type | Fields |
|---|---|
label, checkbox |
text |
textarea |
text, cursor, password |
btnmatrix, keyboard |
buttons (list; "\n" marks a row break), selected, selected_text; keyboards add mode and textarea (id) |
slider, bar, arc |
value, min, max |
table |
rows, cols, cells (list of rows of strings), row/col (selected cell) |
img |
src (file path or symbol; absent for in-memory bitmaps) |
tileview |
tile (id of the active tile) |
EdgeTX's own widgets are built from these primitives: a Choice is an obj holding an img and a label, a TextButton is a btn holding a label, pages and dialogs are window nodes.
Example:
wait_ready(10)
local function find(node, pred, out)
out = out or {}
if pred(node) then out[#out + 1] = node end
for _, child in ipairs(node.children or {}) do find(child, pred, out) end
return out
end
local tree = screen.get()
for _, l in ipairs(find(tree, function(n) return n.type == "label" and n.visible end)) do
print(l.text, l.x, l.y)
end
-- tap the first visible button labelled "Save"
local save = find(tree, function(n)
return n.type == "btn" and n.visible
and #find(n, function(c) return c.text == "Save" end) > 0
end)[1]
if save then touch.tap(save.x + save.w // 2, save.y + save.h // 2) end
screen.dump("ui.json") -- keep a copy for offline inspection| Function | Description |
|---|---|
wait(seconds) |
Wait for a duration (float, in seconds) |
wait_ready([timeout]) |
Wait until the firmware has booted and its UI is responsive |
screenshot(path) |
Save LCD framebuffer as PNG |
reset() |
Full simulator restart — reloads all scripts, widgets, and resets screen |
reload() |
Reload Lua scripts from SD card (mix, function, telemetry — not widgets) |
exit(code) |
Exit with a process exit code |
print(...) |
Debug logging (Lua standard library) |
Scripts return exit code 0 by default. Use exit(code) to terminate early with a specific exit code. This is useful for CI pipelines where you need to signal pass/fail.
Scripts halt immediately on any error. Error messages include file name, line number, and a description of the problem (e.g., unknown key "BOGUS" (available: MENU, EXIT, ...)). Script errors produce a non-zero exit code.
test.lua:
-- Wait for boot
wait(5)
-- Navigate to the tools menu
key.press(KEY.SYS)
wait(1)
key.press(KEY.PAGEDN)
wait(0.5)
-- Take a screenshot
screenshot("tools-menu.png")edgetx-cli dev simulator \
--radio "Radiomaster TX16S" \
--headless \
--script test.lua \
--timeout 30s \
--screenshot final.pngUse --script - or --script-stdin to read Lua commands from stdin. This enables AI-driven testing and interactive piped scripting. Multi-line constructs (e.g., for/end blocks) are automatically detected and buffered until complete.
# Pipe commands
echo 'print("hello")' | edgetx-cli dev simulator \
--radio "Radiomaster TX16S" --headless --script - --timeout 10s
# Multi-line via stdin
printf 'for i=1,3 do\nprint(i)\nend\n' | edgetx-cli dev simulator \
--radio "Radiomaster TX16S" --headless --script-stdin --timeout 10s
# Exit with a specific code
echo 'exit(42)' | edgetx-cli dev simulator \
--radio "Radiomaster TX16S" --headless --script - --timeout 10s
echo $? # prints 42When an AI agent (e.g. Claude Code) needs to interactively control the simulator — sending commands one at a time, observing the screen, and deciding what to do next — a simple pipe won't work because each shell invocation is a separate process. Instead, use tail -f on a regular file to keep the simulator's stdin open across multiple shell calls.
# Create a fresh (empty) command file and log file.
# Truncate with `: >`, do NOT `touch`: touch leaves any old contents in place,
# and those stale commands would be replayed into the freshly-booted simulator.
: > /tmp/sim-cmds
: > /tmp/sim-log
# Launch the simulator in the background, feeding commands via tail -f.
# `-n 0` makes tail follow from the end, so only lines appended *after* launch
# are sent to stdin — pre-existing content is never replayed.
tail -n 0 -f /tmp/sim-cmds | edgetx-cli dev simulator \
--radio "Radiomaster TX16S" \
--headless \
--script-stdin \
--timeout 120s \
> /tmp/sim-log 2>&1 &
SIM_PID=$!The key insight: each echo 'cmd' >> /tmp/sim-cmds is a separate shell invocation that appends to a regular file. tail -f watches that file for new content and continuously feeds new lines into the simulator's stdin — bridging separate shell calls into one persistent stream.
# Always wait for the simulator to boot before sending navigation commands
echo 'wait_ready(10)' >> /tmp/sim-cmdswait_ready returns once the firmware has booted, its Lua interpreter is running and the UI loop is cycling. Firmware builds without that capability report an error; fall back to wait(3) there.
- Send Lua commands by appending to the command file (see scripting API above)
- Take a screenshot to observe the result
- Read the screenshot PNG to see what happened
- Decide the next action and repeat
# Send a command
echo 'key.press(KEY.SYS)' >> /tmp/sim-cmds
# Wait for the UI to update, then capture a screenshot
echo 'wait(1)' >> /tmp/sim-cmds
echo 'screenshot("/tmp/sim-screen.png")' >> /tmp/sim-cmds
# Give the screenshot time to be written, then read the PNG to observe the result
sleep 1
# (AI agent reads /tmp/sim-screen.png to see the current screen)
# Send the next command based on what was observed
echo 'touch.tap(240, 20)' >> /tmp/sim-cmdsprint() output goes to stdout, which is captured in the log file (/tmp/sim-log). Read the log file to see Lua print output.
For multi-line Lua blocks (e.g. for/end), use a heredoc so all lines arrive together for the block detector:
cat >> /tmp/sim-cmds << 'CMDS'
for i = 1, 5 do
rotary(1)
wait(0.3)
end
CMDS# Tell the simulator to exit with a specific code
echo 'exit(0)' >> /tmp/sim-cmds
# Wait for the process to finish and capture its exit code
wait $SIM_PID
echo $? # prints 0# Kill the simulator if still running
kill $SIM_PID 2>/dev/null
wait $SIM_PID 2>/dev/null
# Remove temp files
rm -f /tmp/sim-cmds /tmp/sim-log /tmp/sim-screen.png-- Helper to navigate down N times
function nav_down(n)
for i = 1, n do
key.press(KEY.DOWN)
wait(0.2)
end
end
wait(3)
key.press(KEY.SYS)
wait(1)
nav_down(5)
screenshot("result.png")A single script demonstrating every available Lua function and constant table: showcase.lua.