Skip to content

Commit

Permalink
AP_Scripting: add REPL applet
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
tpwrules committed Aug 3, 2024
1 parent a8b0e12 commit 58b2f20
Show file tree
Hide file tree
Showing 2 changed files with 421 additions and 0 deletions.
358 changes: 358 additions & 0 deletions libraries/AP_Scripting/applets/repl.lua
Original file line number Diff line number Diff line change
@@ -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()
Loading

0 comments on commit 58b2f20

Please sign in to comment.