-
Notifications
You must be signed in to change notification settings - Fork 17.6k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
2 changed files
with
421 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
Oops, something went wrong.