mirror of https://github.com/ArduPilot/ardupilot
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:
parent
87d2b017aa
commit
a164abaafb
|
@ -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()
|
|
@ -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.
|
Loading…
Reference in New Issue