AP_Scripting: add REPL applet

Implements a reasonably comfortable REPL accessible over serial entirely
as a loadable script.

Also accessible over MAVLink using QGroundControl's MAVLink Console and
the new mavport module.
This commit is contained in:
Thomas Watson 2024-08-02 22:12:05 -05:00 committed by Andrew Tridgell
parent 87d2b017aa
commit a164abaafb
2 changed files with 421 additions and 0 deletions

View File

@ -0,0 +1,358 @@
-- Interactive REPL (read-evaluate-print-loop) for the Lua scripting engine
-- accessible over serial, with line editing, history, and output formatting.
-- 0-based index of Scripting protocol port to use, or nil to use MAVLink
local PORT_IDX = 0
local MAX_HISTORY = 50 -- number of lines of history to keep (must be >= 1)
local VERSION = "v1.0" -- version is convenience for the user
local port
if PORT_IDX == nil then
port = require("mavport")
else
port = serial:find_serial(PORT_IDX)
end
assert(port, "REPL scripting port not configured")
-- scan through parameters to find our port and grab its baud rate
do
local serial_info = ""
local baud = 115200
if PORT_IDX ~= nil then
local port_num = 0
while PORT_IDX >= 0 and port_num <= 9 do
local protocol = param:get(("SERIAL%d_PROTOCOL"):format(port_num))
port_num = port_num + 1
if protocol == 28 then PORT_IDX = PORT_IDX - 1 end
end
if PORT_IDX == -1 then -- correct port index found
port_num = port_num - 1
baud = param:get(("SERIAL%d_BAUD"):format(port_num)) or 115200
serial_info = (" on SERIAL%d, BAUD=%d"):format(port_num, baud)
end
end
-- if we can't find the right port, the baud probably does not matter
-- (e.g. CAN or network port)
port:begin(baud)
gcs:send_text(6, "Lua REPL "..VERSION.." starting"..serial_info)
end
-- grab things we use from the environment in case the user messes them up
local string = string
local table = table
-- declaration of main state variable and functions
local state_func, state_read, state_eval, state_print
-- write the string s to the port, buffering if not all could be written.
-- buffer starts of with the first message and prompt
local tx_buf = {"\r\n\r\nLua REPL "..VERSION.." started.\r\n> "}
local writestring
if port.writestring then -- use more efficient method if we have it
writestring = function(s)
if tx_buf then -- stuff already in the buffer?
tx_buf[#tx_buf+1] = s -- this needs to go after
else
local written = port:writestring(s)
if written < #s then
-- write was short i.e. port buffer is full. buffer the rest of the
-- string ourselves and transmit it later
tx_buf = { s:sub(written+1) }
end
end
end
else
writestring = function(s)
if tx_buf then -- stuff already in the buffer?
tx_buf[#tx_buf+1] = s -- this needs to go after
else
for ci = 1, #s do
if port:write(s:byte(ci)) == 0 then
-- write failed i.e. port buffer is full. we now buffer the rest of
-- the string ourselves and transmit it later
tx_buf = { s:sub(ci) }
break
end
end
end
end
end
-- don't use print substitute in the REPL's code (e.g. for debugging the REPL)
local print = print -- luacheck: ignore 211 (unused variable warning)
-- substitute print function for within the REPL that prints to the port
function _ENV.print(...)
local t = table.pack(...)
for i = 1, t.n do
writestring(tostring(t[i]))
writestring((i ~= t.n) and "\t" or "\r\n")
end
end
-- write the character c to the port, buffering if failed
local function writechar(c)
-- buffer character if stuff already in buffer or write fails
if tx_buf or port:write(c) == 0 then
tx_buf[#tx_buf+1] = string.char(c) -- massive overhead...
end
end
local function writeobj(o)
if type(o) == "table" then
writestring("{ ")
for k, v in pairs(o) do
if type(k) ~= "number" then k = '"'..k..'"' end
writestring("["..k.."] = ")
writeobj(v)
writestring(", ")
end
writestring("}")
else
writestring(tostring(o))
end
end
local curr_line = nil -- table of line bytes, or nil if viewing history
local curr_pos = 1 -- position the next character will be put at
local curr_esc = nil -- table of escape sequence bytes
local eval_pieces = {} -- pieces of code being evaluated
local history_lines = {""} -- lines in the history (one is always being edited)
local history_pos = 1 -- position in the history being edited
local function writeprompt()
writestring((#eval_pieces > 0) and ">> " or "> ")
end
local function movehistory(dir)
if curr_line then -- current line was edited, store it in history
history_lines[history_pos] = string.char(table.unpack(curr_line))
curr_line = nil
end
history_pos = history_pos + dir -- move to new position
writestring("\x1B[2K\r") -- erase line and return cursor to start
writeprompt() -- draw prompt
local line = history_lines[history_pos] -- and current line from history
writestring(line)
curr_pos = #line + 1
end
local function readesc(c)
assert(curr_esc) -- only called if curr_esc isn't nil
if c == 27 then -- another escape, clear line and buffer and exit escape mode
curr_line = nil
curr_pos = 1
curr_esc = nil
eval_pieces = {}
history_pos = #history_lines
history_lines[history_pos] = ""
writestring("\r\n")
writeprompt()
return
else
curr_esc[#curr_esc+1] = c
end
if curr_esc[1] ~= 91 then -- not a [, exit escape mode
curr_esc = nil
return
end
if #curr_esc < 2 then return end -- command character not yet present
local line_len = #history_lines[history_pos]
if curr_line then line_len = #curr_line end
-- c is now the command character
if c == 65 then -- up
if history_pos > 1 then
movehistory(-1)
end
elseif c == 66 then -- down
if history_pos < #history_lines then
movehistory(1)
end
elseif c == 67 then -- right
if curr_pos < line_len + 1 then
writestring("\x1B[C")
curr_pos = curr_pos + 1
end
elseif c == 68 then -- left
if curr_pos > 1 then
writestring("\x1B[D")
curr_pos = curr_pos - 1
end
elseif c == 72 then -- home
if curr_pos > 1 then
writestring(("\x1B[%dD"):format(curr_pos-1))
curr_pos = 1
end
elseif c == 70 then -- end
if curr_pos < line_len + 1 then
writestring(("\x1B[%dC"):format(line_len-curr_pos+1))
curr_pos = line_len + 1
end
end
curr_esc = nil -- exit escape mode, handling complete
end
local last_c = 0
state_read = function ()
while true do
local c = port:read()
if c == -1 then return end -- no new character, give time for more to come
if curr_esc then -- in escape sequence
readesc(c)
elseif c == 27 then -- escape, start of a control sequence
curr_esc = {} -- engage escape sequence handler
elseif c == 13 or c == 10 then -- line complete
if last_c ~= 13 or c ~= 10 then -- ignore \n after \r
writestring("\r\n")
last_c = c
break
end
elseif c == 8 or c == 127 then -- backspace
if curr_pos > 1 then -- a character to delete?
if curr_line == nil then -- retrieve line for editing
curr_line = table.pack(history_lines[history_pos]:byte(1, -1))
end
table.remove(curr_line, curr_pos-1) -- delete the character
writechar(8) -- back cursor up
curr_pos = curr_pos - 1
if curr_pos <= #curr_line then -- draw characters after deletion point
writestring(string.char(table.unpack(curr_line, curr_pos)))
end
-- blank out trailing character and back cursor up to deletion point
writestring((" \x1B[%dD"):format(#curr_line-curr_pos+2))
end
elseif c >= 32 and c <= 126 then -- a character to type
if curr_line == nil then -- retrieve line for editing
curr_line = table.pack(history_lines[history_pos]:byte(1, -1))
end
table.insert(curr_line, curr_pos, c) -- store character in the line
writechar(c) -- draw the new character
curr_pos = curr_pos + 1
if curr_pos <= #curr_line then -- and any after
writestring(string.char(table.unpack(curr_line, curr_pos)))
-- back cursor up to insertion point
writestring(("\x1B[%dD"):format(#curr_line-curr_pos+1))
end
end
last_c = c
if tx_buf then return end -- give time to flush if buffer full
end
-- loop break, line is complete!
local line = history_lines[history_pos]
if curr_line then
line = string.char(table.unpack(curr_line)) -- store line for processing
curr_line = nil
end
if #line == 0 then -- line is empty, ignore it
writeprompt()
return
end
-- if this line is different to the last one added (one before the last entry)
if history_lines[#history_lines-1] ~= line then
history_lines[#history_lines] = line -- insert it at history end
history_lines[#history_lines+1] = "" -- create empty entry for next line
if #history_lines > MAX_HISTORY then table.remove(history_lines, 1) end
else -- don't create a new entry with a duplicate line
history_lines[#history_lines] = "" -- just clear and reuse the last entry
end
history_pos = #history_lines -- now editing the last entry
curr_pos = 1
eval_pieces[#eval_pieces+1] = line -- evaluate the line
state_func = state_eval
end
local function to_chunk(pieces)
local pos = 1
local function next_piece()
-- going past the last piece returns nil which signals the end
local piece = pieces[pos]
pos = pos + 1
return piece
end
return next_piece
end
local eval_results
state_eval = function ()
local func, err
-- try to compile a single line as "return %s;" assuming it could be an
-- expression. technique borrowed from the official Lua REPL.
if #eval_pieces == 1 then
local expr_pieces = {"return ", eval_pieces[1], ";"}
func = load(to_chunk(expr_pieces), "=input", "t", _ENV)
end
if func == nil then -- compilation unsuccessful, load normally
func, err = load(to_chunk(eval_pieces), "=input", "t", _ENV)
end
-- if there is an error at the end of the statement, assume we need more to
-- complete it. technique borrowed from the official Lua REPL.
-- ignore check since load defines that err is not nil if func is nil
---@diagnostic disable-next-line: need-check-nil
if func == nil and err:sub(-5, -1) == "<eof>" then
-- add a newline and get another line from the user
eval_pieces[#eval_pieces+1] = "\n"
writeprompt()
state_func = state_read
return
end
eval_pieces = {} -- destroy to make room for result
if func == nil then -- result is the load error message
eval_results = { false, err, n = 2 }
else
eval_results = table.pack(pcall(func))
end
state_func = state_print
end
state_print = function ()
for i = 2, eval_results.n do -- skip pcall result
writeobj(eval_results[i]) -- write each result separated by tabs
writestring((i ~= eval_results.n) and "\t" or "\r\n")
eval_results[i] = nil -- destroy to make room for stringified version
end
eval_results = nil
writeprompt()
state_func = state_read -- loop back to read
end
state_func = state_read
local function update()
if tx_buf then -- write out stuff in tx buffer if present
local old_buf = tx_buf
tx_buf = nil
for _, s in ipairs(old_buf) do -- re-write all data
writestring(s)
end
else -- otherwise we have time to process
state_func()
end
---@diagnostic disable-next-line: undefined-field
if PORT_IDX == nil then port:flush() end -- flush MAVLink port if using it
return update, 10
end
return update()

View File

@ -0,0 +1,63 @@
# Lua REPL
This script implements an interactive REPL (read-evaluate-print-loop) for the
Lua scripting engine accessible over serial, with line editing, history, and
output formatting.
The script can also act as a client for QGroundControl's MAVLink Console
functionality (within the Analyze view), subject to limitations detailed
below.
### Basic Usage
* Configure a serial port (e.g. `SERIALn_PROTOCOL`) to protocol 28 (Scripting).
* By default the first such port is used; this can be adjusted in the script
text.
* `SERIAL6` is the alternate USB serial port on Cube Orange, and convenient
for bench testing. CAN and network serial ports will also work.
* Load the `repl.lua` script onto the autopilot.
* Connect a terminal emulator to the port and enter Lua statements/expressions
at the `> ` prompt, then press Enter to execute. Results and errors will be
printed back.
* A `>> ` prompt indicates that more input is needed to complete the
statement.
* You can use the arrow keys to edit the current and previous inputs.
* Press ESC twice to clear the input and any incomplete statement then
return to an empty prompt.
### Autopilot Connection
* On Linux a convenient command is e.g. `minicom -w -D /dev/ttyACM1 -b 115200`,
assuming you have the minicom terminal emulator installed.
* Any terminal emulator on any platform should work; see notes below about
control codes and other configuration.
### SITL Connection
* Start SITL with a command like `Tools/autotest/sim_vehicle.py -A --serialN=tcp:9995:wait` to allow connection to the selected serial port.
* Connect a terminal emulator to localhost TCP port 9995
* On Linux a convenient command is `stty -icanon -echo -icrnl && netcat localhost 9995`.
* Note that you must execute `reset` to turn echo back on once disconnected.
* Scripting must be restarted after a TCP reconnection.
### MAVLink Connection
* Requires at least Ardupilot 4.6.
* Set the port in the script text to `nil` to enable.
* In addition to `repl.lua`, copy the `mavport.lua` file and `MAVLink` directory
from `AP_Scripting/modules` to `APM/SCRIPTS/MODULES` on your autopilot.
* The ESC key is not supported; cause a syntax error to reset the prompt.
* The experience over a radio link might be sub-par due to lack of any sort of
packet loss tracking or retransmission.
### Notes and Limitations
* Statements like `local x = 3` create a variable which immediately goes out of
scope once evaluated. Names must be global to survive to the next prompt.
* There is currently no facility for installing periodic update callbacks.
* While theoretically impossible to accidentally crash the autopilot software,
certain scripting APIs can cause damage to you or your vehicle if used
improperly. Use extreme caution with an armed vehicle!
* The script expects Enter to be `\r`, `\r\n`, or `\n`. It prints `\r\n` for a
new line, and uses ANSI cursor control codes for line editing and history.
Check your terminal configuration if Enter doesn't work or you see garbage
characters. Lines longer than the terminal width likely won't edit properly.
* Evaluating complex statements or printing complex results can cause an
`exceeded time limit` error, stopping the script and losing variables and
history. Increasing the vehicle's `SCR_VM_I_COUNT` parameter reduces the
chance of this occurring.